Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m3s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Sending already switched to the target terminal's tab, which looks like it should be enough: `TerminalView` focuses xterm whenever a terminal becomes active. But that effect keys off `active`, so it only fires on a *change* — and the dock's ordinary case is sending to the terminal already on screen. `setActiveTabKey` writes the key that is already set, nothing changes, no effect re-runs, and focus stays on the Send button. The note is sitting in the prompt and the user still has to click the terminal before pressing Enter. So the send now asks for focus explicitly, through a one-shot request in the store that `TerminalView` consumes and clears — the shape `pendingHomeTab` already uses. Clearing is not tidiness: hold the id and the second send to the same terminal writes a value that is already there, which is precisely the no-op this exists to fix. Focus is requested only on success. A failed send toasts and leaves the user where they are, because there is nothing in the prompt to press Enter on. The three `TerminalView` tests give focus away after mounting before making any assertion, so what they observe is the request landing and never the focus that `active` already grants on mount — which would pass with the feature absent. 752 tests pass, 62 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
553 lines
19 KiB
TypeScript
553 lines
19 KiB
TypeScript
import { create } from "zustand";
|
|
import type {
|
|
Project,
|
|
TerminalSession,
|
|
AppSettings,
|
|
UpdateInfo,
|
|
ImageUpdateInfo,
|
|
Note,
|
|
} from "../lib/types";
|
|
|
|
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
|
|
|
function loadSidebarCollapsed(): boolean {
|
|
try {
|
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function persistSidebarCollapsed(value: boolean) {
|
|
try {
|
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
|
|
} catch {
|
|
// ignore — storage may be unavailable
|
|
}
|
|
}
|
|
|
|
const NOTES_DOCK_KEY = "triple-c.notes.dock";
|
|
const NOTES_DOCK_WIDTH_KEY = "triple-c.notes.dock.width";
|
|
|
|
/** Wide enough for a note, narrow enough to leave a usable terminal. */
|
|
export const NOTES_DOCK_MIN_WIDTH = 260;
|
|
export const NOTES_DOCK_MAX_WIDTH = 720;
|
|
export const NOTES_DOCK_DEFAULT_WIDTH = 352;
|
|
|
|
function loadNotesDockOpen(): boolean {
|
|
try {
|
|
return localStorage.getItem(NOTES_DOCK_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function persistNotesDockOpen(value: boolean) {
|
|
try {
|
|
localStorage.setItem(NOTES_DOCK_KEY, value ? "1" : "0");
|
|
} catch {
|
|
// ignore — storage may be unavailable
|
|
}
|
|
}
|
|
|
|
/** Clamped on the way in as well as out: a stored value can be anything a
|
|
* previous version, a hand edit, or a different screen left behind. */
|
|
export function clampDockWidth(value: number): number {
|
|
if (!Number.isFinite(value)) return NOTES_DOCK_DEFAULT_WIDTH;
|
|
return Math.min(NOTES_DOCK_MAX_WIDTH, Math.max(NOTES_DOCK_MIN_WIDTH, Math.round(value)));
|
|
}
|
|
|
|
function loadNotesDockWidth(): number {
|
|
try {
|
|
const raw = localStorage.getItem(NOTES_DOCK_WIDTH_KEY);
|
|
return raw === null ? NOTES_DOCK_DEFAULT_WIDTH : clampDockWidth(Number(raw));
|
|
} catch {
|
|
return NOTES_DOCK_DEFAULT_WIDTH;
|
|
}
|
|
}
|
|
|
|
function persistNotesDockWidth(value: number) {
|
|
try {
|
|
localStorage.setItem(NOTES_DOCK_WIDTH_KEY, String(value));
|
|
} catch {
|
|
// ignore — storage may be unavailable
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The main area hosts two tab kinds — terminals and Project Home views — in a
|
|
* single ordered strip. Tabs are addressed by a string key so one array can
|
|
* hold both kinds.
|
|
*/
|
|
export const terminalTabKey = (sessionId: string) => `term:${sessionId}`;
|
|
export const homeTabKey = (projectId: string) => `home:${projectId}`;
|
|
export const isTerminalTab = (key: string) => key.startsWith("term:");
|
|
export const isHomeTab = (key: string) => key.startsWith("home:");
|
|
export const tabKeyId = (key: string) => key.slice(key.indexOf(":") + 1);
|
|
|
|
/** activeSessionId is derived from the active tab so exactly one thing is "current". */
|
|
function activation(activeTabKey: string | null) {
|
|
return {
|
|
activeTabKey,
|
|
activeSessionId:
|
|
activeTabKey && isTerminalTab(activeTabKey) ? tabKeyId(activeTabKey) : null,
|
|
};
|
|
}
|
|
|
|
export type ToastKind = "error" | "success" | "info";
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
kind: ToastKind;
|
|
message: string;
|
|
/** Long text (e.g. a bollard error) shown behind a "Details" disclosure. */
|
|
detail?: string;
|
|
/**
|
|
* Optional identity for a *recurring* notice. Pushing another toast with the
|
|
* same key replaces the one already on screen instead of stacking a second
|
|
* copy of it — three refused file drops leave one card, not three.
|
|
*/
|
|
dedupeKey?: string;
|
|
}
|
|
|
|
let toastCounter = 0;
|
|
|
|
interface AppState {
|
|
// Projects
|
|
projects: Project[];
|
|
selectedProjectId: string | null;
|
|
setProjects: (projects: Project[]) => void;
|
|
setSelectedProject: (id: string | null) => void;
|
|
updateProjectInList: (project: Project) => void;
|
|
removeProjectFromList: (id: string) => void;
|
|
|
|
// Terminal sessions
|
|
sessions: TerminalSession[];
|
|
activeSessionId: string | null;
|
|
addSession: (session: TerminalSession) => void;
|
|
removeSession: (id: string) => void;
|
|
setActiveSession: (id: string | null) => void;
|
|
|
|
// Main-area tab strip (terminals + Project Home views)
|
|
tabOrder: string[];
|
|
activeTabKey: string | null;
|
|
openProjectHome: (projectId: string) => void;
|
|
/**
|
|
* Open a project's home tab *on a particular sub-tab*.
|
|
*
|
|
* The sub-tab is local state inside `ProjectHome`, so this parks a request
|
|
* here for it to pick up: an action taken somewhere else entirely — opening a
|
|
* page in the container's browser from a terminal — has to be able to land
|
|
* the user on the pane that shows the result.
|
|
*/
|
|
openProjectHomeTab: (projectId: string, tab: string) => void;
|
|
/** Consumed once by `ProjectHome`, then cleared. */
|
|
pendingHomeTab: { projectId: string; tab: string } | null;
|
|
clearPendingHomeTab: () => void;
|
|
/**
|
|
* Ask a terminal to take keyboard focus.
|
|
*
|
|
* `TerminalView` already focuses when its tab *becomes* active, which covers
|
|
* switching to a terminal. It cannot cover being asked to focus the terminal
|
|
* that is already on screen — nothing changes, so no effect re-runs — and
|
|
* that is the ordinary case for the notes dock, which sits beside the
|
|
* terminal it sends to.
|
|
*
|
|
* Consumed once and cleared, like `pendingHomeTab`: holding the id would
|
|
* make a second request for the same terminal a no-op state write.
|
|
*/
|
|
pendingTerminalFocus: string | null;
|
|
requestTerminalFocus: (sessionId: string) => void;
|
|
clearPendingTerminalFocus: () => void;
|
|
closeHomeTab: (projectId: string) => void;
|
|
setActiveTabKey: (key: string) => void;
|
|
cycleTab: (delta: number) => void;
|
|
focusTabIndex: (index: number) => void;
|
|
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
|
|
moveTab: (key: string, toIndex: number) => void;
|
|
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
|
moveActiveTab: (delta: number) => void;
|
|
|
|
// Per-project notes, cached from the backend.
|
|
//
|
|
// Rust is the source of truth and this is a cache — but it has to be *one*
|
|
// cache. Notes are shown by two surfaces at once (the Project Home sub-tab
|
|
// and the dock, which resolves to the same project), and a hook-local
|
|
// `useState` in each gave them independent copies: an edit made in the dock
|
|
// was invisible to the tab, and the tab's next blur wrote its stale record
|
|
// back over it with no error and no indicator. Keyed by project id so a
|
|
// response that lands after the user has moved on updates the project it
|
|
// belongs to instead of whichever one is on screen.
|
|
//
|
|
// This is also the boundary a detached notes window would need: swap the
|
|
// transport for a `notes-changed` event and both windows feed the same slice.
|
|
notesByProject: Record<string, Note[]>;
|
|
/**
|
|
* Projects with a `list_notes` in flight, so two panels mounting for the
|
|
* same project make one read rather than two, and so a panel whose project
|
|
* has never been read can tell "loading" from "no notes".
|
|
*/
|
|
notesLoading: Record<string, boolean>;
|
|
setProjectNotes: (projectId: string, notes: Note[]) => void;
|
|
setNotesLoading: (projectId: string, loading: boolean) => void;
|
|
|
|
// Inline container progress, replacing the blocking progress modal.
|
|
containerProgress: Record<string, string>;
|
|
setContainerProgress: (projectId: string, message: string | null) => void;
|
|
/** Wall-clock ms when a project was observed transitioning to "running". */
|
|
runningSince: Record<string, number>;
|
|
|
|
// Toasts
|
|
toasts: Toast[];
|
|
pushToast: (toast: Omit<Toast, "id">) => string;
|
|
dismissToast: (id: string) => void;
|
|
|
|
// UI state
|
|
terminalHasSelection: boolean;
|
|
setTerminalHasSelection: (has: boolean) => void;
|
|
// STT toggle for the active session, registered by App so the terminal's
|
|
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
|
sttToggle: () => void;
|
|
setSttToggle: (fn: () => void) => void;
|
|
// Active terminal scroll state, surfaced so the status bar can host the
|
|
// "Jump to Current" control. Only the active TerminalView writes these.
|
|
terminalAtBottom: boolean;
|
|
setTerminalAtBottom: (v: boolean) => void;
|
|
scrollActiveToBottom: () => void;
|
|
setScrollActiveToBottom: (fn: () => void) => void;
|
|
sidebarView: "projects" | "settings";
|
|
setSidebarView: (view: "projects" | "settings") => void;
|
|
sidebarCollapsed: boolean;
|
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
|
toggleSidebarCollapsed: () => void;
|
|
/** The notes dock, visible over any tab including a terminal. */
|
|
notesDockOpen: boolean;
|
|
setNotesDockOpen: (open: boolean) => void;
|
|
toggleNotesDock: () => void;
|
|
/** Dock width in CSS px, clamped and persisted per machine. */
|
|
notesDockWidth: number;
|
|
setNotesDockWidth: (width: number) => void;
|
|
dockerAvailable: boolean | null;
|
|
setDockerAvailable: (available: boolean | null) => void;
|
|
imageExists: boolean | null;
|
|
setImageExists: (exists: boolean | null) => void;
|
|
// App settings
|
|
appSettings: AppSettings | null;
|
|
setAppSettings: (settings: AppSettings) => void;
|
|
|
|
// Update info
|
|
updateInfo: UpdateInfo | null;
|
|
setUpdateInfo: (info: UpdateInfo | null) => void;
|
|
appVersion: string;
|
|
setAppVersion: (version: string) => void;
|
|
|
|
// Image update info
|
|
imageUpdateInfo: ImageUpdateInfo | null;
|
|
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
|
}
|
|
|
|
/** Track running-since transitions so Overview can show an uptime. */
|
|
function trackRunning(
|
|
previous: Project[],
|
|
next: Project[],
|
|
runningSince: Record<string, number>,
|
|
): Record<string, number> {
|
|
let changed = false;
|
|
const result = { ...runningSince };
|
|
const now = Date.now();
|
|
for (const project of next) {
|
|
const before = previous.find((p) => p.id === project.id);
|
|
if (project.status === "running") {
|
|
if (result[project.id] === undefined) {
|
|
result[project.id] = now;
|
|
changed = true;
|
|
}
|
|
} else if (before?.status === "running" || result[project.id] !== undefined) {
|
|
delete result[project.id];
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? result : runningSince;
|
|
}
|
|
|
|
export const useAppState = create<AppState>((set) => ({
|
|
// Projects
|
|
projects: [],
|
|
selectedProjectId: null,
|
|
setProjects: (projects) =>
|
|
set((state) => ({
|
|
projects,
|
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
|
})),
|
|
setSelectedProject: (id) => set({ selectedProjectId: id }),
|
|
updateProjectInList: (project) =>
|
|
set((state) => {
|
|
const projects = state.projects.map((p) =>
|
|
p.id === project.id ? project : p,
|
|
);
|
|
return {
|
|
projects,
|
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
|
};
|
|
}),
|
|
removeProjectFromList: (id) =>
|
|
set((state) => {
|
|
const key = homeTabKey(id);
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[tabOrder.length - 1] ?? null)
|
|
: state.activeTabKey;
|
|
return {
|
|
projects: state.projects.filter((p) => p.id !== id),
|
|
selectedProjectId:
|
|
state.selectedProjectId === id ? null : state.selectedProjectId,
|
|
tabOrder,
|
|
...activation(activeTabKey),
|
|
};
|
|
}),
|
|
|
|
// Terminal sessions
|
|
sessions: [],
|
|
activeSessionId: null,
|
|
addSession: (session) =>
|
|
set((state) => {
|
|
const key = terminalTabKey(session.id);
|
|
return {
|
|
sessions: [...state.sessions, session],
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
...activation(key),
|
|
};
|
|
}),
|
|
removeSession: (id) =>
|
|
set((state) => {
|
|
const key = terminalTabKey(id);
|
|
const index = state.tabOrder.indexOf(key);
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[Math.min(Math.max(index, 0), tabOrder.length - 1)] ?? null)
|
|
: state.activeTabKey;
|
|
return {
|
|
sessions: state.sessions.filter((s) => s.id !== id),
|
|
tabOrder,
|
|
...activation(activeTabKey),
|
|
};
|
|
}),
|
|
setActiveSession: (id) =>
|
|
set(() => activation(id === null ? null : terminalTabKey(id))),
|
|
|
|
// Main-area tabs
|
|
tabOrder: [],
|
|
activeTabKey: null,
|
|
openProjectHome: (projectId) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
return {
|
|
selectedProjectId: projectId,
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
...activation(key),
|
|
};
|
|
}),
|
|
openProjectHomeTab: (projectId, tab) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
return {
|
|
selectedProjectId: projectId,
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
pendingHomeTab: { projectId, tab },
|
|
...activation(key),
|
|
};
|
|
}),
|
|
pendingHomeTab: null,
|
|
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
|
pendingTerminalFocus: null,
|
|
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
|
|
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
|
|
closeHomeTab: (projectId) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
const index = state.tabOrder.indexOf(key);
|
|
if (index === -1) return {};
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[Math.min(index, tabOrder.length - 1)] ?? null)
|
|
: state.activeTabKey;
|
|
return { tabOrder, ...activation(activeTabKey) };
|
|
}),
|
|
setActiveTabKey: (key) =>
|
|
set((state) => {
|
|
if (!state.tabOrder.includes(key)) return {};
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
cycleTab: (delta) =>
|
|
set((state) => {
|
|
if (state.tabOrder.length === 0) return {};
|
|
const current = state.activeTabKey
|
|
? state.tabOrder.indexOf(state.activeTabKey)
|
|
: -1;
|
|
const next =
|
|
(current + delta + state.tabOrder.length) % state.tabOrder.length;
|
|
const key = state.tabOrder[next];
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
focusTabIndex: (index) =>
|
|
set((state) => {
|
|
const key = state.tabOrder[index];
|
|
if (!key) return {};
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
// Reordering is deliberately *only* a reordering: dragging a tab does not
|
|
// select it, so a drag can be aimed at a background tab without yanking the
|
|
// main area (and a running terminal's focus) away mid-gesture.
|
|
moveTab: (key, toIndex) =>
|
|
set((state) => {
|
|
const from = state.tabOrder.indexOf(key);
|
|
if (from === -1) return {};
|
|
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
|
|
if (from === to) return {};
|
|
const tabOrder = [...state.tabOrder];
|
|
tabOrder.splice(from, 1);
|
|
tabOrder.splice(to, 0, key);
|
|
return { tabOrder };
|
|
}),
|
|
moveActiveTab: (delta) =>
|
|
set((state) => {
|
|
const key = state.activeTabKey;
|
|
if (!key) return {};
|
|
const from = state.tabOrder.indexOf(key);
|
|
if (from === -1) return {};
|
|
// Clamped, not wrapped: a tab dragged off the end would otherwise
|
|
// reappear at the other end, which reads as a bug on a held-down key.
|
|
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
|
|
if (from === to) return {};
|
|
const tabOrder = [...state.tabOrder];
|
|
tabOrder.splice(from, 1);
|
|
tabOrder.splice(to, 0, key);
|
|
return { tabOrder };
|
|
}),
|
|
|
|
// Notes
|
|
notesByProject: {},
|
|
notesLoading: {},
|
|
setProjectNotes: (projectId, notes) =>
|
|
set((state) => ({
|
|
notesByProject: { ...state.notesByProject, [projectId]: notes },
|
|
})),
|
|
setNotesLoading: (projectId, loading) =>
|
|
set((state) => {
|
|
if ((state.notesLoading[projectId] ?? false) === loading) return {};
|
|
const next = { ...state.notesLoading };
|
|
if (loading) next[projectId] = true;
|
|
else delete next[projectId];
|
|
return { notesLoading: next };
|
|
}),
|
|
|
|
// Container progress
|
|
containerProgress: {},
|
|
setContainerProgress: (projectId, message) =>
|
|
set((state) => {
|
|
if (message === null) {
|
|
if (state.containerProgress[projectId] === undefined) return {};
|
|
const next = { ...state.containerProgress };
|
|
delete next[projectId];
|
|
return { containerProgress: next };
|
|
}
|
|
if (state.containerProgress[projectId] === message) return {};
|
|
return {
|
|
containerProgress: { ...state.containerProgress, [projectId]: message },
|
|
};
|
|
}),
|
|
runningSince: {},
|
|
|
|
// Toasts
|
|
toasts: [],
|
|
pushToast: (toast) => {
|
|
const id = `toast-${++toastCounter}`;
|
|
set((state) => {
|
|
// A keyed toast supersedes the previous one with that key. The new card
|
|
// gets a new id, so it re-mounts and its dismissal timer restarts.
|
|
const kept = toast.dedupeKey
|
|
? state.toasts.filter((t) => t.dedupeKey !== toast.dedupeKey)
|
|
: state.toasts;
|
|
return { toasts: [...kept, { ...toast, id }] };
|
|
});
|
|
return id;
|
|
},
|
|
dismissToast: (id) =>
|
|
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
|
|
|
|
// UI state
|
|
terminalHasSelection: false,
|
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
|
sttToggle: () => {},
|
|
setSttToggle: (fn) => set({ sttToggle: fn }),
|
|
terminalAtBottom: true,
|
|
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
|
scrollActiveToBottom: () => {},
|
|
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
|
sidebarView: "projects",
|
|
setSidebarView: (view) => set({ sidebarView: view }),
|
|
sidebarCollapsed: loadSidebarCollapsed(),
|
|
setSidebarCollapsed: (collapsed) => {
|
|
persistSidebarCollapsed(collapsed);
|
|
set({ sidebarCollapsed: collapsed });
|
|
},
|
|
toggleSidebarCollapsed: () =>
|
|
set((state) => {
|
|
const next = !state.sidebarCollapsed;
|
|
persistSidebarCollapsed(next);
|
|
return { sidebarCollapsed: next };
|
|
}),
|
|
notesDockOpen: loadNotesDockOpen(),
|
|
setNotesDockOpen: (open) => {
|
|
persistNotesDockOpen(open);
|
|
set({ notesDockOpen: open });
|
|
},
|
|
toggleNotesDock: () =>
|
|
set((state) => {
|
|
const open = !state.notesDockOpen;
|
|
persistNotesDockOpen(open);
|
|
return { notesDockOpen: open };
|
|
}),
|
|
notesDockWidth: loadNotesDockWidth(),
|
|
setNotesDockWidth: (width) => {
|
|
const clamped = clampDockWidth(width);
|
|
persistNotesDockWidth(clamped);
|
|
set({ notesDockWidth: clamped });
|
|
},
|
|
dockerAvailable: null,
|
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
|
imageExists: null,
|
|
setImageExists: (exists) => set({ imageExists: exists }),
|
|
// App settings
|
|
appSettings: null,
|
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
|
|
|
// Update info
|
|
updateInfo: null,
|
|
setUpdateInfo: (info) => set({ updateInfo: info }),
|
|
appVersion: "",
|
|
setAppVersion: (version) => set({ appVersion: version }),
|
|
|
|
// Image update info
|
|
imageUpdateInfo: null,
|
|
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
|
|
}));
|