Design — not yet implemented. Prepared alongside the ArrowDown-at-EOF refinement
in docs/features/markdown-document-view.md so both share the same keyboard /
cursor vocabulary.
Editor state is volatile. Close a tab, reload the browser, or restart the app and every unsaved edit disappears:
The file system already persists committed changes. What’s missing is
in-flight editor state — the work that exists only inside the user’s
buffer between last open and next save.
State is keyed by the tab id, not by file path. The tab is the durable identity in the workspace tree — it already roams across pane drag/drops and persists inside the workspace layout.
Consequences:
| Editor | File | Surface |
|---|---|---|
| MarkdownDiffView | ui/src/panels/DiffPanel.tsx |
Rendered Markdown diff, contentEditable |
| MonacoCodeEditor | ui/src/MonacoCodeEditor.tsx |
Source panel single-file Monaco |
| MonacoDiffEditor | ui/src/MonacoDiffEditor.tsx |
Git-diff two-side Monaco |
All three hold editable content and accept user input. All three already have an internal undo ring that lives and dies with the editor instance; this spec makes that ring durable.
Per tab:
scrollTop for Monaco; pixel scrollTop on the
.markdown-diff-change-scroll container for MarkdownDiffView.{ lineNumber, column } (and for diff editor, which side the
caret was on).data-markdown-line-start + intra-block offset — that can be replayed
after the DOM is re-rendered from Markdown source.Save does not clear the history. After a successful save the buffer matches the on-disk file, but the command log is preserved so the user can still Undo back across the save point. Rationale: users sometimes save prematurely and then realise they want to revert a change — the undo log should outlive a single save.
Save does:
contentHash to match the just-saved file, so conflict
detection on rehydrate compares against the right baseline;Closing a tab evicts its persisted entry from localStorage. This is the
explicit opt-out: if the user wanted to keep editing later, they should
keep the tab open.
If localStorage is purged by the browser (user clears site data, incognito session ends, quota exceeded), the buffer is gone too. This matches the implicit promise of localStorage-backed state everywhere else in the app.
Covered by File Change Awareness. When a
persisted buffer is rehydrated and its contentHash no longer matches the
disk baseline:
The persistence layer does not invent new conflict semantics. It just ensures the existing rebase / conflict flow runs with a rehydrated buffer instead of a fresh one.
Key pattern:
termal-editor-buffer:{workspaceViewId}:{tabId}
workspaceViewId scopes per open workspace (matches the existing workspace
layout key — termal-workspace-layout-{workspaceViewId}).tabId is the workspace tab identifier.Value is a JSON blob shaped like:
type PersistedEditorBuffer = {
version: 1;
editor: "monaco-code" | "monaco-diff" | "markdown-diff";
tabId: string;
filePath: string;
contentHash: string; // for conflict detection on rehydrate
scrollOffset: number;
cursor: EditorCursor; // discriminated union per editor
content: string; // current buffer value
commands: PersistedCommand[]; // ring, capped at 200
savedAt: number | null; // epoch ms of the last save, or null if never saved
updatedAt: number; // epoch ms of the last write
};
termal-editor-buffer:{workspaceViewId}:* keys.updatedAt. If we cannot save a
new entry, we drop the oldest entry and retry; the user is notified if
more than N evictions happen in one session.Per-tab cap TBD during implementation. Likely ~1 MB — enough for a large document + 200 command entries, but small enough that one misbehaving tab cannot exhaust localStorage.
visibilitychange: hidden and beforeunload, flush
pending writes synchronously. localStorage is synchronous, so the flush
itself is reliable; the challenge is making sure we have the latest state
in memory when the event fires.termal-editor-buffer:{workspaceViewId}:{tabId}.contentHash matches disk, apply content, then restore
scrollOffset and cursor, and seed the editor’s undo stack from
commands.contentHash differs, run the rebase pipeline from
File Change Awareness with the persisted
buffer as input.Still to be nailed down. Two candidates:
pushEditOperations
and maintains an undo ring. Serialize entries as
{ range, text, forceMoveMarkers }. Trivial for Monaco editors; we have
to translate to/from the same shape for MarkdownDiffView.{ rangeBefore, rangeAfter,
textBefore, textAfter } computed from a before/after snapshot. Heavier
to produce but editor-agnostic.The structured-patch form is more portable and makes the “shared edit session” future direction easier, at the cost of extra computation on every edit. V1 likely leans on the Monaco-native form for Monaco editors and a structured-patch form for MarkdownDiffView, then unifies later.
One answer to the “localStorage is enough” framing opens an interesting generalisation: move the buffer to the backend. Then:
V1 does not build this. V1 keeps everything in localStorage. But picking a structured-patch command log (candidate 2 above) keeps the door open.
Backend persistence also unlocks roaming: close the laptop, open the desktop, pick up the same in-flight edit. Requires authentication, which TermAl does not have in Phase 1 — so this is post-Phase-1 territory.