返回 DeepSeek-Reasonix
useWorkspacePanelCommands.ts
根目录 / desktop / frontend / src / app-runtime / useWorkspacePanelCommands.ts
1 import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2 import { useCommittedCommand } from "../lib/useCommittedCommand";
3 import { DOCK_ENTRIES } from "../lib/dockEntries";
4 import { resolveLauncherCardState, type SpaceMode } from "../lib/launcherCardState";
5 import type { Translator } from "../lib/i18n";
6 import { loadWorkspacePanelOpen, saveWorkspacePanelOpen, useLayoutStore, type RightDockMode } from "../store/layout";
7 import { useActivityBarStore, type TabType } from "../store/activityBar";
8 import { useRemoteStore } from "../store/remote";
9
10 type Input = {
11 sessionId: string;
12 workspaceRoot: string;
13 visible: boolean;
14 closeOverlays: () => void;
15 clearLiveWidth: (width: null) => void;
16 availableWidth: number;
17 clampTreeWidth: (width: number, availableWidth: number) => number;
18 setTreeWidth: (width: number) => void;
19 /** True while the dock column occupies grid space: the card then overlays
20 * the transcript instead of taking layout space from it. */
21 gridOpen: boolean;
22 t: Translator;
23 };
24
25 // The dock's tab model is the source of truth for what the panel shows; the
26 // legacy rightDockMode enum stays in sync for the geometry/shell readers that
27 // still branch on it.
28 function dockModeForTab(type: TabType): RightDockMode {
29 switch (type) {
30 case "context": return "context";
31 case "changed": return "changed";
32 case "remote": return "remote";
33 case "browser": return "browser";
34 default: return "files";
35 }
36 }
37
38 function tabForDockMode(mode: RightDockMode): TabType {
39 switch (mode) {
40 case "context": return "context";
41 case "changed": return "changed";
42 case "remote": return "remote";
43 case "browser": return "browser";
44 default: return "file";
45 }
46 }
47
48 // Remote and browser tabs have no launcher entry, so they carry their own
49 // label keys; every other tab type is labelled by its DOCK_ENTRIES entry.
50 function labelKeyForTab(type: TabType): string {
51 if (type === "remote") return "rightDock.remote";
52 if (type === "browser") return "rightDock.browser";
53 return DOCK_ENTRIES.find(entry => entry.defaultTab === type)?.labelKey ?? "workspace.filesTab";
54 }
55
56 /** One project-scoped preference owner, with no mirrored layout state. */
57 export function useWorkspacePanelCommands(input: Input) {
58 const t = input.t;
59 const defaultTabSessionRef = useRef("");
60 const mode = useLayoutStore(state => state.rightDockMode);
61 const explorerOpen = useRemoteStore(state => state.explorerOpen);
62 const hostCount = useRemoteStore(state => state.hosts.length);
63 const activeTabType = useActivityBarStore(state => state.tabs.find(tab => tab.id === state.activeTabId)?.type);
64 // The floating launcher card is on screen only while the dock column is
65 // collapsed, the surface is wide enough and the user has not dismissed it —
66 // one shared decision for the card's render condition and the toggle's
67 // pressed state.
68 const [launcherDismissed, setLauncherDismissed] = useState(false);
69 const [launcherSpaceMode, setLauncherSpaceMode] = useState<SpaceMode>("full");
70 // The narrow-surface yield only matters while the card takes layout space
71 // from the chat column; over an open dock it overlays and never squeezes.
72 const launcherCardSpaceMode = input.gridOpen ? "full" : launcherSpaceMode;
73 const launcherCard = resolveLauncherCardState({ spaceMode: launcherCardSpaceMode, dismissed: launcherDismissed });
74 // The card stays mounted whenever it could be shown, even while the space
75 // mode hides it: only the mounted card measures its host, so unmounting on
76 // "hidden" would leave the toggle permanently unable to bring it back.
77 const launcherCardMounted = !launcherDismissed;
78 // Opening the panel replaces the card on screen; the toggle can still summon
79 // it back over the panel afterwards.
80 useEffect(() => {
81 if (input.gridOpen) setLauncherDismissed(true);
82 }, [input.gridOpen]);
83 const openRightDockMode = useCommittedCommand((requestedMode?: RightDockMode) => {
84 input.closeOverlays();
85 const layout = useLayoutStore.getState();
86 const next = requestedMode ?? layout.rightDockMode;
87 if (next === "context" || next !== layout.rightDockMode) layout.setWorkspacePreviewActive(false);
88 layout.setRightDockMode(next);
89 layout.setWorkspacePanelMaximized(false);
90 // The tab list decides what the dock renders, so every caller of this
91 // command must leave a matching tab behind — otherwise the panel opens
92 // empty (the tab model has no "mode" to fall back to).
93 useActivityBarStore.getState().openEntry(tabForDockMode(next), t(labelKeyForTab(tabForDockMode(next)) as never));
94 if (layout.workspacePanelOpen && !layout.workspacePanelMaximized) return;
95 layout.setWorkspacePanelOpen(true);
96 saveWorkspacePanelOpen(true, input.workspaceRoot);
97 });
98 const closeWorkspacePanel = useCommittedCommand(() => {
99 input.closeOverlays();
100 const layout = useLayoutStore.getState();
101 if (!layout.workspacePanelOpen) return;
102 input.clearLiveWidth(null);
103 layout.setWorkspacePanelMaximized(false);
104 layout.setWorkspacePanelOpen(false);
105 saveWorkspacePanelOpen(false, input.workspaceRoot);
106 });
107 // Closing the last tab must release the dock's layout space as well as its
108 // content. Observe the transition synchronously so close-all and close/open
109 // in one batch use the same panel command. Project restoration is not a close.
110 useEffect(() => useActivityBarStore.subscribe((state, previous) => {
111 if (state.workspaceRoot !== previous.workspaceRoot || state.workspaceRoot !== input.workspaceRoot) return;
112 if (previous.tabs.length > 0 && state.tabs.length === 0) closeWorkspacePanel();
113 }), [input.workspaceRoot, closeWorkspacePanel]);
114 // The toggle owns the card and nothing else — the dock panel has its own
115 // button. It is inert only while the surface is too narrow for the card to
116 // occupy space at all.
117 const toggleLauncherCard = useCallback(() => {
118 if (launcherCardSpaceMode === "hidden") return;
119 setLauncherDismissed((dismissed) => !dismissed);
120 }, [launcherCardSpaceMode]);
121 const prepareBlankWorkspace = useCommittedCommand((workspaceRoot = input.workspaceRoot) => {
122 input.closeOverlays();
123 input.clearLiveWidth(null);
124 const layout = useLayoutStore.getState();
125 layout.setWorkspacePanelMaximized(false);
126 layout.setWorkspacePanelOpen(false);
127 // Seed the destination preference before project restoration can run.
128 saveWorkspacePanelOpen(false, workspaceRoot);
129 });
130 // Opening a launcher entry expands the dock to that entry's tab.
131 const openDockEntry = useCommittedCommand((entryId: string) => {
132 const entry = DOCK_ENTRIES.find(candidate => candidate.id === entryId);
133 if (!entry) return;
134 openRightDockMode(dockModeForTab(entry.defaultTab));
135 });
136 // Plain open/close restores the project's tabs. The session initialization
137 // effect below fills an expanded empty dock with Overview once per session.
138 const toggleWorkspacePanel = useCommittedCommand(() => {
139 const layout = useLayoutStore.getState();
140 if (layout.workspacePanelOpen) { closeWorkspacePanel(); return; }
141 input.closeOverlays();
142 const activity = useActivityBarStore.getState();
143 if (activity.tabs.length > 0) activity.setActivityBarOpen(true);
144 layout.setWorkspacePanelMaximized(false);
145 layout.setWorkspacePanelOpen(true);
146 saveWorkspacePanelOpen(true, input.workspaceRoot);
147 });
148 const toggleWorkspaceMaximized = useCommittedCommand(() => {
149 input.closeOverlays();
150 const layout = useLayoutStore.getState();
151 layout.setWorkspacePanelMaximized(!layout.workspacePanelMaximized);
152 });
153 const handleWorkspacePreviewModeChange = useCommittedCommand((active: boolean) => {
154 const layout = useLayoutStore.getState();
155 if (layout.workspacePreviewActive === active) return;
156 input.closeOverlays();
157 layout.setWorkspacePreviewActive(active);
158 });
159 const openRemoteDock = useCommittedCommand(() => {
160 const remote = useRemoteStore.getState();
161 const fallback = remote.hosts.find(host => ["connected", "degraded"].includes(remote.statuses[host.id]?.state)) ?? remote.hosts[0];
162 const hostId = remote.hosts.some(host => host.id === remote.explorerHostId) ? remote.explorerHostId : fallback?.id;
163 if (hostId) remote.openExplorer(hostId);
164 });
165 const restoreWorkspaceDockWidths = useCommittedCommand((treeWidth: number, _previewWidth: number) => {
166 // Single-width dock: only the tree width is meaningful; clamp it to the
167 // dynamic available width (chat keeps its 400px floor), never a fixed
168 // 560 ceiling, so the user's remembered width is preserved when reopened.
169 input.setTreeWidth(input.clampTreeWidth(treeWidth, input.availableWidth));
170 });
171 useLayoutEffect(() => {
172 useLayoutStore.getState().setWorkspacePanelOpen(loadWorkspacePanelOpen(input.workspaceRoot));
173 }, [input.workspaceRoot]);
174 // Keep the legacy mode mirror on the active tab's type.
175 useEffect(() => {
176 if (!activeTabType) return;
177 const next = dockModeForTab(activeTabType);
178 if (useLayoutStore.getState().rightDockMode !== next) useLayoutStore.getState().setRightDockMode(next);
179 }, [activeTabType]);
180 // The tab list is per workspace root; switching projects restores that
181 // project's own tabs instead of carrying the previous one's over.
182 useLayoutEffect(() => {
183 useActivityBarStore.getState().setWorkspaceRoot(input.workspaceRoot);
184 }, [input.workspaceRoot]);
185 // A restored, expanded dock should show useful session context immediately.
186 // Seed only once per session so manually reopening an emptied dock can show
187 // the tab picker without immediately recreating Overview.
188 useLayoutEffect(() => {
189 if (!input.visible || !input.sessionId) return;
190 const sessionKey = `${input.workspaceRoot}\u0000${input.sessionId}`;
191 if (defaultTabSessionRef.current === sessionKey) return;
192 if (!useLayoutStore.getState().workspacePanelOpen) return;
193 defaultTabSessionRef.current = sessionKey;
194 const activity = useActivityBarStore.getState();
195 if (activity.activeTabId) return;
196 activity.openEntry("context", t(labelKeyForTab("context") as never));
197 }, [input.sessionId, input.visible, input.workspaceRoot, t]);
198 useEffect(() => {
199 if (!explorerOpen) return;
200 openRightDockMode("remote");
201 useRemoteStore.getState().closeExplorer();
202 }, [explorerOpen, openRightDockMode]);
203 useEffect(() => {
204 if (hostCount === 0 && mode === "remote") useLayoutStore.getState().setRightDockMode("files");
205 }, [hostCount, mode]);
206 return {
207 openRightDockMode, closeWorkspacePanel, prepareBlankWorkspace, openDockEntry,
208 toggleWorkspacePanel, toggleWorkspaceMaximized, handleWorkspacePreviewModeChange,
209 openRemoteDock, restoreWorkspaceDockWidths,
210 launcherCard, launcherCardMounted, launcherCardOverlay: input.gridOpen,
211 toggleLauncherCard, setLauncherSpaceMode,
212 };
213 }
214
214 lines TYPESCRIPT