Skip to content

ADR 0054: Multiple buffers, and finally wiring the plugin host in

Status

Accepted

Context

Two items from EXTENSIBILITY.md and the post-v1 roadmap, taken together because the first is small and the second is the largest functional gap the editor had: it could only ever hold one file per window, with no way to open a second without losing the first.

Decision

A viewport per buffer, not a document per viewport

ADR 0052 predicted that multi-buffer would force extracting a Document class (buffer + undo + syntax + path + LSP state) out of EditorViewport, since the viewport owns all of those as singletons. On actually costing it out, that prediction was wrong, and the reverse is true.

A Document earns its keep only when you need two views of the same file — split view. That isn't this feature. Without that requirement, "one EditorViewport per open buffer, stacked so one is visible" is not a workaround for missing the extraction; it is the simpler correct model: the viewport is the document view. Extracting Document first would have meant moving fifteen-odd members and touching all nine translation units to buy nothing today.

So: QStackedWidget of viewports, owned by MainWindow, which grew from "a closeEvent override" into the thing that owns the buffer list. Document is now explicitly parked until split view is wanted — at which point it becomes the right call, for a reason that actually applies.

The honest cost of this shape: some per-viewport state is duplicated N times (a blink timer, a config-reload timer, a full set of floating panels). Panels are inert until shown and hidden widgets get no paint events, so the cost is small — but it is real, and it is the price of not having a Document.

The buffer bar is a dot, a name, and nothing else

Explicit design direction: filename with a dot beside it, active at full opacity, inactive dimmed (dot and name together), no lines above, below or between, close mark only on the active entry, minimal and fast.

Deliberately not a QTabBar. Every native tab widget draws frames, separators, a selected-tab lip and a hover plate — none of which this app has anywhere else. The chrome language here is "no lines, state carried by opacity" (ADR 0007's one-font-color pillar, ADR 0022's panels), and a QTabBar would have imported a different one. BufferBar is ~180 lines of custom paint instead, using the same opacity tiers the gutter already uses to say "this line is yours / these are context".

Two consequences worth naming:

  • One buffer means no bar at all. sizeHint() returns zero height below two entries — the filename is already in the window title, so a single-entry bar would be chrome that tells you nothing. Anyone who never opens a second file sees exactly the editor they had before this feature existed.
  • The close mark only exists on the active entry, per the direction. An x on every buffer is five things asking to be clicked instead of one. Its hit rect is deliberately padded well beyond the 7px glyph.

The one thing added beyond the brief: a small opacity lift on hover for inactive entries, so a click target acknowledges the pointer. It adds no chrome — it's the same opacity channel, one tier up.

Deliberately not shown in the bar: dirty state

The dot tracks active, exactly as specified, not modified. That is a real trade: with three buffers open and two edited, the bar won't tell you which. The dirty marker lives in the status bar and window title (ADR 0023) and those follow the active buffer only. Flagged here as a known gap rather than silently "improved", since the dot is the obvious place to put it if it's wanted later.

LSP starts on first activation, not at construction

Opening ten files must not spawn ten clangd processes for the nine you never looked at, so startLspClientIfConfigured() moved out of the constructor into onActivated(), called when a buffer first becomes visible.

It deliberately does not stop on switch-away: restarting clangd costs a full reindex, and switching buffers is the common action — making the common action slow to save memory on the uncommon one is the wrong trade. So the cost is one server per visited C file. The real fix is one project-wide server handling several didOpen documents, which is what LSP is designed for; that's now a named roadmap item.

openFile() deleted

In-place replacement of a viewport's contents is obsolete once opening a file means opening a buffer. FileBrowserPanel now emits through EditorViewport::fileOpenRequested and the window decides; the 52-line openFile() and its LSP-restart dance are gone. Reopening an already-open path switches to it rather than creating a second copy.

Plugin host: wired, with the undo hazard handled honestly

EditorViewport creates an AsePluginHost, loads <config dir>/plugins/ at startup (a missing directory is not an error — same "unconfigured is a normal state" stance as lsp_command/build_command), and runCommand() falls through to the registry for any :name that isn't a built-in. Built-ins are checked first, so a plugin cannot shadow one.

The hazard, and why it is handled the way it is: a plugin command is handed the raw AseBuffer and edits it directly, because the ABI (ADR 0009) has no way to do anything else. That leaves every offset already recorded in the undo stack potentially stale — and undoing against stale offsets corrupts the buffer rather than merely doing the wrong thing. So the undo history is dropped after a successful plugin command. Losing history is a visible, understandable cost; silent corruption is not. Routing plugin edits through undo needs the wider plugin context described in EXTENSIBILITY.md, and this is the concrete reason that widening is worth doing.

Consequences

Verified live end to end: three buffers open showing • alpha.c • beta.c • gamma.c × with only the active one at full opacity and no rules anywhere; click-to-switch and Ctrl+Tab both move the active entry and its close mark; closing back down to one collapses the bar to zero height; a dirty buffer refuses to close without the themed confirmation, and Cancel leaves the unsaved edit intact. The plugin host was verified with a real Lua plugin (:shout, uppercasing the buffer) loaded from ~/.config/ase/plugins/ — syntax, diagnostics and the dirty marker all updated correctly afterwards.

Every confirmation in the window now goes through one themed confirmDiscard() rather than each building its own box — the per-buffer close was briefly a native QMessageBox::question, which is exactly the mismatch ADR 0044 already had to fix once.

New keys: Ctrl+Tab / Ctrl+Shift+Tab cycle buffers, Ctrl+W closes one. They are window-level QShortcuts rather than additions to EditorViewport's Ctrl-chain, because they act on the window's buffer list, not on text — and Qt dispatches shortcuts before the focus widget's key handler, so Ctrl+Tab never reaches the viewport's Tab case.

ctest 9/9, clean build, zero warnings.