| 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 |