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