diff --git a/src/servers/api/dashboards/dashboards.ts b/src/servers/api/dashboards/dashboards.ts index f9c1c7ad..67e7e5c0 100644 --- a/src/servers/api/dashboards/dashboards.ts +++ b/src/servers/api/dashboards/dashboards.ts @@ -17,7 +17,10 @@ export const dashboardsRouter = createRouter(); // `ws--`. This is an allow-list because `ws--` cannot be split // without one — both halves may contain dashes. Keep it in step with the `statePrefix` props in // `apps/Terminal/index.tsx`; the 400 at the bottom of the PATCH loop is what tells you when it is not. -const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code']; +// `editor` joined 2026-08-15: which files a code-editor panel has open, and which is in front. Note an +// unmatched key is a 400 rather than a silent 200 — so a new panel prefix MUST be added here or its state +// vanishes loudly instead of quietly, which is the whole reason that branch exists. +const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code', 'editor']; // A `LayoutNode` is an object. Everything from the PATCH body down to jsonb is `unknown`, so without this // the only thing standing between a wrong-shaped body and the layout columns is the CHECK constraint — diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx index f013e465..079dfcc4 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx +++ b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx @@ -10,6 +10,8 @@ import { EditorTabs } from './EditorTabs'; import { useEditorState } from './useEditorState'; import { getLanguage } from './language-map'; import { useFilesRefresh } from '../../channels'; +import { useWorkspace } from '../../components/Workspace'; +import { useEditorTabs } from './use-editor-tabs'; type CodeEditorViewProps = { className?: string; @@ -18,6 +20,11 @@ type CodeEditorViewProps = { initialPath?: string; /** Put the open file in `?file=`. Opt-in — only the /code-editor screen is guaranteed one editor. */ urlState?: boolean; + /** + * Identifies this panel so its open tabs can be remembered across a reload. Absent on the screen, where + * `?file=` already survives one and there is nothing to restore. + */ + panelId?: string; }; export const CodeEditorView = ({ @@ -26,7 +33,10 @@ export const CodeEditorView = ({ root = 'home', initialPath, urlState, + panelId, }: CodeEditorViewProps) => { + const { workspace } = useWorkspace(); + const { restored, remember } = useEditorTabs(panelId, workspace); const { files, activePath, @@ -87,6 +97,51 @@ export const CodeEditorView = ({ }; }, [activePath, files]); + /** + * Reopen what this panel had open before the reload. + * + * Sequential rather than parallel, because the order of the tabs is the order you left them in and + * `openFile` appends. Each path is read fresh; one that no longer reads is skipped, so a file deleted + * while the page was closed drops out of the strip instead of coming back as an empty buffer. + * + * `active` is applied at the end and only if it actually opened — otherwise the last surviving tab wins, + * which is what `closeFile` does when the active tab goes. + */ + const didRestore = useRef(false); + useEffect(() => { + if (didRestore.current || !restored || restored.open.length === 0) return; + didRestore.current = true; + let alive = true; + void (async () => { + const opened: string[] = []; + for (const path of restored.open) { + try { + const res = await readFileRef.current(path); + if (!alive) return; + openFile(path, path.split('/').pop() ?? path, res.content); + opened.push(path); + } catch { + // Gone or unreadable — leave it out rather than open an empty tab named after it. + } + } + if (alive && restored.active && opened.includes(restored.active)) setActivePath(restored.active); + })(); + return () => { + alive = false; + }; + }, [restored]); + + /** + * Write the strip back whenever it changes. + * + * Guarded on the restore having run, or the first render — with no tabs open yet — would persist an + * empty list over the very state it is about to read. + */ + useEffect(() => { + if (restored && !didRestore.current) return; + remember({ open: files.map((f) => f.path), active: activePath }); + }, [files, activePath]); + /** * Re-read the disk: the tree, and every open file that has no unsaved edits. * diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditorPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditorPanelWrapper.tsx index c709343a..4a911c49 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditorPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditorPanelWrapper.tsx @@ -12,7 +12,7 @@ import { CodeEditorView } from './CodeEditor'; * No `urlState`: a dashboard can hold two editors and one `?file=` would drive both. Same reasoning as * FileBrowserPanelWrapper, which is where this pattern came from and which had it right already. */ -export const CodeEditorPanelWrapper = () => { +export const CodeEditorPanelWrapper = ({ panelId }: { panelId: string }) => { const { cwd } = useWorkspace(); - return ; + return ; }; diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/use-editor-tabs.ts b/src/workspaces/officerdev/src/apps/CodeEditor/use-editor-tabs.ts new file mode 100644 index 00000000..30f569a0 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CodeEditor/use-editor-tabs.ts @@ -0,0 +1,82 @@ +import { useRef } from 'react'; +import { useDashboardState } from 'state/useDashboardState'; +import { usePanelClose, type WorkspaceIdentity } from '../../components/Workspace'; + +/** + * Which files a code-editor panel has open, remembered across a reload. + * + * A panel's tabs lived in component state, so a refresh emptied it — you came back to "Open a file from + * the explorer to start editing" with no sign of what you had been doing. The terminal solved the same + * problem earlier by keeping a `panelId -> sessionId` map in dashboard state; this is that map with a + * different payload, and it is deliberately the same shape so there is one pattern to learn rather than + * two to compare. + * + * ── What is stored, and what is not ── + * + * PATHS, not contents. The file on disk is the truth: restoring a remembered body would mean showing a + * version that may be hours stale, or resurrecting a file something has since deleted. On restore each + * path is read fresh, and one that no longer reads is dropped rather than opened empty. + * + * The cost is that UNSAVED edits still do not survive a reload — they did not before either, since the + * whole tab went with them. Keeping them means persisting buffers, which is a different feature with its + * own hard question (what happens when the disk AND the buffer have both changed?) and is not smuggled + * in here. + */ +export type EditorPanelState = { open: string[]; active: string | null }; + +const EMPTY: Record = {}; + +/** `ws-editor-{dashboardId}`, or `-default` for a panel on a screen. Mirrors `terminalStateKey`. */ +export function editorStateKey(workspace: WorkspaceIdentity | null): string { + const id = workspace?.kind === 'dashboard' ? workspace.id : 'default'; + return `ws-editor-${id}`; +} + +export function useEditorTabs(panelId: string | undefined, workspace: WorkspaceIdentity | null) { + const { value: bag, setValue: setBag } = useDashboardState>( + editorStateKey(workspace), + EMPTY, + ); + + const saved = panelId ? bag[panelId] : undefined; + + /** + * The remembered set is claimed ONCE, on the first render that has it. + * + * After that this panel is the authority. Re-reading would fight the user: every tab they open writes + * back into this same bag, so a later read would arrive as an "update" telling them to reopen what they + * had just closed. + */ + const restored = useRef(null); + const claimed = useRef(false); + if (!claimed.current && saved) { + claimed.current = true; + restored.current = saved; + } + + const remember = (state: EditorPanelState) => { + if (!panelId) return; + setBag((prev) => { + const before = prev[panelId]; + // Cheap equality on a small ordered payload. Without it, every render caused by a keystroke would + // PATCH the identical value back to the server. + if (before && before.active === state.active && before.open.join('\n') === state.open.join('\n')) { + return prev; + } + return { ...prev, [panelId]: state }; + }); + }; + + // A panel closed for good should not leave its tab list behind. `usePanelClose` fires on a real close + // and on nothing else — not a drag, not a swap, not a mobile panel switch. + usePanelClose(panelId ?? '', () => { + if (!panelId) return; + setBag((prev) => { + if (!(panelId in prev)) return prev; + const { [panelId]: _drop, ...rest } = prev; + return rest; + }); + }); + + return { restored: restored.current, remember }; +}