a code editor panel remembers its tabs across a reload

Refreshing the page emptied it. You came back to "Open a file from the explorer to
start editing" with no trace of what you had been doing — two tabs open, one of
them the notes you were writing in.

The terminal solved this shape years ago: a `panelId -> sessionId` map kept in
dashboard state, keyed `ws-{prefix}-{dashboardId}`. This is that map with a
different payload, deliberately the same shape so there is one pattern rather than
two to compare. `editor` is registered in PANEL_STATE_PREFIXES — an unmatched key
is a 400, not a silent 200, which is the branch that exists precisely so a new
prefix cannot vanish quietly.

── Paths, not contents ──

The disk is the truth. Restoring a remembered BODY would show a version that may
be hours stale, or resurrect a file something has since deleted. Each path is read
fresh on restore, in the order you left them, and one that no longer reads is
dropped rather than opened as an empty buffer named after a missing file.

The cost, stated rather than hidden: unsaved edits still do not survive a reload.
They did not before either — the whole tab went. Keeping them means persisting
buffers, which has its own hard question (the disk AND the buffer have both
changed — now what?) and is not smuggled in here.

── The two ways this goes wrong, both guarded ──

Reading the saved set more than once would fight the user: every tab they open
writes into that same bag, so a later read arrives as an update telling them to
reopen what they just closed. It is claimed once, on the first render that has it.

Writing before restoring would persist an empty list over the state it is about to
read — first render, no tabs yet. The write is guarded on the restore having run.

Also: a panel closed for good drops its entry, via `usePanelClose`, which fires on
a real close and not on a drag, a swap or a mobile panel switch.

Per PANEL, not per dashboard, so two editors side by side keep separate strips.
Not wired on the /code-editor screen: `?file=` already survives a reload there.

tsgo clean, frontend builds, 846 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 22:49:43 +00:00
co-authored by Claude Opus 5
parent dc3841a877
commit 95b83fb29b
4 changed files with 143 additions and 3 deletions
+4 -1
View File
@@ -17,7 +17,10 @@ export const dashboardsRouter = createRouter();
// `ws-<statePrefix>-<dashboardId>`. This is an allow-list because `ws-<prefix>-<id>` 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 —
@@ -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.
*
@@ -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 <CodeEditorView className="h-full w-full" initialPath={cwdToPath(cwd)} />;
return <CodeEditorView className="h-full w-full" initialPath={cwdToPath(cwd)} panelId={panelId} />;
};
@@ -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<string, EditorPanelState> = {};
/** `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<Record<string, EditorPanelState>>(
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<EditorPanelState | null>(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 };
}