返回 DeepSeek-Reasonix
activityBar.ts
根目录 / desktop / frontend / src / store / activityBar.ts
1 // activityBar owns the right dock's tab-container state: the open tabs, the
2 // active one, whether the container is expanded, and the + add-menu flag.
3 // Panel contents are rendered by the dock region (they need its props); this
4 // store only tracks which tab is open so the dock can switch between them.
5 //
6 // `activityBarOpen` records the expanded state (true while ≥1 tab is open);
7 // closing the last tab collapses the container.
8 //
9 // Tabs are persisted per workspace root to localStorage, so switching projects
10 // shows each one's own tabs. The add menu stays session-local.
11
12 import { create } from "zustand";
13
14 export type TabType = "file" | "changed" | "context" | "remote" | "browser";
15
16 export interface TabItem {
17 id: string;
18 type: TabType;
19 label: string;
20 meta?: Record<string, unknown>;
21 /** Epoch ms the tab was opened; absent on tabs restored from an older
22 * persisted snapshot, which then simply show no relative time. */
23 openedAt?: number;
24 }
25
26 /** A tab the user closed, kept for the tab overview's reopen list. */
27 export interface ClosedTabRecord {
28 tab: TabItem;
29 closedAt: number;
30 }
31
32 const CLOSED_TAB_LIMIT = 10;
33
34 const STORAGE_KEY = "reasonix.dock.tabs";
35
36 // Tabs are scoped per project (workspace root), so switching projects shows
37 // each one's own open tabs. A root of "" falls back to the legacy global key.
38 let workspaceRoot = "";
39 const closedByProject = new Map<string, ClosedTabRecord[]>();
40 function storageKey(): string {
41 return workspaceRoot ? `${STORAGE_KEY}.${workspaceRoot}` : STORAGE_KEY;
42 }
43
44 function nextTabId(): string {
45 return `dock-tab-${crypto.randomUUID()}`;
46 }
47
48 function loadTabs(): { tabs: TabItem[]; activeTabId: string | null } {
49 if (typeof window === "undefined") return { tabs: [], activeTabId: null };
50 try {
51 const raw = window.localStorage.getItem(storageKey());
52 if (!raw) return { tabs: [], activeTabId: null };
53 const parsed = JSON.parse(raw) as { tabs?: TabItem[]; activeTabId?: string | null };
54 const tabs = Array.isArray(parsed.tabs) ? parsed.tabs : [];
55 // Drop structurally invalid entries and duplicate ids (a persisted
56 // snapshot may contain them from an earlier bug); duplicate ids would
57 // make React report "two children with the same key".
58 const seen = new Set<string>();
59 const valid = tabs.filter((tab) => {
60 if (!tab || typeof tab.id !== "string" || typeof tab.type !== "string") return false;
61 if (seen.has(tab.id)) return false;
62 seen.add(tab.id);
63 return true;
64 });
65 return { tabs: valid, activeTabId: valid.some((tab) => tab.id === parsed.activeTabId) ? parsed.activeTabId ?? null : valid[valid.length - 1]?.id ?? null };
66 } catch {
67 return { tabs: [], activeTabId: null };
68 }
69 }
70
71 function persist(tabs: TabItem[], activeTabId: string | null): void {
72 if (typeof window === "undefined") return;
73 try {
74 window.localStorage.setItem(storageKey(), JSON.stringify({ tabs, activeTabId }));
75 } catch {
76 /* ignore storage failures */
77 }
78 }
79
80 const initial = loadTabs();
81
82 export type ActivityBarState = {
83 workspaceRoot: string;
84 tabs: TabItem[];
85 activeTabId: string | null;
86 /** True while the tab container is expanded (dock shows the panel, not just
87 * the 48px activity bar). Independent of tabs: collapsing via the toggle
88 * keeps the tabs so re-expanding restores them. */
89 activityBarOpen: boolean;
90 addMenuOpen: boolean;
91 /** Most recently closed tabs, newest first. Session-local. */
92 recentlyClosed: ClosedTabRecord[];
93 /** Open the entry's default tab, switching to it when one of that type exists. */
94 openEntry: (type: TabType, label: string, meta?: Record<string, unknown>) => string;
95 /** Append a new tab of the given type and activate it. */
96 addTab: (type: TabType, label: string, meta?: Record<string, unknown>) => void;
97 closeTab: (tabId: string) => void;
98 /** Re-open a tab from the recently-closed list. */
99 reopenTab: (tabId: string) => void;
100 activateTab: (tabId: string) => void;
101 /** Move a tab so it lands on the left/right side of another tab. */
102 moveTab: (fromId: string, toId: string, side: "left" | "right") => void;
103 /** Collapse/expand the tab container without touching the tab list. */
104 setActivityBarOpen: (open: boolean) => void;
105 setAddMenuOpen: (open: boolean) => void;
106 /** Switch the active project (workspace root): reloads that project's own
107 * persisted tabs. A root of "" falls back to the legacy global tabs. */
108 setWorkspaceRoot: (root: string) => void;
109 };
110
111 // Tab and project changes never cancel file navigation here. The runtime
112 // reconciles its navigation records against the open dock tabs, so collapsing
113 // the dock keeps a preview to restore while closing its tab ends that record.
114 export const useActivityBarStore = create<ActivityBarState>((set, get) => ({
115 workspaceRoot,
116 tabs: initial.tabs,
117 activeTabId: initial.activeTabId,
118 activityBarOpen: initial.tabs.length > 0,
119 addMenuOpen: false,
120 recentlyClosed: [],
121 openEntry: (type, label, meta) => {
122 set((state) => {
123 const existing = state.tabs.find((tab) => tab.type === type);
124 if (existing) {
125 persist(state.tabs, existing.id);
126 return { activeTabId: existing.id, activityBarOpen: true };
127 }
128 const tab: TabItem = { id: nextTabId(), type, label, meta, openedAt: Date.now() };
129 const tabs = [...state.tabs, tab];
130 persist(tabs, tab.id);
131 return { tabs, activeTabId: tab.id, activityBarOpen: true };
132 });
133 return get().activeTabId!;
134 },
135 addTab: (type, label, meta) => {
136 set((state) => {
137 const tab: TabItem = { id: nextTabId(), type, label, meta, openedAt: Date.now() };
138 const tabs = [...state.tabs, tab];
139 persist(tabs, tab.id);
140 return { tabs, activeTabId: tab.id, activityBarOpen: true };
141 });
142 },
143 closeTab: (tabId) => {
144 set((state) => {
145 const index = state.tabs.findIndex((tab) => tab.id === tabId);
146 if (index < 0) return state;
147 const tabs = state.tabs.filter((tab) => tab.id !== tabId);
148 const closed = state.tabs[index];
149 const recentlyClosed = [{ tab: closed, closedAt: Date.now() }, ...state.recentlyClosed]
150 .slice(0, CLOSED_TAB_LIMIT);
151 let activeTabId = state.activeTabId;
152 if (state.activeTabId === tabId) {
153 // Fall back to the neighbor on the left, then the right, then null.
154 activeTabId = tabs[index - 1]?.id ?? tabs[index]?.id ?? null;
155 }
156 persist(tabs, activeTabId);
157 // Closing the last tab collapses the container back to the activity bar.
158 return { tabs, activeTabId, recentlyClosed, activityBarOpen: tabs.length > 0 };
159 });
160 },
161 reopenTab: (tabId) =>
162 set((state) => {
163 const record = state.recentlyClosed.find((entry) => entry.tab.id === tabId);
164 if (!record) return state;
165 if (state.tabs.some((tab) => tab.id === tabId)) return state;
166 const tabs = [...state.tabs, record.tab];
167 persist(tabs, tabId);
168 return {
169 tabs,
170 activeTabId: tabId,
171 activityBarOpen: true,
172 recentlyClosed: state.recentlyClosed.filter((entry) => entry.tab.id !== tabId),
173 };
174 }),
175 activateTab: (tabId) => {
176 set((state) => {
177 if (!state.tabs.some((tab) => tab.id === tabId)) return state;
178 persist(state.tabs, tabId);
179 return { activeTabId: tabId, activityBarOpen: true };
180 });
181 },
182 moveTab: (fromId, toId, side) =>
183 set((state) => {
184 if (fromId === toId) return state;
185 const tabs = [...state.tabs];
186 const fromIndex = tabs.findIndex((tab) => tab.id === fromId);
187 if (fromIndex < 0) return state;
188 const [moved] = tabs.splice(fromIndex, 1);
189 const toIndex = tabs.findIndex((tab) => tab.id === toId);
190 if (toIndex < 0) return state;
191 tabs.splice(side === "right" ? toIndex + 1 : toIndex, 0, moved);
192 persist(tabs, state.activeTabId);
193 return { tabs };
194 }),
195 setActivityBarOpen: (open) => set({ activityBarOpen: open }),
196 setAddMenuOpen: (open) => set({ addMenuOpen: open }),
197 setWorkspaceRoot: (root) => {
198 if (root === workspaceRoot) return;
199 closedByProject.set(workspaceRoot, get().recentlyClosed);
200 workspaceRoot = root;
201 const loaded = loadTabs();
202 set({
203 workspaceRoot: root,
204 tabs: loaded.tabs,
205 activeTabId: loaded.activeTabId,
206 activityBarOpen: loaded.tabs.length > 0,
207 addMenuOpen: false,
208 recentlyClosed: closedByProject.get(root) ?? [],
209 });
210 },
211 }));
212
212 lines TYPESCRIPT