diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx index 079dfcc4..9ca79226 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx +++ b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx @@ -98,49 +98,56 @@ export const CodeEditorView = ({ }, [activePath, files]); /** - * Reopen what this panel had open before the reload. + * Reopen what this panel had open before the reload, then start remembering. * - * 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. + * `restoreDone` is a state flag rather than a ref, and both halves depend on it, because the two ways + * this goes wrong are both about ORDER: * - * `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. + * 1. Writing before the restore finishes clobbers the thing being restored. The restore is async — it + * reads each file — while the write effect runs on the same mount, sees an empty `files`, and + * persists `{open: [], active: null}` over the list it was about to reopen. + * + * 2. Treating "nothing to restore" as "restore still pending" locks the panel forever. That is the bug + * this replaces: an empty saved value is TRUTHY, the restore bailed on `open.length === 0` without + * marking itself done, and the write guard then blocked every subsequent save. One empty write and + * the panel could never record anything again — which is exactly what the database showed. + * + * So the flag is set when the restore COMPLETES, including when it completes by having nothing to do. + * Sequential opens, because tab order is the order you left them in and `openFile` appends. */ - const didRestore = useRef(false); + const [restoreDone, setRestoreDone] = useState(false); + const startedRestore = useRef(false); + useEffect(() => { - if (didRestore.current || !restored || restored.open.length === 0) return; - didRestore.current = true; + if (startedRestore.current) return; + startedRestore.current = true; let alive = true; void (async () => { + const wanted = restored?.open ?? []; const opened: string[] = []; - for (const path of restored.open) { + for (const path of wanted) { 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. + // Gone or unreadable — leave it out rather than open an empty tab named after a missing file. } } - if (alive && restored.active && opened.includes(restored.active)) setActivePath(restored.active); + if (!alive) return; + if (restored?.active && opened.includes(restored.active)) setActivePath(restored.active); + setRestoreDone(true); })(); 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; + if (!restoreDone) return; remember({ open: files.map((f) => f.path), active: activePath }); - }, [files, activePath]); + }, [files, activePath, restoreDone]); /** * Re-read the disk: the tree, and every open file that has no unsaved edits.