Commit Graph

4 Commits

Author SHA1 Message Date
Levi Neuwirth 313b1ff77a feat(fold): Stage 2 — grid (daemon-rendered) collapse
Implements docs/folding-stage2-framing.md rev 4. The daemon grid
renderer now consults the fold store: hidden lines are omitted, rows
below shift up, and every consumer that assumed
`display_row = source_line - view_top` routes through one shared
projection. No wire schema change and no protocol bump (Bet B6) — the
collapse is entirely daemon-side; the GPU path is Stage 3.

The spine (Q#FD12) is `src/fold_view.rs`: a `VisibleLineMap` derived
from `FoldRegistry::folds` plus a window's line offsets and never
stored. Its unit is a merged **hidden component** — overlapping OR
adjacent hidden intervals unioned, each keeping the one visible
`head_line` and that line's exact `head_position`. Adjacent intervals
merge because the later fold's head is itself hidden, which is what
makes nesting, shared heads, and crossing overlap all resolve to a
head that can actually render (round-3 F2).

Instances are short-lived and built **per rendered window** and **per
command/event operation**, never once per frame: `paint_frame` renders
several windows that may show different buffers, so a singleton would
leak one pane's folds into another (round-2 F2). The render instance
rides on a lifetime-bearing `Viewport<'a>` as `Option<&'a
VisibleLineMap>` — a shared ref is `Copy`, so `Viewport` stays `Copy`
(Bet B7).

Rendering:
- `TextView::render` walks visible lines; the head line gets a
  trailing content-area ellipsis (Q#FD13).
- The gutter walks visible lines too: Absolute keeps the raw `line+1`,
  Relative/Hybrid measure VISIBLE distance anchored on the cursor's
  visible head (Q#FD14). The fold glyph takes the col-0 sign cell only
  when a gutter exists — line numbers default to Off, so with no gutter
  the ellipsis is the sole marker (Q#FD20, round-1 F3). A diagnostic
  clamped onto the head wins that cell by paint order.
- A diagnostic on a hidden line clamps its SIGN to the outermost
  visible head (most-severe merge); the squiggle needs a real row, so
  only the sign clamps (Q#FD15).
- Caret, local selection endpoints, and peer cursors project via
  `visible_position_of` — the head row AND the head's end-of-content
  column, never an arbitrary column (round-2 F3). Peer presence derives
  the RECIPIENT window's map.
- Style/search/completion overlays route through
  `Viewport::row_offset_of`; the mode-line indicator reckons in
  visible-line space.

Command/event time is scoped per frontend (Q#FD21): a
`fold_projection` flag on `FrontendView`, set at attach from the
negotiated `semantic_render` bit (grid ⇒ true, semantic ⇒ false until
Stage 3, LOCAL ⇒ true) and never inferred from a `FrontendId` (Bet
B8). Without it, shared `EditorCore` motion would make a simultaneous
unfolded GPU session's cursor skip lines it still displays. The map's
two axes stay separate (round-3 F1): the acting frontend supplies the
policy, the operation's TARGET window supplies the buffer — a wheel
event names a pane without activating it.

Motion (Q#FD17, ruled: include), paging, wheel, the click inverse, and
the auto-scroll clamp all step by visible lines under that gate;
motion from a hidden logical cursor normalizes to the visible head
first. `view_top` stays a source-line index (Bet B5), set only via
`clamp_view_top` so it never rests hidden.

Unfold widening (Q#FD19): the pre-edit unfold moves to the top of
`apply_active_edit` — one funnel that subsumes the six primitives'
calls and covers yank + query-replace, both of which place point at
the edit site first. Interactive Lua mutators hook the common
`run_buffer_edit`, above the managed/bypass split, gated on
`InteractiveCommandOrigin` AND the edit targeting that frontend's
active-window buffer. The remote/optimistic-CRDT path stays excluded
(Stage 3); undo/redo unfold stays deferred.

Acceptance: `tests/folding_stage2_acceptance.rs`, 35 tests asserting
on the real `paint_frame` cell grid, covering framing items 1–14
including crossing folds, a nested deeply-hidden cursor, a split of
two different buffers with an inactive-pane wheel, and simultaneous
grid+semantic motion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
2026-07-23 19:24:07 -04:00
Levi Neuwirth ec42526652 feat(persistence): autosave + crash recovery (Arc 3 phase 3)
Framing: docs/autosave-recovery-framing.md (Q#AS1-11). Closes the
persistence arc. Every modified file buffer is periodically written to a
private recovery copy; if pmacs dies, the next session says so and
`M-x recover-file` installs it. Emacs's auto-save-mode + recover-file.

Hybrid, forced by the same two gaps as phase 2: Lua has no per-buffer
path getter and FileMeta is neither Lua-visible nor serde. Rust owns the
sweep and the external-change guard; Lua owns cadence, config, and UX.

src/autosave.rs (new):
- One atomic envelope per recovery: a JSON header line + `\n` + raw
  buffer bytes. Split at the FIRST newline, so contents may hold newlines
  and non-UTF-8. A crash can never leave a torn header/contents pair.
- `origin` is NULLABLE: a `[new file]` buffer (a path with nothing on
  disk) has no FileMeta, and its unsaved contents are exactly the work
  most worth recovering.
- status(): Fresh / Stale / Corrupt / None. Only Fresh is announced;
  Stale (file changed, deleted, or created underneath us) is never
  auto-offered; Corrupt is typed, quiet, and discardable.
- sweep(): all modified file buffers, skipping clean/scratch and those
  unchanged since their last copy. The skip cache is keyed
  BufferId -> (path_hash, revision), not revision alone: a buffer keeps
  its BufferId across a path change (LSP WorkspaceEdit rename), so a
  revision-only cache would skip the write and orphan the old key.
- pending(): enumerates ALL open file buffers in Rust, which is what
  covers argv `[new file]` buffers -- they fire no hook at all.

Private storage (Q#AS11, a precondition for default-on): autosave stores
unsaved FILE CONTENTS, not metadata. New `file_io::save_atomic_with_mode`
sets the temp's mode BEFORE the rename (a chmod-after-write leaves a
window where the file is 0644), and `state::write_private` creates the
dir 0700 and the file 0600. Plus `state::read_bytes` (state::read is
read_to_string, which non-UTF-8 buffer contents would fail).

builtin/runtime/autosave.lua:
- Cadence is `process.after-tick` + monotonic_ms, NOT workers.sleep: a
  long sleep parks one of only `available_parallelism - 1` pool threads,
  and re-reading the interval each tick makes it live-reconfigurable.
- pmacs.autosave.interval_ms([ms]) -- validated getter/setter following
  the async_config.frame_target_ms shape. Default 30000, floor 1000.
  pmacs.autosave.enable(on). On by default.
- Notify, never prompt: `after-load` only raises a flag; the tick emits
  ONE aggregate message ("3 files have autosave recovery"). A modal
  prompt from after-load would stack N modals during a desktop restore.
- recover-file confirms, pins to the origin buffer, replaces contents,
  then explicitly fires `buffer.after-edit` -- the mutators only notify
  windows and queue CRDT, and after-edit comes from dispatch_key's
  post-command check, which the minibuffer shadow returns before. Without
  the explicit fire, LSP didChange and the syntax reparse never see the
  recovery. discard-recovery deletes a copy (including a Corrupt one).
- Cleanup: after-save discards; per-buffer on_removed discards on kill
  (there is no global kill hook); before-quit does one final synchronous
  sweep and never vetoes.

src/hash.rs (new): one pub(crate) sha256_hex, shared by desktop, autosave,
and packages::fetcher -- which had two private duplicates (Q#AS9).

Not daemon-gated (unlike desktop-save): autosave is per-buffer, not
per-frontend, and a daemon holds the unsaved work.

Tests: 8 autosave units + 13 state/hash units + tests/autosave_acceptance
(15): sweep round-trip, non-UTF-8 envelope, [new file] null-origin
Fresh->Stale, 0600/0700 perms, skip clean/scratch/unchanged, path-change
rewrites new key + discards old, save/kill cleanup, Stale not offered,
Corrupt typed+quiet+discardable, recover-file installs + fires after-edit
+ leaves modified, tick aggregation (3 loads -> 1 message, no repeat),
single-file naming, interval validation + live change, enable gate,
before-quit sweeps without vetoing.

Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 15;
desktop 11; persistence 5; m4 90; m7_8 5; m8 10; GPU 58; git diff --check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:24:08 -04:00
Levi Neuwirth e56c3055f2 fix(persistence): reliable daemon gate, unarm, per-pane after-load
Addresses the PR #99 review:

- HIGH daemon local-only was not reliable: run_daemon sets DaemonMode
  only after EditorState::new() has run init.lua, so desktop_mode(true)
  in init saw is_daemon()==false and the raw bindings were ungated. Now
  save_session/restore_session early-return in Rust when the DaemonMode
  marker is present — set right after the daemon's new(), so it holds for
  every save/restore that can run after startup (before-quit hook, manual
  commands, direct binding calls).

- MEDIUM desktop_mode(false) could not unarm startup restore: arm_restore
  is now a boolean (arm_restore(on)) that sets/removes the marker, and
  desktop_mode(on) calls arm_restore(on). enable-then-disable no longer
  restores.

- MEDIUM/LOW same-file multi-pane missed per-window overlays: restore now
  fires buffer.after-load once PER LEAF (per window), not once per buffer.
  Syntax attaches its overlay to the active window, so each pane gets its
  own; LSP attach_buffer is idempotent, so the same file in two panes
  attaches LSP once but syntax to both.

- MEDIUM hidden restored buffers: documented as registry-only in v1 (they
  are live/openable/in recentf, but do not fire after-load, so they
  attach syntax on first visit via after-switch and LSP when next shown).
  Full initial attach for hidden buffers is deferred. Noted in the
  framing + a code comment.

- LOW trailing whitespace in docs/desktop-save-framing.md.

Tests (desktop_acceptance now 11): same_file_..._fires_per_pane asserts
after-load fires twice for two panes of one file; daemon_mode_disables_
save_and_restore; disabling_desktop_mode_unarms_restore.

Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 11;
persistence 5; m4 90; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:27:24 -04:00
Levi Neuwirth 3607df0afe feat(persistence): desktop-save --- buffers + layout + positions (Arc 3 phase 2)
Framing: docs/desktop-save-framing.md (Q#DS1-10). Save the open file
buffers, window layout, and per-window positions on quit; rebuild them
on startup. Emacs desktop.el, opt-in, local-mode only in v1.

All-Rust (Q#DS1) --- the core window enums are not serde and there is
no Lua tree API, so the layout mirror + structural rebuild live in Rust.
Lua adds only the opt-in switch and manual commands.

src/desktop.rs (new):
- Serde mirror (SavedDesktop / SavedBuffer / SavedNode / SavedLeaf /
  SavedOrientation): every open file buffer (visible OR hidden, so a
  switched-away file survives), the layout tree with orientation +
  weights + nesting, per-leaf cursor/view_top, and an active-leaf
  preorder index with a nearest-neighbor fallback (Q#DS10).
- session_key: SHA-256, name.<hex> when a socket name is set else
  cwd.<hex> (charset-safe for the pmacs.state key).
- save_session / restore_session take the &Lua that carries the
  SharedCore / StateDir / LocalInstanceInfo app-data, so they run
  identically from a pmacs.session.* binding and the startup trigger.
- restore ordering (Q#DS3): open all buffers; prune EVERY window of the
  old LOCAL layout (not just scratch); rebuild the tree; then per leaf
  in preorder activate its window and fire buffer.after-load once per
  newly-loaded buffer (hooks read active state), and write the exact
  cursor/view_top AFTER so desktop wins over saveplace (same file in two
  panes keeps distinct positions). A missing file collapses its leaf.

src/editor_core.rs: get_or_load_buffer(path) --- find_by_path else
load fresh, WITHOUT switching the active window; returns (id, newly).

src/lua_bindings: pmacs.session.{save_desktop, restore_desktop,
arm_restore, is_daemon}; DesktopRestoreArmed + DaemonMode markers;
fire_after_load_hook seam.

builtin/runtime/desktop.lua: pmacs.session.desktop_mode(on) wires
before-quit save + arms restore; desktop-save / desktop-restore
commands. No-op under a daemon (Q#DS9).

Startup trigger (Q#DS7): editor::run captures had_file before the match
consumes `file`, and restore_desktop_if_armed runs INSIDE the RunLocal
arm (after attach dispatch) so a hand-off to attach never populates an
EditorState it is about to drop. Daemon marks DaemonMode → desktop
stays local-only.

Tests: src/desktop.rs units (tree collapse, active-leaf fallback,
key/json round-trip) + tests/desktop_acceptance.rs (9): nested weighted
round-trip, hidden-buffer survival, after-load-active probe, same-file
two-pane distinct positions, missing-file collapse + focus fallback, no
orphan windows, name-vs-cwd key scoping, modified warning, startup gate.

Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 9;
persistence 5; m4 90; m8 daemon 10/15; query-replace/completion/
listview/overlay/cua green; GPU 58; git diff --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:28:43 -04:00