import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; export type WorkspaceTabTarget = | { kind: "draft"; draftId: string } | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "file"; path: string }; export type WorkspaceTab = { tabId: string; target: WorkspaceTabTarget; createdAt: number; }; function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function normalizeWorkspaceId(value: string): string { return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); } export function buildWorkspaceTabPersistenceKey(input: { serverId: string; workspaceId: string; }): string | null { const serverId = trimNonEmpty(input.serverId); const workspaceId = trimNonEmpty(input.workspaceId); if (!serverId || !workspaceId) { return null; } return `${serverId}:${normalizeWorkspaceId(workspaceId)}`; } function normalizeTabTarget( value: WorkspaceTabTarget | null | undefined, ): WorkspaceTabTarget | null { if (!value || typeof value !== "object" || typeof value.kind !== "string") { return null; } if (value.kind === "draft") { const draftId = trimNonEmpty(value.draftId); return draftId ? { kind: "draft", draftId } : null; } if (value.kind === "agent") { const agentId = trimNonEmpty(value.agentId); return agentId ? { kind: "agent", agentId } : null; } if (value.kind === "terminal") { const terminalId = trimNonEmpty(value.terminalId); return terminalId ? { kind: "terminal", terminalId } : null; } if (value.kind === "file") { const path = trimNonEmpty(value.path); return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; } return null; } function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { if (left.kind !== right.kind) { return false; } if (left.kind === "draft" && right.kind === "draft") { return left.draftId === right.draftId; } if (left.kind === "agent" && right.kind === "agent") { return left.agentId === right.agentId; } if (left.kind === "terminal" && right.kind === "terminal") { return left.terminalId === right.terminalId; } if (left.kind === "file" && right.kind === "file") { return left.path === right.path; } return false; } function buildDeterministicTabId(target: WorkspaceTabTarget): string { if (target.kind === "draft") { return target.draftId; } if (target.kind === "agent") { return `agent_${target.agentId}`; } if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } return `file_${target.path}`; } function normalizeTabOrder(list: unknown): string[] { if (!Array.isArray(list)) { return []; } const next: string[] = []; const used = new Set(); for (const value of list) { const tabId = trimNonEmpty(typeof value === "string" ? value : null); if (!tabId || used.has(tabId)) { continue; } used.add(tabId); next.push(tabId); } return next; } function ensureInOrder(input: { current: string[]; tabId: string }): string[] { if (input.current.includes(input.tabId)) { return input.current; } return [...input.current, input.tabId]; } type WorkspaceTabsState = { uiTabsByWorkspace: Record; tabOrderByWorkspace: Record; focusedTabIdByWorkspace: Record; openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string; }) => string | null; ensureTab: (input: { serverId: string; workspaceId: string; target: WorkspaceTabTarget; }) => string | null; openOrFocusTab: (input: { serverId: string; workspaceId: string; target: WorkspaceTabTarget; }) => string | null; focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; retargetTab: (input: { serverId: string; workspaceId: string; tabId: string; target: WorkspaceTabTarget; }) => string | null; reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; }; export const useWorkspaceTabsStore = create()( persist( (set, get) => ({ uiTabsByWorkspace: {}, tabOrderByWorkspace: {}, focusedTabIdByWorkspace: {}, openDraftTab: ({ serverId, workspaceId, draftId }) => { const normalizedDraftId = trimNonEmpty(draftId); if (!normalizedDraftId) { return null; } return get().openOrFocusTab({ serverId, workspaceId, target: { kind: "draft", draftId: normalizedDraftId }, }); }, ensureTab: ({ serverId, workspaceId, target }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const normalizedTarget = normalizeTabTarget(target); if (!key || !normalizedTarget) { return null; } const deterministicTabId = buildDeterministicTabId(normalizedTarget); let resolvedTabId = deterministicTabId; const now = Date.now(); set((state) => { const currentTabs = state.uiTabsByWorkspace[key] ?? []; const tabWithSameTarget = currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null; const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; resolvedTabId = effectiveTabId; const currentOrder = state.tabOrderByWorkspace[key] ?? []; const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); const nextTabs = (() => { if (existingIndex < 0) { return [ ...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt: now }, ]; } const existing = currentTabs[existingIndex]; if (existing && tabTargetsEqual(existing.target, normalizedTarget)) { return currentTabs; } return currentTabs.map((tab, index) => index === existingIndex ? { ...tab, target: normalizedTarget } : tab, ); })(); return { uiTabsByWorkspace: nextTabs === currentTabs ? state.uiTabsByWorkspace : { ...state.uiTabsByWorkspace, [key]: nextTabs }, tabOrderByWorkspace: nextOrder === currentOrder ? state.tabOrderByWorkspace : { ...state.tabOrderByWorkspace, [key]: nextOrder }, }; }); return resolvedTabId; }, openOrFocusTab: ({ serverId, workspaceId, target }) => { const tabId = get().ensureTab({ serverId, workspaceId, target }); if (!tabId) { return null; } get().focusTab({ serverId, workspaceId, tabId }); return tabId; }, focusTab: ({ serverId, workspaceId, tabId }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const normalizedTabId = trimNonEmpty(tabId); if (!key || !normalizedTabId) { return; } set((state) => { if (state.focusedTabIdByWorkspace[key] === normalizedTabId) { return state; } return { ...state, focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: normalizedTabId, }, }; }); }, closeTab: ({ serverId, workspaceId, tabId }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const normalizedTabId = trimNonEmpty(tabId); if (!key || !normalizedTabId) { return; } set((state) => { const currentTabs = state.uiTabsByWorkspace[key] ?? []; const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId); const currentOrder = state.tabOrderByWorkspace[key] ?? []; const nextOrder = currentOrder.filter((value) => value !== normalizedTabId); const nextUiTabsByWorkspace = nextTabs.length === 0 ? (() => { const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; return rest; })() : nextTabs.length === currentTabs.length ? state.uiTabsByWorkspace : { ...state.uiTabsByWorkspace, [key]: nextTabs }; const nextTabOrderByWorkspace = nextOrder.length === 0 ? (() => { const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; return rest; })() : nextOrder.length === currentOrder.length ? state.tabOrderByWorkspace : { ...state.tabOrderByWorkspace, [key]: nextOrder }; const currentFocused = state.focusedTabIdByWorkspace[key] ?? null; const nextFocused = currentFocused !== normalizedTabId ? currentFocused : (nextOrder[nextOrder.length - 1] ?? null); const nextFocusedByWorkspace = (() => { if (!nextFocused) { const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace; return rest; } return { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; })(); const tabsChanged = nextTabs.length !== currentTabs.length; const orderChanged = nextOrder.length !== currentOrder.length; const focusChanged = (state.focusedTabIdByWorkspace[key] ?? null) !== (nextFocusedByWorkspace[key] ?? null); if (!tabsChanged && !orderChanged && !focusChanged) { return state; } return { uiTabsByWorkspace: nextUiTabsByWorkspace, tabOrderByWorkspace: nextTabOrderByWorkspace, focusedTabIdByWorkspace: nextFocusedByWorkspace, }; }); }, retargetTab: ({ serverId, workspaceId, tabId, target }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const normalizedTabId = trimNonEmpty(tabId); const normalizedTarget = normalizeTabTarget(target); if (!key || !normalizedTabId || !normalizedTarget) { return null; } let retargetedTabId: string | null = null; set((state) => { const currentTabs = state.uiTabsByWorkspace[key] ?? []; const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); if (index < 0) { return state; } const currentTarget = currentTabs[index]?.target; if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) { return state; } const nextTabs = currentTabs.map((tab, tabIndex) => tabIndex === index ? { ...tab, target: normalizedTarget } : tab, ); retargetedTabId = normalizedTabId; return { uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, tabOrderByWorkspace: state.tabOrderByWorkspace, focusedTabIdByWorkspace: state.focusedTabIdByWorkspace, }; }); return retargetedTabId; }, reorderTabs: ({ serverId, workspaceId, tabIds }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); if (!key) { return; } const normalized = normalizeTabOrder(tabIds); set((state) => { const current = state.tabOrderByWorkspace[key] ?? []; if (current.length === normalized.length) { let same = true; for (let i = 0; i < current.length; i += 1) { if (current[i] !== normalized[i]) { same = false; break; } } if (same) { return state; } } return { ...state, tabOrderByWorkspace: { ...state.tabOrderByWorkspace, [key]: normalized, }, }; }); }, getWorkspaceTabs: ({ serverId, workspaceId }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); if (!key) { return []; } return get().uiTabsByWorkspace[key] ?? []; }, }), { name: "workspace-tabs-state", version: 5, storage: createJSONStorage(() => AsyncStorage), partialize: (state) => { const nextUiTabsByWorkspace: Record = {}; for (const key in state.uiTabsByWorkspace) { const tabs = (state.uiTabsByWorkspace[key] ?? []) .map((tab) => { const normalizedTarget = normalizeTabTarget(tab.target); const normalizedTabId = trimNonEmpty(tab.tabId); if (!normalizedTarget || !normalizedTabId) { return null; } return { tabId: normalizedTabId, target: normalizedTarget, createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(), } satisfies WorkspaceTab; }) .filter((tab): tab is WorkspaceTab => tab !== null); if (tabs.length > 0) { nextUiTabsByWorkspace[key] = tabs; } } const nextTabOrderByWorkspace: Record = {}; for (const key in state.tabOrderByWorkspace) { const order = normalizeTabOrder(state.tabOrderByWorkspace[key]); if (order.length > 0) { nextTabOrderByWorkspace[key] = order; } } const nextFocusedTabIdByWorkspace: Record = {}; for (const key in state.focusedTabIdByWorkspace) { const focusedTabId = trimNonEmpty(state.focusedTabIdByWorkspace[key]); if (focusedTabId) { nextFocusedTabIdByWorkspace[key] = focusedTabId; } } return { uiTabsByWorkspace: nextUiTabsByWorkspace, tabOrderByWorkspace: nextTabOrderByWorkspace, focusedTabIdByWorkspace: nextFocusedTabIdByWorkspace, }; }, migrate: (persistedState) => { const legacy = persistedState as | { version?: number; state?: any; openTabsByWorkspace?: Record; uiTabsByWorkspace?: Record; focusedTabIdByWorkspace?: Record; tabOrderByWorkspace?: Record; lastFocusedTabByWorkspace?: Record; tabOrderLegacyByWorkspace?: Record; } | undefined; const rawState = (legacy as any)?.state ?? legacy ?? {}; const rawUiTabsByWorkspace = rawState.uiTabsByWorkspace ?? rawState.openTabsByWorkspace ?? legacy?.uiTabsByWorkspace ?? legacy?.openTabsByWorkspace ?? {}; const rawFocused = rawState.focusedTabIdByWorkspace ?? legacy?.focusedTabIdByWorkspace ?? rawState.lastFocusedTabByWorkspace ?? {}; const rawOrder = rawState.tabOrderByWorkspace ?? legacy?.tabOrderByWorkspace ?? rawState.tabOrderByWorkspace ?? {}; const legacyOrder = rawState.tabOrderByWorkspace ?? rawState.tabOrderLegacyByWorkspace ?? {}; const uiTabsByWorkspace: Record = {}; const tabOrderByWorkspace: Record = {}; const focusedTabIdByWorkspace: Record = {}; for (const key in rawUiTabsByWorkspace) { const entries = Array.isArray(rawUiTabsByWorkspace[key]) ? rawUiTabsByWorkspace[key] : []; const nextUiTabs: WorkspaceTab[] = []; const orderFromTabs: string[] = []; const usedOrder = new Set(); for (const rawTab of entries) { if (!rawTab || typeof rawTab !== "object") { continue; } const normalizedTarget = normalizeTabTarget((rawTab as WorkspaceTab).target); const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId); if (!normalizedTarget) { continue; } const tabId = rawTabId ?? buildDeterministicTabId(normalizedTarget); if (!usedOrder.has(tabId)) { usedOrder.add(tabId); orderFromTabs.push(tabId); } nextUiTabs.push({ tabId, target: normalizedTarget, createdAt: typeof (rawTab as WorkspaceTab).createdAt === "number" ? (rawTab as WorkspaceTab).createdAt : Date.now(), }); } if (nextUiTabs.length > 0) { uiTabsByWorkspace[key] = nextUiTabs; } if (orderFromTabs.length > 0) { tabOrderByWorkspace[key] = orderFromTabs; } } for (const key in rawOrder) { const normalizedOrder = normalizeTabOrder(rawOrder[key]); if (normalizedOrder.length === 0) { continue; } const existing = tabOrderByWorkspace[key] ?? []; tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedOrder]); } for (const key in legacyOrder) { const list = legacyOrder[key]; if (!Array.isArray(list) || list.length === 0) { continue; } const normalizedLegacyOrder: string[] = []; for (const entry of list) { const raw = typeof entry === "string" ? entry.trim() : ""; if (!raw) { continue; } if (raw.startsWith("agent:")) { const agentId = raw.slice("agent:".length).trim(); if (agentId) { normalizedLegacyOrder.push(`agent_${agentId}`); } continue; } if (raw.startsWith("terminal:")) { const terminalId = raw.slice("terminal:".length).trim(); if (terminalId) { normalizedLegacyOrder.push(`terminal_${terminalId}`); } } } if (normalizedLegacyOrder.length === 0) { continue; } const existing = tabOrderByWorkspace[key] ?? []; tabOrderByWorkspace[key] = normalizeTabOrder([...existing, ...normalizedLegacyOrder]); } for (const key in rawFocused) { const value = rawFocused[key]; if (typeof value === "string") { const normalized = trimNonEmpty(value); if (normalized) { focusedTabIdByWorkspace[key] = normalized; } continue; } if (!value || typeof value !== "object" || typeof value.kind !== "string") { continue; } if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) { focusedTabIdByWorkspace[key] = `agent_${value.agentId.trim()}`; continue; } if ( value.kind === "terminal" && typeof value.terminalId === "string" && value.terminalId.trim() ) { focusedTabIdByWorkspace[key] = `terminal_${value.terminalId.trim()}`; continue; } if (value.kind === "draft" && typeof value.draftId === "string" && value.draftId.trim()) { focusedTabIdByWorkspace[key] = value.draftId.trim(); } } return { uiTabsByWorkspace, tabOrderByWorkspace, focusedTabIdByWorkspace, }; }, }, ), );