TermAl

Feature Spec: Editor Buffer Persistence

Status

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.

Problem

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.

Goals

Non-goals (V1)

Product Model

Persistence key: tab id

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:

Covered editors

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.

Persisted state shape

Per tab:

  1. Scroll offset. Pixel scrollTop for Monaco; pixel scrollTop on the .markdown-diff-change-scroll container for MarkdownDiffView.
  2. Cursor position. Editor-specific:
    • Monaco: { lineNumber, column } (and for diff editor, which side the caret was on).
    • MarkdownDiffView: an anchor path — section id + text-node offset or a data-markdown-line-start + intra-block offset — that can be replayed after the DOM is re-rendered from Markdown source.
  3. Current buffer content. The working copy, regardless of whether it has been saved.
  4. Command log. A ring buffer of reversible edit operations, capped at 200 entries. Each entry is enough to undo the edit (either a structured diff patch against the previous state or Monaco’s native undo ring entries serialized).

Save semantics

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:

Tab-close semantics

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.

Conflict handling

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.

Storage

localStorage layout

Key pattern:

termal-editor-buffer:{workspaceViewId}:{tabId}

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

Eviction

Size cap

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.

Write discipline

Rehydration discipline

Command-log format

Still to be nailed down. Two candidates:

  1. Monaco-native serialization. Monaco already exposes 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.
  2. Structured diff patches. Each entry is { 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.

Open questions

Future Directions

Shared edit sessions

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.

Cross-device roaming

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.