Merge pull request #187 from levineuwirth/bottom-panel-stage2b2
Bottom panel Stage 2B-2: the daemon panel projection and the epoch machine
This commit is contained in:
commit
300cbc4ef0
|
|
@ -5,11 +5,26 @@ landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
|
|||
entries when their PR merges; do not let this become a second permanent
|
||||
backlog.
|
||||
|
||||
**No lane below is retained past its merge.** The PTY terminate
|
||||
diagnostic (#176) was the last one — retained because rule 4 removes a
|
||||
**No lane below is retained past its merge.** This snapshot removes the
|
||||
resource-op delete guard (#186) and dired Stage 2 framing (#171) lanes
|
||||
the moment their PRs merged, because the same commit put their
|
||||
load-bearing decisions into `docs/agent-handoff.md` §1 — rule 4's
|
||||
precondition, satisfied deliberately rather than deferred. The
|
||||
bottom-panel lane is not removed: 2B-2 landing leaves 2B-3 and Stage 3
|
||||
ahead of it, so the lane is rewritten to the remaining plan.
|
||||
|
||||
**One open PR has no lane here: #188**, the generated-buffer
|
||||
immutability framing. Its lane lives on its own branch and arrives with
|
||||
it, which is deliberate — with several PRs open, a lane written here for
|
||||
work that lands elsewhere re-conflicts on every merge.
|
||||
|
||||
The PTY terminate
|
||||
diagnostic (#176) was the last lane retained past its merge — retained
|
||||
because rule 4 removes a
|
||||
merged lane only *after* its durable facts reach
|
||||
`docs/agent-handoff.md`, and that absorption was unowned. This snapshot
|
||||
owns it: #176's facts are now in the handoff (§1's arc bullet and §5's
|
||||
`docs/agent-handoff.md`, and that absorption was unowned. The
|
||||
2026-07-28 snapshot
|
||||
owned it: #176's facts are now in the handoff (§1's arc bullet and §5's
|
||||
two ops lessons about ticking observers and proving child exit), so its
|
||||
lane is gone. The Lean 4, GPU-terminal-input, inline-math (#172), dired
|
||||
(#169), and terminal config + copy mode lanes were removed the same way
|
||||
|
|
@ -20,14 +35,15 @@ would stop re-conflicting in this file.
|
|||
number appears in `git log --first-parent githubsucks/main`, it has
|
||||
landed regardless of what a lane says.
|
||||
|
||||
**Two open PRs had no lane here at all before this snapshot** — #174 and
|
||||
#171. An open PR is exactly the volatile work this file exists to
|
||||
record, so its absence is a ledger defect rather than a tidy omission:
|
||||
#171 drifted **153 commits** while invisible here, and its still-green
|
||||
old CI run describes a tree nobody has looked at since. **When a PR is
|
||||
opened, give it a lane.** #174 has since merged, so per rule 4 its lane
|
||||
is gone again and its durable lesson is in `docs/agent-handoff.md` §5;
|
||||
#171's lane is below.
|
||||
**Two open PRs had no lane here at all before the 2026-07-28
|
||||
snapshot** — #174 and #171. An open PR is exactly the volatile work this
|
||||
file exists to record, so its absence is a ledger defect rather than a
|
||||
tidy omission: #171 drifted **153 commits** while invisible here, and
|
||||
its still-green old CI run described a tree nobody had looked at since.
|
||||
**When a PR is opened, give it a lane.** All three have since merged —
|
||||
#174, #171 and #186 — so per rule 4 their lanes are gone again and
|
||||
their durable facts are in `docs/agent-handoff.md` (§5 for #174's
|
||||
lesson, §1 for the two framings).
|
||||
|
||||
## Repository authority
|
||||
|
||||
|
|
@ -38,16 +54,17 @@ is gone again and its durable lesson is in `docs/agent-handoff.md` §5;
|
|||
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||
or something else, and therefore has no authority by name alone.
|
||||
- Canonical base at this snapshot:
|
||||
`githubsucks/main` @ `0442d78` (the M4 config-sink race fix #174, atop
|
||||
bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet
|
||||
#183, Journey Stage 1a #182 and the previously recorded landed work).
|
||||
`githubsucks/main` @ `7586905` (the docs-only coherence listview
|
||||
correction #189, atop the docs-only landed-state refresh #185, the M4
|
||||
config-sink race fix #174, bottom-panel Stage 2B-1 #184, the
|
||||
Journey/GPU directory-target ratchet #183, Journey Stage 1a #182 and
|
||||
the previously recorded landed work).
|
||||
**Protocol schema support is
|
||||
`v6..=v21`; the production server-first `Hello` still advertises
|
||||
v20** — two different facts, and #184 landed only the first. The
|
||||
previous snapshot named `7fd646d`, and **the
|
||||
previous snapshot named `0442d78`, and **the
|
||||
recovery floor advances with it**: the check below now requires
|
||||
`0442d78` or newer, so a tree at `7fd646d` — or at `6bee09d` — no
|
||||
longer passes. That is
|
||||
`7586905` or newer, so a tree at `0442d78` no longer passes. That is
|
||||
deliberate — the floor moves with the base, because a check that
|
||||
accepts an older commit than the declared base passes on a tree the
|
||||
rest of this file does not describe.
|
||||
|
|
@ -86,7 +103,7 @@ git worktree list
|
|||
git status --short --branch
|
||||
```
|
||||
|
||||
The `git log` command must expose `0442d78` — the base named above — or a
|
||||
The `git log` command must expose `7586905` — the base named above — or a
|
||||
newer intentional main. Keep this threshold and the canonical-base line in
|
||||
step: a recovery check that accepts an older commit than the base it
|
||||
declares canonical will pass on a tree the rest of this file does not
|
||||
|
|
@ -228,17 +245,16 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
never been enforced. Any CI job that compiles the `crdt` targets has to
|
||||
fix them first or it will be red on arrival.
|
||||
|
||||
## Bottom-panel lane (Arc 7) — 2B-1 MERGED; 2B-2 IN FLIGHT
|
||||
## Bottom-panel lane (Arc 7) — 2B-2 MERGED; 2B-3 IS NEXT
|
||||
|
||||
Stage 1, the Stage 2 framing, Stage 2A, and **Stage 2B-1 are all on
|
||||
`main`**. Framing revision 5's three-way split of 2B was explicitly
|
||||
approved on 2026-07-27; revision 6 records PR #184's review correction.
|
||||
**2B-2 — the daemon panel projection and epoch machine — is under way**
|
||||
on branch `bottom-panel-stage2b2`, worktree `../pmacs-bp-stage2b2`,
|
||||
branched fresh from `githubsucks/main` @ `6bee09d`. It is *not* stacked
|
||||
on the 2B-1 branch, which is the rule for every slice in this arc. Note
|
||||
that `main` has since advanced to `0442d78`; the only difference is the
|
||||
test-only #174, so the slice's integration surface is unchanged.
|
||||
Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and **Stage 2B-2 are
|
||||
all on `main`**. Framing revision 5's three-way split of 2B was
|
||||
explicitly approved on 2026-07-27; revision 6 records PR #184's review
|
||||
correction. **2B-2 — the daemon panel projection and epoch machine —
|
||||
landed as [PR #187](https://github.com/levineuwirth/pmacs/pull/187)**,
|
||||
one review round of five findings on top of the implementation, 12/12
|
||||
green, 22/22 mutations biting. Its durable lessons are in
|
||||
`docs/agent-handoff.md` §1; what remains below is the 2B-3 plan.
|
||||
|
||||
**2B-2's boundaries, restated because they are easy to overrun:** the
|
||||
production `Hello` stays at v20 and `panel_capable` stays `false`. The
|
||||
|
|
@ -247,6 +263,52 @@ activation, the GPU band, and the negotiated capability flip are all
|
|||
2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to
|
||||
21.
|
||||
|
||||
- **What PR #187 shipped, dark by construction:** the semantic daemon's
|
||||
`FrontendCellGeometry` epoch machine; one reconciled panel grid
|
||||
derivation; `PanelFrame::{Present, Absent}` projection on both document
|
||||
and terminal semantic paths; stable presentation epochs; resize and
|
||||
pointer validation against the live window/buffer/epochs; the panel's
|
||||
own statusline context; and pre-drain semantic panel-terminal resize.
|
||||
It does not add the GPU consumer or enable the capability.
|
||||
- **Review round 1 closed five findings plus one sweep result at
|
||||
`3ecb03d`.** The wire-area clamp became durable hide state; a stale
|
||||
same-buffer reopen can no longer retain input authority; semantic panel
|
||||
terminals resize before child drain; `NoMessage` retains a published
|
||||
band baseline while `Invalidated` clears it; wheel activation follows
|
||||
the terminal-only focus rule; and legally wide panels clamp their PTY
|
||||
content without disappearing.
|
||||
- **Review round 2 closed two findings at `bfaaf2b` plus this ledger
|
||||
commit.** Side affinity can replace the buffer while preserving the
|
||||
`WindowId`, so retained panel statusline segments are now keyed by the
|
||||
full `(WindowId, BufferId)` presentation. Every authoritative `Absent`
|
||||
also clears that baseline, including duplicate-suppressed `Absent`, so
|
||||
a later `Present` under `NoMessage` cannot resurrect peer state that
|
||||
was already cleared. Two acceptance tests bite those exact transitions.
|
||||
This lane and `docs/agent-handoff.md` now name the open PR, current
|
||||
landed base, checkpoint, and 2B-3 ordering instead of calling 2B-2
|
||||
merely “next.”
|
||||
- **Round-2 verification at code checkpoint `bfaaf2b`:** formatting and
|
||||
strict workspace Clippy; library **1,863 passed + 3 ignored** default
|
||||
and **2,048 passed + 4 ignored** CRDT; bottom-panel Stage 1 / 2A /
|
||||
2B-1 / 2B-2 **46 / 17 / 16 / 28**; statusline **8 CRDT**; semantic
|
||||
routing **2 CRDT**; M4 **121 passed + 3 ignored + 1 filtered**;
|
||||
required GPU **202/202**; isolated-config full workspace sweep; and
|
||||
`git diff --check`. The first workspace sweep had one GPU rendering
|
||||
failure in `failures_and_display_math_render_as_source`; that test had
|
||||
passed in the immediately preceding required-GPU run, passed alone,
|
||||
and the complete workspace rerun passed. Real-daemon and managed-attach
|
||||
cases were rerun outside the tool sandbox after its local-socket policy
|
||||
produced `Operation not permitted`; the authoritative reruns passed.
|
||||
- **Cross-machine recovery (fresh clone):**
|
||||
|
||||
```sh
|
||||
git fetch githubsucks --prune
|
||||
git switch --track -c bottom-panel-stage2b2 githubsucks/bottom-panel-stage2b2
|
||||
git rev-parse HEAD
|
||||
```
|
||||
|
||||
#187 has landed, so `githubsucks/main` already contains this work and
|
||||
the branch is retained only for provenance. Start 2B-3 from `main`.
|
||||
- **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all
|
||||
twelve checks green on the reviewed head `5539b6e`; two review rounds
|
||||
plus a gate-found follow-up). Branch
|
||||
|
|
@ -459,341 +521,6 @@ has **no branch and no framing yet**.
|
|||
`FrontendView.fold_projection` to `true` for semantic frontends, which
|
||||
Stage 2 deliberately left `false` (Q#FD21).
|
||||
|
||||
## Resource-op delete guard lane — PR #186 OPEN, PROPOSED, DO NOT MERGE
|
||||
|
||||
- Portable branch: `githubsucks/resource-op-delete-guard`; worktree
|
||||
`../pmacs-resource-op-delete`. **PR #186**, base `main`. Currently
|
||||
framing only — `docs/resource-op-delete-guard-framing.md`, **revision
|
||||
5** — plus this lane entry. No runtime code yet.
|
||||
- **Measured 2026-07-28, `main` @ `7586905`:**
|
||||
|
||||
```
|
||||
$ git rev-list --left-right --count HEAD...githubsucks/main
|
||||
5 0
|
||||
```
|
||||
|
||||
Five commits ahead, **0 behind** at the pushed revision-5 head. This
|
||||
count includes the revision commit itself; revision 4 recorded the
|
||||
pre-commit count and was therefore one short.
|
||||
- **This PR becomes the implementation PR.** Revision 2 dropped rev 1's
|
||||
framing-PR-then-implementation-PR plan as a one-feature/one-branch/
|
||||
one-PR violation. The framing is revised in place; implementation
|
||||
commits land on this same branch **only after explicit user
|
||||
approval**.
|
||||
- **Live data-loss bug, reproduced four ways against `ad41cf1`.**
|
||||
`pmacs.buffer.apply_resource_op`'s delete arm removes the path from
|
||||
disk and *then* drops any buffer bound to it, with no dirty check at
|
||||
any link — not the arm, not `remove_buffer_and_fire`, and not
|
||||
`BufferRegistry::remove`, whose only guard is `editing_in_progress`.
|
||||
Reachable through any language server's `WorkspaceEdit`. The four
|
||||
modes: (a) the plain case returns `Ok(())` with file and buffer both
|
||||
gone; (b) `ignore_if_not_exists = true` does **zero** filesystem work
|
||||
and still destroys the buffer; (c) `recursive = true` reconciles
|
||||
**nothing**, so a whole tree leaves orphaned buffers — the most
|
||||
destructive arm does the least reconciliation, and it bypasses any
|
||||
exact-path guard; (d) removal is not `kill_buffer`, so windows are
|
||||
left bound to a removed `BufferId` and the registry can be driven to
|
||||
**empty**.
|
||||
- **Approved in principle after review round 1; revision 5 closes round
|
||||
4's two contract P1s and the ledger-ownership P1. Still PROPOSED,
|
||||
still not approved for implementation.** Settled: refuse
|
||||
unconditionally; take the delete side now. Withdrawn: rev 1's
|
||||
buffer-first ordering. The design is `stat/no-op/refuse → enumerate
|
||||
and validate → mutate filesystem → reconcile`, which keeps
|
||||
`on_removed`'s "path already gone" invariant and makes a failed
|
||||
deletion leave buffers intact automatically.
|
||||
- **The stable cross-lane ownership split with #171:**
|
||||
|
||||
> #186 owns the urgent **pre-filesystem refusal** for synchronous
|
||||
> `apply_resource_op`. #171 later owns **full post-delete lifecycle
|
||||
> reconciliation**, including the **async race where a buffer becomes
|
||||
> modified after dired dispatch**.
|
||||
|
||||
#186 additionally **owns the shared walk query** (scan every
|
||||
path-bound buffer, normalize once, component-aware `Path::starts_with`)
|
||||
under the boundary's "whichever lands first owns the query"; #171
|
||||
adopts it and extends it to `reconcile_rename`. **Neither lane guards
|
||||
`pmacs.fs.remove`** — zero production callers today, named out of
|
||||
scope by both.
|
||||
- **#171 owns its own lane entry.** Revision 4 rewrote that sibling
|
||||
block and was stale before push when #171 revision 8 landed 67 seconds
|
||||
earlier. Revision 5 restores the block to `main`'s tree, so #186's
|
||||
diff no longer changes it. The one fact this lane depends on is the
|
||||
policy split above, which is stable through #171's pushed revision 8
|
||||
and independent of its commit count.
|
||||
- **Standing rule this lane learned the expensive way.** A census is a
|
||||
reading, not a constant. **Do not write an ahead/behind count, a line
|
||||
count, or a call-site count into this file that you have not just
|
||||
produced with a command whose output you can paste.** #186 shipped a
|
||||
stale line count, then a stale commit count, then a stale ledger
|
||||
citation, in three consecutive revisions — each time by carrying a
|
||||
measurement across a base change instead of re-running it. The
|
||||
specific trap: a count taken against `ad41cf1` was reported in
|
||||
present tense after `main` had moved to `7586905`, which silently
|
||||
converted "0 behind" into a falsehood.
|
||||
- **Four facts a re-scout should not have to rediscover**, all verified
|
||||
at `ad41cf1`:
|
||||
- **No caller reliably surfaces a raise.** The server pump runs under
|
||||
`pcall(handle_server_requests)` (`builtin/runtime/lsp.lua:1892`), so
|
||||
a raise unwinds past the `send_response` and the server is never
|
||||
answered; and the two user-initiated paths route uncaught coroutine
|
||||
errors through `pmacs.error`, which is **undefined** (11 call sites
|
||||
in `builtin/`, zero definitions). Refusals must travel as values.
|
||||
- **A partial batch is already the status quo** — verified in-repo:
|
||||
two delete ops, the second raises, the first stayed applied. Any
|
||||
framing claiming batch atomicity here is wrong. **Do not justify
|
||||
this from the LSP spec.** Revision 2 of #186 wrote that LSP 3.18
|
||||
"assigns `FailureHandlingKind.Abort` to resource-op-bearing edits";
|
||||
**it does not** — recovery is described by the client's advertised
|
||||
`workspace.workspaceEdit.failureHandling`, `Abort` is one of four
|
||||
strategies, only `TextOnlyTransactional` degrades to abort for
|
||||
resource changes, and **pmacs advertises none of them**. The
|
||||
justification is repository evidence plus the judgement that a
|
||||
visible partial refactor beats unrecoverable unsaved work.
|
||||
- **`find_by_path` is singular and duplicates are reachable.**
|
||||
`BufferRegistry::find_by_path` returns the first match in insertion
|
||||
order, `EditorCore::find_buffer_for_path` inherits that, and
|
||||
`pmacs.buffer.from_file` creates path-bound buffers with **no
|
||||
dedup** — so a clean first match can hide a modified second. The
|
||||
guard needs a full scan with component-aware `Path::starts_with`.
|
||||
- **pmacs advertises no `workspace.workspaceEdit` capability at all** —
|
||||
`"applyEdit": true` but no `documentChanges`, no
|
||||
`resourceOperations`, no `failureHandling`; `grep -rn
|
||||
failureHandling` returns 0. Parked, not fixed here.
|
||||
- **Ownership claim, concretely.** For this lane's duration #186 owns:
|
||||
the pre-filesystem refusal inside synchronous `apply_resource_op`; the
|
||||
**shared walk query**; and `builtin/runtime/lsp.lua`'s
|
||||
`apply_workspace_edit` plus the `workspace/applyEdit` server-request
|
||||
boundary. It does **not** own: full post-delete lifecycle
|
||||
reconciliation, the dired async race between dispatch and
|
||||
`remove_blocking`, the rename side of the walk, or `pmacs.fs.remove`.
|
||||
Do not run the two lanes concurrently over `builtin/runtime/lsp.lua`
|
||||
without re-splitting that claim.
|
||||
- **Two residues #186 deliberately leaves for #171**, both named rather
|
||||
than silent: after a successful *clean* recursive delete, descendant
|
||||
buffers stay orphaned-and-clean (widening removal would promote the
|
||||
dangling-window/empty-registry defect from exact-path to tree-wide);
|
||||
and after a successful delete with several clean duplicates on one
|
||||
path, only the first is reconciled. #186 validates **every** match but
|
||||
reconciles **one**, which is today's behaviour preserved on purpose.
|
||||
- Files the implementation will touch: `src/lua_bindings/mod.rs`,
|
||||
`builtin/runtime/lsp.lua`, `tests/m4_acceptance.rs`,
|
||||
`tests/lsp_dispatch_seams_acceptance.rs`,
|
||||
`src/bin/pmacs_fake_lsp.rs`. **Not** `src/daemon.rs`,
|
||||
`pmacs-protocol/`, `builtin/runtime/dired.lua`,
|
||||
`docs/agent-handoff.md` or `COHERENCE.md`. No protocol change.
|
||||
- Recovery from a clean checkout:
|
||||
`git fetch githubsucks && git worktree add ../pmacs-resource-op-delete
|
||||
-b resource-op-delete-guard githubsucks/resource-op-delete-guard`.
|
||||
|
||||
## dired Stage 2 framing lane — PR #171 AT REVISION 9, AWAITING APPROVAL
|
||||
|
||||
- Portable branch: `githubsucks/dired-stage2-framing`;
|
||||
worktree `../pmacs-dired-stage1`. **PR #171**, base `main`, integrated
|
||||
up to canonical `main` @ `7586905`. Framing only —
|
||||
`docs/dired-stage2-framing.md`, **3,844 lines measured at this
|
||||
revision**, no runtime code. This lane rides that PR — it has since
|
||||
rev 6, when #185 merged; do not open a standalone ledger PR.
|
||||
- **Status: PROPOSED, never approved.** Revision 5 was reviewed and NOT
|
||||
approved (six findings, four P1); revision 6 answered them; **revision
|
||||
7 resolved a cross-lane conflict with PR #186**; revision 8 was
|
||||
reviewed and NOT approved (six findings, four P1); revision 9 answers
|
||||
them. The framing is at revision 9 after seven review rounds; **that
|
||||
is not the same as approval**, and it must not be read as one.
|
||||
- **Round 8 (rev 9) made every rev-8 correction singular and
|
||||
executable:**
|
||||
- Q#DR26 no longer retains the withdrawn execution-order claim; it
|
||||
owns only the homogeneous enum/vector shape and delegates sequence
|
||||
semantics to Q#DR29: **bus arrival, not filesystem execution**.
|
||||
- 2a owns acceptance **23–38 and 50–55, including every suffixed
|
||||
item**, so rev 8's new tests cannot fall between implementation PRs.
|
||||
- The LSP gate is one manager-owned, generation-scoped
|
||||
`HashSet<(LspServerId, String)>`. Both
|
||||
`absorb_publish_diagnostics` and the now-server-aware
|
||||
`mark_document_stale` check the exact pair; `did_open` clears one
|
||||
pair, and server teardown clears all and only that server's pairs.
|
||||
It is reclaimed, not falsely called size-bounded. Acceptance covers
|
||||
both writers and both reclamation paths.
|
||||
- Buffer-name following now uses explicit
|
||||
`BufferNameOrigin::{Explicit, PathDerived}` state. The acceptance
|
||||
collision is a file `${cwd}/notes` explicitly named `notes`, which
|
||||
defeats rev 8's path-equivalence heuristic.
|
||||
- **Round 7 (rev 8) found five contract defects of ONE family, and the
|
||||
family is the transferable lesson: a guarantee assumed from a
|
||||
mechanism whose implementation was never read.** Reply order assumed
|
||||
to be execution order; a refusal assumed to be inert; `forget_uri`
|
||||
assumed to cover every writer. Three of the five were literally that.
|
||||
The decisions:
|
||||
- **Reply order is NOT execution order.** `AsyncRuntime::tick`
|
||||
(`src/async_runtime.rs:1003`) is `while let Ok(env) =
|
||||
self.main.try_recv()` — a bus drain with **no execution token**, so
|
||||
a worker can finish, be descheduled before sending, and have a later
|
||||
reply arrive first. The ordering guarantee was **withdrawn, not
|
||||
engineered**: reconciliation is order-independent, and `fs.lua:155-165`
|
||||
already recommends serialization. Rev 9 makes the public contract
|
||||
exact: overlapping mutations dispatch serially, awaiting one before
|
||||
the next. A lock was rejected — it would serialize every fs mutation
|
||||
for a hazard absent from shipped in-tree callers.
|
||||
- **`EditorCore::kill_buffer` damages editor state BEFORE it can
|
||||
refuse.** It clears `round_trip_buffers`, closes side windows and
|
||||
redirects ordinary windows, and only then can
|
||||
`BufferRegistry::remove` return `ConcurrentEdit`. So "refused" is
|
||||
not "nothing happened". Fixed by preflighting `editing_in_progress`
|
||||
— sound because phase 1 is pure `EditorCore` and holds no Lua
|
||||
handle, so nothing can re-enter between check and removal.
|
||||
- **Buffer names are set from `path.display()` AS GIVEN** while only
|
||||
`file_path` is normalized (`src/editor_core.rs:917`,
|
||||
`src/lua_bindings/mod.rs:3112`), so a relative open leaves a short
|
||||
name that a string-equality rule mistakes for user-chosen. Rev 8
|
||||
moved to path-equivalence; rev 9 replaces that inference with
|
||||
explicit provenance because a custom name can equal the path.
|
||||
- **`diag_store` has ZERO correlated writers**, and
|
||||
`DiagnosticStore.by_uri` is keyed by **URI alone** with no server
|
||||
component (`src/diag.rs:198`) — so the store most needing protection
|
||||
is the one a `pending_routes` purge cannot help. Rev 8 incorrectly
|
||||
inferred that the tombstone must share the store's URI-only key;
|
||||
rev 9 gates in `LspManager`, where `sid` is still available.
|
||||
- **A first pass of rev 8 checked only `handle_notification` and
|
||||
concluded `publishDiagnostics` was the only uncorrelated writer.
|
||||
Wrong lens boundary** — `pub fn mark_document_stale`
|
||||
(`src/lsp.rs:3108`) takes **no `LspServerId`** and creates URI keys
|
||||
across three stores for every server. Rev 9 changes that surface and
|
||||
its private Lua binding to `(sid, uri)` and pins it independently.
|
||||
- **Both new hooks are `all-must-succeed`, not short-circuit** — a gap
|
||||
the review did not raise and the sweep found. `src/hook.rs` defines
|
||||
three `HookKind`s; registered short-circuit, one `resource.renamed`
|
||||
subscriber returning falsey would silently stop every later
|
||||
subscriber reconciling.
|
||||
- **CROSS-LANE SPLIT WITH PR #186 — settled, recorded verbatim so the
|
||||
two lanes cannot diverge again:**
|
||||
|
||||
> #186 owns the urgent **pre-filesystem refusal** for synchronous
|
||||
> `apply_resource_op`. #171 later owns **full post-delete lifecycle
|
||||
> reconciliation**, including the **async race where a buffer becomes
|
||||
> modified after dired dispatch**. #171's revision 7 adopts the
|
||||
> refusal and stops saying LSP intentionally deletes modified files.
|
||||
|
||||
**PR #186 (`resource-op-delete-guard`) owns the pre-filesystem
|
||||
refusal.** Do not implement a competing dirty check from #171. Rev 7
|
||||
adopts #186's Q#RD1 (refuse, do not prompt, do not save, do not back
|
||||
up) and its Q#RD6 shared query (scan every path-bound buffer, not
|
||||
first-match; normalize once; component-aware `Path::starts_with`);
|
||||
whichever lane lands first owns that query and the other adopts it.
|
||||
The two fit deliberately: #186's Q#RD5 keeps reconciliation
|
||||
**exact-path** so it does not promote the dangling-window and
|
||||
last-buffer defects tree-wide, and #171's Q#DR27 is the lane that
|
||||
makes widening safe.
|
||||
- **What rev 7 removed.** Rev 6 said an LSP-authored delete removes the
|
||||
file and orphans the modified buffer as accepted residue. That is
|
||||
withdrawn — **20 matched lines across 13 passages** were censused and
|
||||
reclassified: 4 ownership claims reassigned to #186, 4 policy claims
|
||||
deleted, 3 ground-truth statements kept but attributed, 2 async-race
|
||||
claims kept and narrowed.
|
||||
- **What rev 7 kept, and it is now one path not two.** The async race —
|
||||
a buffer modified between dired's pre-dispatch check and
|
||||
`remove_blocking`'s syscall — survives #186's refusal, because **dired
|
||||
never goes through `apply_resource_op`**: it calls `pmacs.fs.remove`,
|
||||
which dispatches a worker no in-applier guard can see. Rev 6 carried
|
||||
this as "two paths, LSP and dired"; after #186 the LSP path has no
|
||||
residue at all.
|
||||
- **A gap neither lane closes, named in rev 7 §11:** `pmacs.fs.remove`
|
||||
is public Lua API with **no dirty check of its own**. After both lanes
|
||||
land the guards sit one layer above it on each side — at
|
||||
`apply_resource_op` (#186) and in dired's policy layer (#171) — so a
|
||||
third caller inherits neither. Latent, not live: it has zero
|
||||
production callers today.
|
||||
- **LSP failure-handling, verified against the 3.18 spec** because #186
|
||||
got it wrong and this lane was checked for the same error. **This lane
|
||||
never made the claim** — it asserts nothing about `WorkspaceEdit`
|
||||
recovery. For the record: only `textOnlyTransactional` degrades to
|
||||
abort for resource operations; `transactional` covers them; and the
|
||||
specification states **no default** for a client that does not
|
||||
advertise `failureHandling` (pmacs advertises none). So "Abort applies
|
||||
by omission" does not follow.
|
||||
- **Round 5's single theme, worth carrying because it will recur:** rev
|
||||
5 changed the slice split and the ownership of a decision, and the
|
||||
prose did not follow. Four of the six findings were that same defect
|
||||
in different places — stale slice labels, an acceptance allocation
|
||||
that contradicted the code split, and a PR body still describing
|
||||
revision *1*'s two-slice plan. **When a cut changes, sweep for the
|
||||
label, do not patch the instance.** Rev 6's own sweep found four more
|
||||
the review had not cited, one of which was wrong in both halves
|
||||
(`x`'s report tagged 2a, `remove_dir_all` tagged 2b).
|
||||
- **Rev 4 was scouted at `c8ec8f3`, which is dired Stage 1's own merge
|
||||
commit (#165)** — so dired Stage 1 and find-file (#162) were always
|
||||
its base, not new arrivals. Eighteen PRs landed on top before the
|
||||
re-scout. Rev 5 is that re-scout, against `6bee09d` and then
|
||||
integrated forward.
|
||||
- **Seven of rev 4's own claims about pmacs were wrong**, which is the
|
||||
lane's most transferable lesson: a framing can verify all its external
|
||||
facts and still be wrong about the codebase it is for. The load-bearing
|
||||
one: §5 named `drain_external_cancelled` but cited `lsp.rs:1596`.
|
||||
**Those are two different functions 35 lines apart** —
|
||||
`drain_external_cancelled` (`:1561`) is the unconditional
|
||||
server-scoped drain and is the right precedent;
|
||||
`drain_cancelled_externals` (`:1596`) is a per-tick token/timeout
|
||||
sweep. A rename flips no token, so following the line number yields a
|
||||
`forget_uri` whose drain half is a silent no-op and the awaiting
|
||||
coroutine hangs forever — exactly the failure that step exists to
|
||||
prevent.
|
||||
- **The rename path-owner census is SIX, not five.** The sixth is
|
||||
`lean.lua`'s `M.file_progress`, a URI-keyed Lua module table living in
|
||||
no Rust store, so `forget_uri` structurally cannot reach it. That is
|
||||
the first case outside dired proving the **hook**, not the Rust
|
||||
method, is the mechanism that scales. The other five: buffer path,
|
||||
buffer name, the URI-keyed LSP stores plus `DiagnosticView` (URI set
|
||||
once at construction), dired's pathless handles, and a captured Lua
|
||||
local no transaction can reach.
|
||||
- **The journey ratchet is split across two files**, and both are gates:
|
||||
`tests/journey_acceptance.rs` (24 tests, "stages add rows, none
|
||||
removes them", seven rows asserting on dired) and
|
||||
`gpu_invocation_acceptance.rs`, where #183 actually put the GPU row.
|
||||
A scout that checks only the first will think #183 added nothing.
|
||||
- **`open_directory` now commits under `pmacs.window.commit_to`, whose
|
||||
scope REFUSES an `await`** (#182). That directly constrains the
|
||||
serialize-and-await batch contract the framing proposes.
|
||||
- **The typed-edit chain does not reach dired** — verified rather than
|
||||
assumed. Its lessons bind the framing's two *new* hooks instead.
|
||||
- **Scope MOVED OUT of this lane, and rev 6 records the handoff.**
|
||||
Rev 5 added Q#DR25 to adopt `set_generated_contents` at the head of
|
||||
2b, because dired's listing is a generated buffer whose paint bypasses
|
||||
an intercept over a still writable rope. That turned out to be a
|
||||
**class bug, not dired's** — the same idiom is in listview, compile
|
||||
and search/grep, and **no Lua caller anywhere sets `read_only`**
|
||||
because there is no Lua `set_read_only` to call — so it is owned by
|
||||
the generated-buffer immutability lane. **Done: rev 6 withdraws
|
||||
Q#DR25**, and §3.1 hands that lane what this one's re-scout found,
|
||||
including the trap that
|
||||
`dired_buffer_is_read_only_and_round_trips_input` passes **either
|
||||
way** and is therefore not coverage of the adoption. Do not implement
|
||||
Q#DR25 from this document.
|
||||
- **Two substrate facts rev 6 added that outlive this lane.** (1) Buffer
|
||||
removal is **two phases** and **no existing Rust path composes them**:
|
||||
`EditorCore::kill_buffer` (`src/editor_core.rs:4590`) does windows and
|
||||
registry, `after_buffer_removed` (`src/lua_bindings/mod.rs:1602`) does
|
||||
keymaps/config/folds/`on_removed`, and only the Lua binding
|
||||
`pmacs.buffer.kill` (`mod.rs:5476-5491`) runs both — while
|
||||
`apply_resource_op`'s delete uses `remove_buffer_and_fire`, which
|
||||
skips phase 1, **so a window displaying the deleted buffer keeps a
|
||||
removed id**. That is a third defect on that arm beside the missing
|
||||
dirty check and the first-match lookup. (2) The drain outcome must
|
||||
carry deletions as well as renames in one `Vec<ResourceOp>` so every
|
||||
kind is expressible, but its sequence is **bus-arrival order, not
|
||||
filesystem execution order**; Q#DR29 requires callers with
|
||||
interdependent mutations to serialize.
|
||||
- Intended serial implementation once approved: **2a** rename/delete
|
||||
reconciliation substrate with no dired UI, **2b** marks and
|
||||
operations, **2c** mkdir/copy/recursive-delete primitives, then Stage
|
||||
3 wdired. The re-scout re-examined this cut and it holds unchanged.
|
||||
- **Ownership warning:** dired 2a overlaps `src/editor_core.rs`,
|
||||
`builtin/runtime/lsp.lua`, and the URI-keyed LSP state with other
|
||||
coherence work. Do not run it concurrently with Journey Stage 1b
|
||||
without assigning those files to one lane first.
|
||||
- **Two live bugs on `main` this lane confirmed but does NOT fix**, both
|
||||
now owned elsewhere: `apply_resource_op`'s delete arm destroys unsaved
|
||||
work (no dirty check anywhere on the path), and `View` still lacks
|
||||
`rename_resource`.
|
||||
|
||||
## Parked lane: kill-ring browser + persistence
|
||||
|
||||
- Portable branch: `githubsucks/kill-ring-browser`
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-28, after the M4 config-sink race fix (#174) and
|
||||
bottom-panel Stage 2B-1 (#184) merged; the canonical landed base is
|
||||
`0442d78`. #174 is test-only. #184 is the substantive one — the reserved
|
||||
protocol-v21
|
||||
**Last updated: 2026-07-28, as bottom-panel Stage 2B-2 (#187) — the
|
||||
daemon panel projection and the epoch machine — lands on `6c9e765`,
|
||||
which is the dired Stage 2 framing (#171) atop the resource-op delete
|
||||
guard framing (#186). Those two are framing-only: both are approved
|
||||
documents with no runtime code, and neither has begun implementation.
|
||||
Beneath them, the docs-only coherence listview
|
||||
correction (#189) and landed-state refresh (#185); the canonical
|
||||
landed base beneath those is `7586905`. The runtime anchor beneath them is the M4
|
||||
config-sink race fix (#174) and bottom-panel Stage 2B-1 (#184). #174 is
|
||||
test-only. #184 is the substantive one — the reserved protocol-v21
|
||||
bottom-panel wire family, dark by construction, with the production
|
||||
handshake deliberately still advertising v20 — following the Journey/GPU
|
||||
directory-target ratchet (#183), following Journey Stage 1a (#182),
|
||||
|
|
@ -51,9 +57,14 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
|
||||
## 1. Where the project stands (2026-07-28)
|
||||
|
||||
- `main` @ `0442d78` (the M4 config-sink race fix #174 — test-only —
|
||||
atop bottom-panel Stage 2B-1 #184, the Journey/GPU
|
||||
directory-target ratchet #183, Journey Stage 1a #182, incorporating
|
||||
- `main` @ `6c9e765` (the dired Stage 2 framing #171 and the resource-op
|
||||
delete guard framing #186 — both framing-only, no runtime code, no
|
||||
implementation started — atop
|
||||
the docs-only coherence listview correction #189,
|
||||
atop the docs-only landed-state refresh #185, the M4 config-sink race
|
||||
fix #174 — test-only — atop bottom-panel Stage 2B-1 #184, the
|
||||
Journey/GPU directory-target ratchet #183, Journey Stage 1a #182,
|
||||
incorporating
|
||||
terminal configuration + copy
|
||||
mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed
|
||||
docs #169 and the PTY-terminate diagnostic #176, terminal copy mode
|
||||
|
|
@ -518,20 +529,23 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
|
||||
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
|
||||
Stage 2 48. All 12 CI checks green at merge.
|
||||
- **Stage 2 (the GPU panel band) is FRAMED, and its first two slices
|
||||
have LANDED** — `docs/bottom-panel-stage2-framing.md` rev 6, four
|
||||
- **Stage 2 (the GPU panel band) is FRAMED; its first two slices have
|
||||
LANDED and its third is open as PR #187** —
|
||||
`docs/bottom-panel-stage2-framing.md` rev 6, four
|
||||
framing review rounds, no open framing items; the rev-5
|
||||
implementation split was explicitly approved 2026-07-27 and rev 6
|
||||
records PR #184's server-first compatibility and gate correction.
|
||||
It reserves protocol **v21** and ships as four serial
|
||||
implementation slices: **2A** classified census routing +
|
||||
per-window painter extraction (no wire change, #177), **2B-1** the
|
||||
wire (#184), **2B-2** the daemon projection and epoch machine,
|
||||
then **2B-3** the GPU band, compatible v21 activation, and the
|
||||
wire (#184), **2B-2** the daemon projection and epoch machine
|
||||
(implemented but not landed in PR #187), then **2B-3** the GPU band,
|
||||
compatible v21 activation, and the
|
||||
negotiated `panel_capable` flip. Production attachment remains v20
|
||||
through 2B-2. Parent acceptance 37–55 remains authoritative.
|
||||
Stage 3 is the adopter default flip. **2B-2 is the next slice, and
|
||||
it branches from `0442d78` or newer.**
|
||||
Stage 3 is the adopter default flip. **Do not start 2B-3 until PR
|
||||
#187 lands.** Its branch, checkpoints, two review rounds, verification,
|
||||
and exact recovery commands live in `docs/active-work.md`.
|
||||
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
|
||||
Projection class (#1–#12, #21–#22) routes through
|
||||
`primary_document_window`; focus/input (#13–#15, #23), focus chrome
|
||||
|
|
@ -597,6 +611,97 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
statusline and Vterm Stage 3 ladders still pinned v20 and rejected
|
||||
v21, in both structural and real-headless-probe form. Grep for the
|
||||
outgoing version across `tests/` before calling a bump complete.
|
||||
- **Bottom panel Stage 2B-2 (daemon projection + epoch machine) LANDED
|
||||
— #187** (one review round of five findings on top of the
|
||||
implementation; 12/12 green; 22/22 mutations bite). What it
|
||||
establishes, beyond the feature:
|
||||
- **A durable transition implemented as a per-frame effect is a bug
|
||||
shape, not a style.** Four of the five review findings were one
|
||||
defect: a renderer-side conditional with a durable twin that
|
||||
disagreed with it. Wire-area exhaustion hid the rendered frame but
|
||||
left panel state live; a stale `panel_epoch` could address a
|
||||
reopened same-buffer panel; `NoMessage` cleared statusline segments
|
||||
it should have retained. The fix that generalizes is
|
||||
`presentable_panel_grid` — **one** private derivation behind both
|
||||
the renderer and `reconcile_panel_layout_core`, because two
|
||||
derivations of one predicate is *how* they drift. When touching
|
||||
panel rendering, ask of every conditional: is there a durable twin,
|
||||
and does it agree?
|
||||
- **Ask what the other frontend kind's equivalent does.** The
|
||||
semantic panel terminal missed the pre-child-drain resize its
|
||||
document sibling had; the repro is a 4×20 panel reporting 3×120.
|
||||
The new `sync_semantic_panel_terminal_layout` is disjoint from that
|
||||
sibling *by construction* — one resolves through
|
||||
`primary_document_window`, the other through `side_window_for` — so
|
||||
the SIGWINCH storm the original extraction prevented cannot recur
|
||||
through the new path.
|
||||
- **A panel may legitimately be wider than a PTY.** `>512` columns
|
||||
are legal on the wire (2B-1's `WireGridLimits` parameter), but
|
||||
`snapshot_for_view` refused the band's content rect and the band
|
||||
went `Absent` with `panel_hidden` false. Clamp the *terminal
|
||||
projection*, not the band: the child takes the columns a PTY can
|
||||
have and the remainder is band background.
|
||||
- **Mutation testing cannot reach behaviour never modelled.** All 16
|
||||
original mutations passed while five real defects stood; one of
|
||||
them even fired, removing an `Absent` the test asserted. Mutations
|
||||
prove assertions bite the code that exists. They say nothing about
|
||||
the assertion never written.
|
||||
- **One durability hole is accepted deliberately:** epoch exhaustion
|
||||
stays a per-frame `Absent`, flagged rather than hidden, because
|
||||
making it durable needs a new "presentation permanently
|
||||
unavailable" reason in `FrontendView` for a state requiring 2^64
|
||||
presentations in one session.
|
||||
- **dired Stage 2 framing LANDED (document only) — #171**
|
||||
(`docs/dired-stage2-framing.md`, revision 9; seven review rounds).
|
||||
**Approved as a framing; no runtime code and no implementation
|
||||
started.** Load-bearing decisions a reader must not re-derive:
|
||||
- **Reconciliation is order-independent, deliberately.** Reply order
|
||||
is *not* execution order — `AsyncRuntime::tick` drains the bus with
|
||||
`try_recv`, so a worker can finish first and be descheduled before
|
||||
sending. An ordering token was rejected rather than built: no
|
||||
static rule rescues the hazard anyway (rename `dir`→`newdir` racing
|
||||
delete `dir/child.txt` needs opposite orders depending on which ran
|
||||
first), and no production path can produce it. The trigger to
|
||||
revisit is named: the first production caller that fire-and-forgets
|
||||
two interdependent mutations.
|
||||
- **A refused mid-edit kill is already destructive.**
|
||||
`kill_buffer` clears round-trip state and moves windows *before*
|
||||
`BufferRegistry::remove` can return `ConcurrentEdit`, so
|
||||
`editing_in_progress` must be preflighted rather than relied on to
|
||||
refuse cleanly.
|
||||
- **Path-backed buffer names are not full paths.**
|
||||
`get_or_load_buffer` takes the name from the path *as given* and
|
||||
normalizes only the stored path, so a relative open is named
|
||||
`foo.rs`. Rename provenance is path-*equivalence*, not equality.
|
||||
- **URI-keyed stores have uncorrelated writers.**
|
||||
`textDocument/publishDiagnostics` is absorbed unconditionally, and
|
||||
`mark_document_stale` takes no `LspServerId` at all while creating
|
||||
URI keys across three stores. A route purge keyed on request
|
||||
responses cannot cover either.
|
||||
- **Resource-op delete guard framing LANDED (document only) — #186**
|
||||
(`docs/resource-op-delete-guard-framing.md`, revision 5; five review
|
||||
rounds). **Approved as a framing; no runtime code.** It owns the
|
||||
urgent **pre-filesystem refusal** for synchronous `apply_resource_op`
|
||||
— which on `main` today destroys unsaved work — while #171 owns full
|
||||
post-delete lifecycle reconciliation. Carried facts:
|
||||
- The sequence is `stat/no-op -> enumerate and validate -> mutate
|
||||
filesystem -> reconcile`. Validation inspects without removing, so
|
||||
a failed deletion leaves buffers intact and `on_removed` still
|
||||
observes the path already gone.
|
||||
- **`EditorCore::find_buffer_for_path` is the wrong lookup** — it
|
||||
delegates to first-match-only `find_by_path`, and
|
||||
`pmacs.buffer.from_file` creates path-bound buffers with no dedup,
|
||||
so a clean first match can hide a modified second.
|
||||
- **LSP does not promise batch atomicity, and pmacs advertises no
|
||||
`workspace.workspaceEdit` capability at all** — no
|
||||
`documentChanges`, no `resourceOperations`, no `failureHandling`.
|
||||
Recovery is the client's declared choice, and there is no spec
|
||||
default for a client that declares none. Say only that observed
|
||||
pmacs behaviour *resembles* abort-style application.
|
||||
- **`pmacs.fs.remove` has no dirty check of its own** and neither
|
||||
lane adds one. After both land the guards sit one layer above it.
|
||||
Latent — zero production callers — but "both lanes guard deletion"
|
||||
reads as the primitive being guarded, and it is not.
|
||||
- **GPU initial target LANDED — #148**
|
||||
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
|
||||
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
|
||||
|
|
|
|||
1258
src/daemon.rs
1258
src/daemon.rs
File diff suppressed because it is too large
Load Diff
500
src/editor.rs
500
src/editor.rs
|
|
@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr;
|
|||
|
||||
use crate::async_runtime::SharedAsyncRuntime;
|
||||
use crate::cell::{CellCoord, CellSize};
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::editor_core::{EditorCore, GeometryUpdate};
|
||||
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
|
||||
use crate::key::{Chord, display_sequence};
|
||||
use crate::keymap_stack::{Action, KeyDispatcher};
|
||||
|
|
@ -1196,13 +1196,49 @@ impl EditorState {
|
|||
/// (Q#BP2b / Q#BP15a).
|
||||
///
|
||||
/// The single seam for grid and `LOCAL` views, whose real attach and
|
||||
/// resize sizes ARE the declaration. A semantic view never calls this
|
||||
/// in Stage 1; its geometry stays **unknown**.
|
||||
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) {
|
||||
self.core
|
||||
/// resize sizes ARE the declaration. A semantic view never calls this;
|
||||
/// its geometry arrives through
|
||||
/// [`Self::accept_semantic_frame_geometry`].
|
||||
///
|
||||
/// Reconciliation runs on every call, not only on
|
||||
/// [`GeometryUpdate::Advanced`]: panel presentability depends on the
|
||||
/// layout as well as on the geometry, and this is also the defensive
|
||||
/// pre-paint reconciliation point. The exhaustion arm is exactly why
|
||||
/// it must still run after a `Rejected` — `declare_frame_geometry`
|
||||
/// cleared the declaration to unknown, and the panel has to hide.
|
||||
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) -> GeometryUpdate {
|
||||
let update = self
|
||||
.core
|
||||
.borrow_mut()
|
||||
.declare_frame_geometry(frontend_id, total);
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
update
|
||||
}
|
||||
|
||||
/// Accept an authenticated semantic frontend's
|
||||
/// `FrontendEvent::FrontendCellGeometry` declaration (Q#BP15a).
|
||||
///
|
||||
/// The three outcomes are acted on differently, and that is the whole
|
||||
/// point of the three-valued result: `Advanced` reconciles panel
|
||||
/// layout, `Duplicate` returns without touching panel state, and
|
||||
/// `Rejected` drops the event before any reconciliation. A
|
||||
/// `Duplicate` that reconciled would do redundant work on every
|
||||
/// repeated declaration; a `Rejected` that reconciled would let a
|
||||
/// stale or conflicting declaration move the panel.
|
||||
pub fn accept_semantic_frame_geometry(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
geometry_epoch: u64,
|
||||
total: CellSize,
|
||||
) -> GeometryUpdate {
|
||||
let update =
|
||||
self.core
|
||||
.borrow_mut()
|
||||
.accept_frame_geometry(frontend_id, geometry_epoch, total);
|
||||
if update == GeometryUpdate::Advanced {
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
}
|
||||
update
|
||||
}
|
||||
|
||||
/// Local-frontend compatibility wrapper.
|
||||
|
|
@ -1834,6 +1870,91 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Sync a semantic frontend's **panel** terminal to the
|
||||
/// daemon-derived content grid (Q#BP7 / Q#BP15a).
|
||||
///
|
||||
/// The sibling of [`Self::sync_semantic_terminal_layout`], and the
|
||||
/// case review round 1 (R1-3) found missing. The two arms are
|
||||
/// disjoint by construction rather than by discipline:
|
||||
/// `sync_semantic_terminal_layout` resolves its window through
|
||||
/// `primary_document_window`, so it can never reach a side window,
|
||||
/// and this one resolves through `side_window_for`, so it can never
|
||||
/// reach the document. Nothing is ever resized twice per tick — the
|
||||
/// failure mode the extraction of `sync_terminal_layouts_for_tick`
|
||||
/// exists to prevent.
|
||||
///
|
||||
/// A panel terminal has **no** `FrontendEvent::TerminalResize`
|
||||
/// declaration to consult: the daemon derives its geometry, the
|
||||
/// frontend never asserts it (Q#BP15a). So the size comes from
|
||||
/// `panel_grid_size` minus the panel's one mode line, and it must be
|
||||
/// applied at the tick's layout step — before `tick_processes`
|
||||
/// drains the child — or the program formats its output against a
|
||||
/// geometry the band is not showing.
|
||||
///
|
||||
/// Recording the view size is unconditional for a resolvable panel
|
||||
/// (that is what gives a passive view its own clipped projection);
|
||||
/// only the durable controller resizes the shared PTY.
|
||||
///
|
||||
/// Returns whether the shared screen geometry actually changed.
|
||||
pub fn sync_semantic_panel_terminal_layout(&mut self, frontend_id: FrontendId) -> bool {
|
||||
let Some((window_id, buffer_id, content)) = ({
|
||||
let core = self.core.borrow();
|
||||
core.panel_grid_size(frontend_id).and_then(|size| {
|
||||
let window_id = core.side_window_for(frontend_id)?;
|
||||
let buffer_id = core.windows.get(&window_id)?.buffer_id;
|
||||
Some((
|
||||
window_id,
|
||||
buffer_id,
|
||||
CellSize::new(size.rows.saturating_sub(1), size.cols),
|
||||
))
|
||||
})
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
if content.rows == 0 || content.cols == 0 {
|
||||
return false;
|
||||
}
|
||||
if !self.terminal_manager.borrow().is_terminal(buffer_id) {
|
||||
return false;
|
||||
}
|
||||
let key = TerminalViewKey::new(frontend_id, window_id, buffer_id);
|
||||
let content = terminal_projection_size(content);
|
||||
if !self
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.record_view_size(key, content)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let controls = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.controller(buffer_id)
|
||||
.is_some_and(|controller| controller.matches(key));
|
||||
if !controls {
|
||||
return false;
|
||||
}
|
||||
if self.terminal_manager.borrow().screen_size(buffer_id) == Some(content) {
|
||||
return false;
|
||||
}
|
||||
let (Ok(rows), Ok(cols)) = (u16::try_from(content.rows), u16::try_from(content.cols))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let result = self.terminal_manager.borrow_mut().resize(
|
||||
buffer_id,
|
||||
rows,
|
||||
cols,
|
||||
&mut self.process_supervisor.borrow_mut(),
|
||||
);
|
||||
if let Err(error) = result {
|
||||
self.core.borrow_mut().status = error.to_string();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a semantic frontend's terminal-cell pointer gesture.
|
||||
///
|
||||
/// The gesture must name the authenticated frontend's active
|
||||
|
|
@ -1875,6 +1996,257 @@ impl EditorState {
|
|||
true
|
||||
}
|
||||
|
||||
/// Paint one semantic frontend's side window into a panel-sized grid
|
||||
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17).
|
||||
///
|
||||
/// Returns `None` for every non-presentable state — no side window,
|
||||
/// a hidden panel, unknown geometry, a zero-column frame, or a grid
|
||||
/// too small for the structural floor. The caller turns that into an
|
||||
/// **authoritative**
|
||||
/// [`pmacs_protocol::panel::PanelFramePayload::Absent`]: silence
|
||||
/// would leave the receiver's retained band on screen forever.
|
||||
///
|
||||
/// `statusline` is the side window's evaluated segments, supplied by
|
||||
/// the caller from the *same* provider invocation that produced the
|
||||
/// document's wire segments (parent acceptance 45). Evaluating again
|
||||
/// here would run every provider twice per frame.
|
||||
///
|
||||
/// **Folds are gated on the OWNING frontend (Q#BP17).** The panel is
|
||||
/// painted for `frontend_id`, which is not necessarily the acting
|
||||
/// frontend, so `EditorCore::fold_map_for_window` — which gates on
|
||||
/// the *active* frontend — is the wrong source and is deliberately
|
||||
/// not called.
|
||||
#[must_use]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one panel paint transaction: derive the grid, paint the window, resolve the caret"
|
||||
)]
|
||||
pub fn prepare_panel_projection(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
statusline: Option<&crate::statusline::StatuslineWindowSegments>,
|
||||
) -> Option<PanelProjection> {
|
||||
let (size, window_id, buffer_id, focused, fold_projection) = {
|
||||
let core = self.core.borrow();
|
||||
let size = core.panel_grid_size(frontend_id)?;
|
||||
let window_id = core.side_window_for(frontend_id)?;
|
||||
let buffer_id = core.windows.get(&window_id)?.buffer_id;
|
||||
let view = core.views.get(&frontend_id)?;
|
||||
(
|
||||
size,
|
||||
window_id,
|
||||
buffer_id,
|
||||
view.active == window_id,
|
||||
view.fold_projection,
|
||||
)
|
||||
};
|
||||
let outer = Rect::new(0, 0, size.rows, size.cols);
|
||||
let content = Rect::new(0, 0, size.rows.saturating_sub(1), size.cols);
|
||||
let placement = WindowPlacement { outer, content };
|
||||
let theme = {
|
||||
let handle = self.syntax_registry.theme();
|
||||
let t = handle.lock().expect("theme mutex poisoned");
|
||||
t.clone()
|
||||
};
|
||||
let mut cells = vec![crate::cell::Cell::default(); (size.rows * size.cols) as usize];
|
||||
let mut grid = crate::cell::CellGrid {
|
||||
cells: &mut cells,
|
||||
stride: size.cols,
|
||||
size,
|
||||
};
|
||||
|
||||
// Q#BP7 / Q#BP15a: a terminal panel's grid excludes its one mode
|
||||
// line, and its geometry reaches the shared screen through the
|
||||
// same view-size path the grid frontends use — never the 24×80
|
||||
// attach placeholder and never the full-window declaration.
|
||||
let terminal = self.terminal_manager.borrow().is_terminal(buffer_id);
|
||||
let cursor = if terminal {
|
||||
let key = TerminalViewKey::new(frontend_id, window_id, buffer_id);
|
||||
let snapshot = self
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.snapshot_for_view(key, terminal_projection_size(content.size))?;
|
||||
paint_terminal_snapshot(&mut grid, content, &snapshot, &theme);
|
||||
let registry = self.core.borrow().registry.clone();
|
||||
let reg = registry.borrow();
|
||||
if let Ok(buf) = reg.get(buffer_id) {
|
||||
let coord = snapshot.cursor.unwrap_or_default();
|
||||
let scroll = if snapshot.scroll_offset == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("↑{}", snapshot.scroll_offset)
|
||||
};
|
||||
paint_mode_line(
|
||||
&mut grid,
|
||||
&outer,
|
||||
buf.name(),
|
||||
false,
|
||||
focused,
|
||||
coord.row,
|
||||
coord.col,
|
||||
&scroll,
|
||||
"",
|
||||
mode_line_style(&theme),
|
||||
statusline.map_or(&[], |segments| segments.left.as_slice()),
|
||||
statusline.map_or(&[], |segments| segments.right.as_slice()),
|
||||
&theme,
|
||||
);
|
||||
}
|
||||
snapshot
|
||||
.cursor
|
||||
.filter(|coord| coord.row < content.size.rows && coord.col < content.size.cols)
|
||||
} else {
|
||||
let registry = self.core.borrow().registry.clone();
|
||||
let reg = registry.borrow();
|
||||
let diag_store = self.lsp_manager.borrow().diag_store();
|
||||
let mut core = self.core.borrow_mut();
|
||||
let window = core.windows.get_mut(&window_id)?;
|
||||
let buf = reg.get(buffer_id).ok()?;
|
||||
let folds = if fold_projection {
|
||||
crate::fold_view::map_for_window(&self.fold_registry, window)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
window.last_visible_rows = content.size.rows;
|
||||
// A2A-3 / parent 48: the auto-scroll clamp belongs to the
|
||||
// FOCUSED window only. Running it for a passive panel would
|
||||
// move a `view_top` the user is not driving.
|
||||
if focused {
|
||||
prepare_window_cursor_visible(window, buf, content.size.rows, folds.as_ref());
|
||||
}
|
||||
paint_window_content(
|
||||
&mut grid,
|
||||
window,
|
||||
buf,
|
||||
placement,
|
||||
folds.as_ref(),
|
||||
focused,
|
||||
&theme,
|
||||
statusline,
|
||||
&diag_store,
|
||||
);
|
||||
window_cursor_cell(window, buf, folds.as_ref(), outer)
|
||||
};
|
||||
|
||||
Some(PanelProjection {
|
||||
window_id,
|
||||
buffer_id,
|
||||
size,
|
||||
cells,
|
||||
cursor,
|
||||
focused,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply an accepted `FrontendEvent::PanelResizeRows` (Q#BP15a).
|
||||
///
|
||||
/// The request is expressed as a boundary move rather than a direct
|
||||
/// `fixed_rows` write, so it lands on the same Q#BP5b clamp path a
|
||||
/// TUI divider drag takes — including the interactive
|
||||
/// `window.min-height` preference resolved per leaf. A request the
|
||||
/// clamp cannot satisfy is a no-op, not an error.
|
||||
///
|
||||
/// Returns whether the effective allocation actually moved.
|
||||
pub fn apply_panel_resize_rows(&self, frontend_id: FrontendId, rows: u32) -> bool {
|
||||
let (side, area_rows, current) = {
|
||||
let core = self.core.borrow();
|
||||
match (
|
||||
core.side_window_for(frontend_id),
|
||||
core.frontend_area_rows(frontend_id),
|
||||
) {
|
||||
(Some(side), Some(area_rows)) => (
|
||||
side,
|
||||
area_rows,
|
||||
core.panel_allocation(frontend_id, area_rows),
|
||||
),
|
||||
_ => return false,
|
||||
}
|
||||
};
|
||||
let Some(current) = current else {
|
||||
return false;
|
||||
};
|
||||
let Ok(rows) = EditorCore::clamp_panel_rows(rows) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(delta) = i32::try_from(i64::from(rows) - i64::from(current)) else {
|
||||
return false;
|
||||
};
|
||||
if delta == 0 {
|
||||
return false;
|
||||
}
|
||||
let _ = self.resize_window_boundary(frontend_id, side, delta, area_rows);
|
||||
self.reconcile_panel_layout(frontend_id);
|
||||
self.core
|
||||
.borrow()
|
||||
.panel_allocation(frontend_id, area_rows)
|
||||
.is_some_and(|now| now != current)
|
||||
}
|
||||
|
||||
/// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16).
|
||||
///
|
||||
/// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the
|
||||
/// daemon's own state — a live, non-hidden side window whose current
|
||||
/// buffer matches the payload, and a coordinate inside the grid the
|
||||
/// daemon derived. Steps 1, 3, and 4 (source authentication and both
|
||||
/// epochs) belong to the caller, because only the session holds the
|
||||
/// declaration the frontend was actually looking at.
|
||||
///
|
||||
/// **Activation is not uniform, and Q#BP16 says so explicitly.** A
|
||||
/// **press** focuses any panel — that is click-to-focus, and
|
||||
/// `Down(Right)` is the context-menu gesture, so both buttons count.
|
||||
/// Everything else depends on what the panel holds:
|
||||
///
|
||||
/// * a **terminal** panel activates on *every* non-`Move` gesture,
|
||||
/// because the shared terminal adapter claims the controller for
|
||||
/// wheel, press, drag, and release alike — leaving a wheel step
|
||||
/// unactivated would hand the child to a window that does not own
|
||||
/// focus;
|
||||
/// * a **document** panel keeps today's **scroll-without-focus**
|
||||
/// behaviour, matching `dispatch_mouse`, where a wheel notch moves
|
||||
/// a viewport without selecting the window (and preserves a kill
|
||||
/// chain for the same reason).
|
||||
///
|
||||
/// Review round 1 (R2-5) found the terminal clause applied to both.
|
||||
/// Bare hover neither focuses nor claims, on either kind.
|
||||
///
|
||||
/// **Replay is out of scope in Stage 2B-2.** Driving selection,
|
||||
/// listview rows, or child SGR reporting is parent acceptance 48,
|
||||
/// which needs the GPU band and lands in Stage 2B-3.
|
||||
///
|
||||
/// Returns whether the gesture was accepted.
|
||||
pub fn dispatch_semantic_panel_pointer(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
coord: CellCoord,
|
||||
kind: pmacs_protocol::MouseKind,
|
||||
) -> bool {
|
||||
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
|
||||
return false;
|
||||
};
|
||||
if coord.row >= size.rows || coord.col >= size.cols {
|
||||
return false;
|
||||
}
|
||||
let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id);
|
||||
let mut core = self.core.borrow_mut();
|
||||
let Some(side) = core.side_window_for(frontend_id) else {
|
||||
return false;
|
||||
};
|
||||
if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) {
|
||||
return false;
|
||||
}
|
||||
let activates = if is_terminal {
|
||||
!matches!(kind, pmacs_protocol::MouseKind::Move)
|
||||
} else {
|
||||
matches!(kind, pmacs_protocol::MouseKind::Down(_))
|
||||
};
|
||||
if activates {
|
||||
core.focus_window(frontend_id, side);
|
||||
core.active_frontend = frontend_id;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Precompute owned terminal view snapshots before entering paint borrows.
|
||||
pub fn prepare_terminal_views(
|
||||
&mut self,
|
||||
|
|
@ -3085,6 +3457,59 @@ const SCROLL_LINES: i32 = 3;
|
|||
/// therefore only appears when a line-number mode reserves a gutter.
|
||||
const FOLD_GUTTER_GLYPH: char = '▸';
|
||||
|
||||
/// The largest viewport the terminal subsystem will actually project,
|
||||
/// for a window content rect that may legitimately be larger.
|
||||
///
|
||||
/// A panel deliberately does **not** inherit the terminal's per-axis PTY
|
||||
/// caps (Bet B5'): a 4K surface at a small font is legitimately wider
|
||||
/// than 512 columns, and `PanelFrame` answers only to the shared area
|
||||
/// bound. The terminal *screen* keeps its own policy, so without this
|
||||
/// clamp `snapshot_for_view` refused the panel's content rect, the whole
|
||||
/// projection collapsed to `None`, and the band went per-frame `Absent`
|
||||
/// while `panel_hidden` still said "visible" — review round 1's R1-1
|
||||
/// shape, found again by its own sweep.
|
||||
///
|
||||
/// Clamping rather than hiding is the right answer because the band is
|
||||
/// legitimately that wide: the child occupies the columns a PTY can
|
||||
/// have, and the remainder paints as band background exactly as a
|
||||
/// snapshot narrower than its window already does. Rows are shed for the
|
||||
/// area bound rather than columns, so a wide band keeps its full width.
|
||||
fn terminal_projection_size(content: CellSize) -> CellSize {
|
||||
let cols = content
|
||||
.cols
|
||||
.min(u32::from(crate::terminal::MAX_TERMINAL_COLS));
|
||||
let rows = content
|
||||
.rows
|
||||
.min(u32::from(crate::terminal::MAX_TERMINAL_ROWS));
|
||||
let rows_within_area =
|
||||
u32::try_from(crate::terminal::MAX_TERMINAL_VISIBLE_CELLS / (cols as usize).max(1))
|
||||
.unwrap_or(u32::MAX);
|
||||
CellSize::new(rows.min(rows_within_area), cols)
|
||||
}
|
||||
|
||||
/// One painted side window, ready to become a
|
||||
/// [`pmacs_protocol::panel::PanelFrame`] (bottom-panel Stage 2B-2).
|
||||
///
|
||||
/// The producer carries the identity fields as well as the cells because
|
||||
/// the presentation epoch is allocated from them: `window_id` changes on
|
||||
/// a new side window and `buffer_id` on a replacement, and either one
|
||||
/// moving is what makes a stale `PanelPointer` unaddressable (Q#BP16).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PanelProjection {
|
||||
/// The side window this frame projects.
|
||||
pub window_id: WindowId,
|
||||
/// Buffer that window is currently showing.
|
||||
pub buffer_id: crate::buffer::BufferId,
|
||||
/// Panel grid dimensions, mode line included.
|
||||
pub size: CellSize,
|
||||
/// Row-major cells; exactly `size.area()` entries.
|
||||
pub cells: Vec<crate::cell::Cell>,
|
||||
/// Panel caret, or `None` when it is scrolled out of the band.
|
||||
pub cursor: Option<CellCoord>,
|
||||
/// Whether the panel currently owns this frontend's focus.
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
/// Shared outer/content geometry consumed by terminal paint and PTY resize.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WindowPlacement {
|
||||
|
|
@ -3943,44 +4368,65 @@ pub fn paint_frame(
|
|||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
let aw = &core.windows[&active];
|
||||
let inner_rows = inner_rows(&active_rect);
|
||||
let buf = reg.get(aw.buffer_id).ok()?;
|
||||
// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden
|
||||
// line renders at its hidden component's head POSITION — the visible
|
||||
// head row *and* that head's end-of-content column, i.e. exactly
|
||||
// where Stage 1 moves point on a fold-at-cursor. Row-only clamping
|
||||
// would leave the column unspecified; resolving through the merged
|
||||
// component (rather than the innermost containing fold) also keeps a
|
||||
// crossing overlap from landing on another hidden position.
|
||||
let folds = crate::fold_view::map_for_window(&state.fold_registry, aw);
|
||||
let cursor = match folds.as_ref() {
|
||||
Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor),
|
||||
None => aw.cursor,
|
||||
window_cursor_cell(aw, buf, folds.as_ref(), active_rect)
|
||||
}
|
||||
|
||||
/// Where one window's caret lands in the cell grid, or `None` when it is
|
||||
/// scrolled out of that window's text area.
|
||||
///
|
||||
/// Extracted from `paint_frame`'s tail for bottom-panel Stage 2B-2: the
|
||||
/// panel band ships its own caret in
|
||||
/// [`pmacs_protocol::panel::PanelFrame::cursor`], and a second derivation
|
||||
/// would be the exact shape of Stage 1's `Layout::compute` two-caller
|
||||
/// defect — one consumer silently reckoning against different geometry.
|
||||
///
|
||||
/// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden line
|
||||
/// renders at its hidden component's head POSITION — the visible head row
|
||||
/// *and* that head's end-of-content column, i.e. exactly where Stage 1
|
||||
/// moves point on a fold-at-cursor. Row-only clamping would leave the
|
||||
/// column unspecified; resolving through the merged component (rather
|
||||
/// than the innermost containing fold) also keeps a crossing overlap from
|
||||
/// landing on another hidden position.
|
||||
fn window_cursor_cell(
|
||||
window: &crate::window::Window,
|
||||
buf: &crate::buffer::Buffer,
|
||||
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||
rect: Rect,
|
||||
) -> Option<CellCoord> {
|
||||
let inner_rows = inner_rows(&rect);
|
||||
let cursor = match folds {
|
||||
Some(map) => map.visible_position(
|
||||
window.text_view.line_at_offset(window.cursor),
|
||||
window.cursor,
|
||||
),
|
||||
None => window.cursor,
|
||||
};
|
||||
let disp = aw.text_view.pos_to_display(buf, cursor)?;
|
||||
let row_offset = match folds.as_ref() {
|
||||
let disp = window.text_view.pos_to_display(buf, cursor)?;
|
||||
let row_offset = match folds {
|
||||
Some(map) => {
|
||||
let top = map.clamp_view_top(aw.view_top);
|
||||
let top = map.clamp_view_top(window.view_top);
|
||||
let row = disp.row as usize;
|
||||
if row < top {
|
||||
return None;
|
||||
}
|
||||
map.visible_rows_between(top, row)
|
||||
}
|
||||
None => (disp.row as usize).checked_sub(aw.view_top)?,
|
||||
None => (disp.row as usize).checked_sub(window.view_top)?,
|
||||
};
|
||||
if row_offset >= inner_rows as usize {
|
||||
return None;
|
||||
}
|
||||
// UX gutter: the terminal caret sits in the text area, past the
|
||||
// reserved gutter strip (mirrors the viewport shift above).
|
||||
// UX gutter: the caret sits in the text area, past the reserved
|
||||
// gutter strip (mirrors the viewport shift in `paint_window_content`).
|
||||
let gutter_w = {
|
||||
let w = aw.gutter_width();
|
||||
if w >= active_rect.size.cols { 0 } else { w }
|
||||
let w = window.gutter_width();
|
||||
if w >= rect.size.cols { 0 } else { w }
|
||||
};
|
||||
let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?;
|
||||
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1);
|
||||
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||
let grid_row = rect.origin.row + u32::try_from(row_offset).ok()?;
|
||||
let max_col = rect.origin.col + rect.size.cols.saturating_sub(1);
|
||||
let grid_col = (rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||
Some(CellCoord::new(grid_row, grid_col))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -236,6 +236,28 @@ pub struct PanelReconciliation {
|
|||
pub released_terminal: Option<WindowId>,
|
||||
}
|
||||
|
||||
/// What a frame-geometry declaration did (Q#BP2S1, Stage 2 §3.1).
|
||||
///
|
||||
/// Three-valued rather than a boolean because the caller must act
|
||||
/// differently on each, and collapsing the middle arm is a defect in one
|
||||
/// direction or the other: folded into `Advanced` it reconciles panel
|
||||
/// layout on every repeated declaration; folded into `Rejected` it
|
||||
/// reports a stale-event condition that never happened. A `Duplicate`
|
||||
/// **is** accepted — which is why a narrower internal boolean would have
|
||||
/// to be named `advanced`, never `accepted`.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GeometryUpdate {
|
||||
/// The epoch advanced and the declaration was stored verbatim. Run
|
||||
/// panel reconciliation.
|
||||
Advanced,
|
||||
/// Same epoch, same total: already current. Do no work.
|
||||
Duplicate,
|
||||
/// Same epoch with a different total, a lower epoch, the reserved
|
||||
/// epoch `0`, an unknown frontend, or allocator exhaustion. Drop the
|
||||
/// event before any reconciliation.
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Row extent of an arbitrary subtree, derived from its leaves' computed
|
||||
/// rects: leaves tile their parent, so the union's height is the node's.
|
||||
fn node_row_extent(node: &LayoutNode, placements: &HashMap<WindowId, crate::window::Rect>) -> u32 {
|
||||
|
|
@ -3240,29 +3262,182 @@ impl EditorCore {
|
|||
(geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1)
|
||||
}
|
||||
|
||||
/// Cache a frontend's authoritative frame capacity (Q#BP2b).
|
||||
/// A frontend's current authoritative frame-geometry declaration.
|
||||
///
|
||||
/// The panel producer echoes `geometry_epoch` into every
|
||||
/// [`pmacs_protocol::panel::PanelFrame`] it ships, and the daemon
|
||||
/// compares an inbound panel event's epoch against it (Q#BP16 step
|
||||
/// 3), so the epoch has to be readable, not only the size.
|
||||
#[must_use]
|
||||
pub fn frame_geometry_for(
|
||||
&self,
|
||||
fid: FrontendId,
|
||||
) -> Option<crate::window::DeclaredFrameGeometry> {
|
||||
self.views.get(&fid)?.frame_geometry
|
||||
}
|
||||
|
||||
/// Cache a frontend's authoritative frame capacity — the **grid /
|
||||
/// `LOCAL`** allocator (Q#BP2b, Stage 2 §3.1).
|
||||
///
|
||||
/// Grid / `LOCAL` views call this from their real attach and resize
|
||||
/// sizes with an internally minted epoch; a semantic view stays
|
||||
/// `None` until Stage 2's authenticated declaration. A repeated
|
||||
/// identical size is not a new declaration.
|
||||
pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) {
|
||||
/// sizes with an internally minted epoch; a semantic view never takes
|
||||
/// this path at all — it goes through
|
||||
/// [`Self::accept_frame_geometry`], which applies the frontend-owned
|
||||
/// epoch verbatim and does **no** value dedup.
|
||||
///
|
||||
/// Value dedup is correct *here* and only here: a grid frontend's
|
||||
/// cells are the unit it declares, so an unchanged grid under
|
||||
/// unchanged metrics leaves any existing frame valid. It is wrong on
|
||||
/// the semantic path, where a font or scale change can invalidate a
|
||||
/// panel frame while [`crate::cell::CellSize`] is identical — the
|
||||
/// case daemon-side dedup cannot see (Q#BP2S1).
|
||||
///
|
||||
/// **Exhaustion fails closed.** Allocation is checked rather than
|
||||
/// saturating: `saturating_add` pins at `u64::MAX`, after which two
|
||||
/// different geometries share one declaration id. On exhaustion the
|
||||
/// authoritative declaration is *cleared* to `None` (unknown), which
|
||||
/// is already non-presentable under Q#BP2b, so the caller's
|
||||
/// reconciliation hides the panel. Retaining the last valid geometry
|
||||
/// would keep painting a panel sized to a frame that no longer
|
||||
/// exists.
|
||||
pub fn declare_frame_geometry(
|
||||
&mut self,
|
||||
fid: FrontendId,
|
||||
total: crate::cell::CellSize,
|
||||
) -> GeometryUpdate {
|
||||
let Some(view) = self.views.get_mut(&fid) else {
|
||||
return;
|
||||
return GeometryUpdate::Rejected;
|
||||
};
|
||||
if view
|
||||
.frame_geometry
|
||||
.is_some_and(|geometry| geometry.total == total)
|
||||
{
|
||||
return;
|
||||
return GeometryUpdate::Duplicate;
|
||||
}
|
||||
let next = view
|
||||
.frame_geometry
|
||||
.map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1));
|
||||
let next = match view.frame_geometry {
|
||||
None => Some(1),
|
||||
Some(geometry) => geometry.geometry_epoch.checked_add(1),
|
||||
};
|
||||
let Some(next) = next else {
|
||||
view.frame_geometry = None;
|
||||
return GeometryUpdate::Rejected;
|
||||
};
|
||||
view.frame_geometry = Some(crate::window::DeclaredFrameGeometry {
|
||||
geometry_epoch: next,
|
||||
total,
|
||||
});
|
||||
GeometryUpdate::Advanced
|
||||
}
|
||||
|
||||
/// Accept a **semantic** frontend's authoritative geometry
|
||||
/// declaration (Q#BP15a, Stage 2 §3.1).
|
||||
///
|
||||
/// Deliberately a second method rather than
|
||||
/// [`Self::declare_frame_geometry`] with an optional epoch: the two
|
||||
/// regimes differ in whether value dedup applies, and one ambiguous
|
||||
/// entry point would let a future caller silently take the wrong one.
|
||||
///
|
||||
/// | Incoming declaration | Result |
|
||||
/// | --- | --- |
|
||||
/// | epoch **greater** than stored | [`GeometryUpdate::Advanced`], stored **verbatim**, even when `total` is unchanged |
|
||||
/// | same epoch, same `total` | [`GeometryUpdate::Duplicate`] |
|
||||
/// | same epoch, **different** `total` | [`GeometryUpdate::Rejected`] |
|
||||
/// | **lower** epoch, any `total` | [`GeometryUpdate::Rejected`] |
|
||||
///
|
||||
/// The last row is not an optimization: a lower epoch carrying
|
||||
/// *identical* data is still stale, and accepting it would let a
|
||||
/// reordered declaration resurrect geometry the frontend has moved
|
||||
/// past.
|
||||
///
|
||||
/// Epoch `0` is reserved for "never declared" and is rejected on the
|
||||
/// wire.
|
||||
pub fn accept_frame_geometry(
|
||||
&mut self,
|
||||
fid: FrontendId,
|
||||
geometry_epoch: u64,
|
||||
total: crate::cell::CellSize,
|
||||
) -> GeometryUpdate {
|
||||
if geometry_epoch == 0 {
|
||||
return GeometryUpdate::Rejected;
|
||||
}
|
||||
let Some(view) = self.views.get_mut(&fid) else {
|
||||
return GeometryUpdate::Rejected;
|
||||
};
|
||||
match view.frame_geometry {
|
||||
Some(stored) if geometry_epoch < stored.geometry_epoch => GeometryUpdate::Rejected,
|
||||
Some(stored) if geometry_epoch == stored.geometry_epoch => {
|
||||
if stored.total == total {
|
||||
GeometryUpdate::Duplicate
|
||||
} else {
|
||||
GeometryUpdate::Rejected
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
view.frame_geometry = Some(crate::window::DeclaredFrameGeometry {
|
||||
geometry_epoch,
|
||||
total,
|
||||
});
|
||||
GeometryUpdate::Advanced
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The **third** geometry of Q#BP15a: the panel grid the daemon
|
||||
/// derives and paints, or `None` when no panel is presentable.
|
||||
///
|
||||
/// Columns are the frontend's full declared width. Rows are the
|
||||
/// stored `fixed_rows` request clamped by Q#BP2's recursive document
|
||||
/// minimum ([`Self::panel_allocation`]) and then by the shared wire
|
||||
/// area budget, so a very wide frame cannot produce a frame the
|
||||
/// protocol would reject. **The stored request is never rewritten**
|
||||
/// — a later narrower geometry restores it.
|
||||
///
|
||||
/// Returns `None` — the Q#BP2b hidden arm — when geometry is unknown,
|
||||
/// when the panel is hidden or absent, when the frame declares zero
|
||||
/// columns, or when even [`MIN_WINDOW_OUTER_ROWS`] rows would exceed
|
||||
/// the area budget.
|
||||
#[must_use]
|
||||
pub fn panel_grid_size(&self, fid: FrontendId) -> Option<crate::cell::CellSize> {
|
||||
if self.views.get(&fid)?.panel_hidden {
|
||||
return None;
|
||||
}
|
||||
self.presentable_panel_grid(fid)
|
||||
}
|
||||
|
||||
/// The panel grid this frontend's layout and geometry **could**
|
||||
/// present, ignoring the cached `panel_hidden` bit.
|
||||
///
|
||||
/// This is the single derivation behind both [`Self::panel_grid_size`]
|
||||
/// and [`Self::reconcile_panel_layout_core`]'s satisfiability test,
|
||||
/// and it is one function on purpose (review round 1, R1-1). When the
|
||||
/// wire-area clamp lived only in the renderer, the daemon shipped an
|
||||
/// authoritative `Absent` while `panel_hidden` stayed `false` — so
|
||||
/// keys still reached the invisible window and a panel terminal kept
|
||||
/// its controller. Q#BP2b is explicit that hiding is a **durable
|
||||
/// state transition**, never a per-frame effect, and two derivations
|
||||
/// of "can this panel be shown" is exactly how it became one.
|
||||
///
|
||||
/// The area bound is a transport-safety limit rather than a frontend
|
||||
/// policy, so it is applied uniformly rather than only on the
|
||||
/// semantic path. It cannot bind for a grid frontend at any physically
|
||||
/// reachable width — two rows fit until roughly 131,000 columns — so
|
||||
/// one shared rule costs nothing and removes the drift.
|
||||
#[must_use]
|
||||
fn presentable_panel_grid(&self, fid: FrontendId) -> Option<crate::cell::CellSize> {
|
||||
let view = self.views.get(&fid)?;
|
||||
self.side_window_for(fid)?;
|
||||
let geometry = view.frame_geometry?;
|
||||
let cols = geometry.total.cols;
|
||||
if cols == 0 {
|
||||
return None;
|
||||
}
|
||||
let area_rows = self.frontend_area_rows(fid)?;
|
||||
let rows = self.panel_allocation(fid, area_rows)?;
|
||||
let budget_rows =
|
||||
u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1))
|
||||
.unwrap_or(u32::MAX);
|
||||
let rows = rows.min(budget_rows);
|
||||
(rows >= MIN_WINDOW_OUTER_ROWS).then(|| crate::cell::CellSize::new(rows, cols))
|
||||
}
|
||||
|
||||
/// Core half of the idempotent panel-reconciliation transaction
|
||||
|
|
@ -3285,13 +3460,14 @@ impl EditorCore {
|
|||
return result;
|
||||
};
|
||||
let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden);
|
||||
// Unknown geometry (a semantic view before Stage 2's declaration)
|
||||
// and a zero-column frame are both non-presentable, and follow the
|
||||
// hidden arm rather than being sized against a placeholder.
|
||||
let satisfiable = self
|
||||
.frontend_area_rows(fid)
|
||||
.and_then(|rows| self.panel_allocation(fid, rows))
|
||||
.is_some();
|
||||
// Unknown geometry (a semantic view before Stage 2's declaration),
|
||||
// a zero-column frame, a layout that cannot spare the rows, and a
|
||||
// grid the shared wire budget cannot carry are ALL non-presentable
|
||||
// and all follow the hidden arm. One derivation, shared with the
|
||||
// renderer (R1-1): a condition that only the renderer knew about
|
||||
// produced a blank band with the durable state still saying
|
||||
// "visible".
|
||||
let satisfiable = self.presentable_panel_grid(fid).is_some();
|
||||
let Some(view) = self.views.get_mut(&fid) else {
|
||||
return result;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -60,6 +60,21 @@ use std::path::PathBuf;
|
|||
// directly.
|
||||
pub use pmacs_protocol::*;
|
||||
|
||||
/// Lowest negotiated protocol version that carries the bottom-panel wire
|
||||
/// family (Q#BP9): [`InstanceMessage::PanelFrame`] daemon→frontend, and
|
||||
/// `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`
|
||||
/// frontend→daemon.
|
||||
///
|
||||
/// One constant rather than a literal at each gate, because the panel
|
||||
/// bump gates in **both** directions: the send filter, the producer's
|
||||
/// peer flag, and the three inbound event gates must move together or one
|
||||
/// side starts trusting a wire the other never negotiated.
|
||||
///
|
||||
/// Distinct from [`ADVERTISED_PROTOCOL_VERSION`], which the production
|
||||
/// daemon still holds at 20: the v21 schema is reserved, and the
|
||||
/// compatibility-preserving activation is bottom-panel Stage 2B-3's.
|
||||
pub const PANEL_MIN_VERSION: u32 = 21;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attachment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -38,14 +38,16 @@ use crate::cell::{CellSize, Style};
|
|||
use crate::editor::EditorState;
|
||||
use crate::protocol::{
|
||||
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
|
||||
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment,
|
||||
StyleSpan,
|
||||
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION,
|
||||
StatuslineSegment, StyleSegment, StyleSpan,
|
||||
};
|
||||
use crate::statusline::{
|
||||
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
|
||||
evaluate_statusline,
|
||||
StatuslineWindowSegments, evaluate_statusline,
|
||||
};
|
||||
use crate::terminal::TerminalFrame;
|
||||
use crate::window::WindowId;
|
||||
use pmacs_protocol::panel::{PanelFrame, PanelFramePayload};
|
||||
|
||||
/// The viewport a `semantic_render` frontend last declared.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
|
@ -317,6 +319,64 @@ pub struct SemanticRenderState {
|
|||
terminal_error_latched: bool,
|
||||
/// Whether the most recent render pass projected a terminal.
|
||||
terminal_active: bool,
|
||||
/// Whether the peer negotiated protocol v21, which is where
|
||||
/// [`InstanceMessage::PanelFrame`] was appended (Q#BP9). A v20 peer
|
||||
/// receives no band at all — and, per Q#BP13, is never *placed* in a
|
||||
/// side window either, because denying only the message would leave
|
||||
/// its window invisible.
|
||||
peer_knows_panel_frames: bool,
|
||||
/// The panel payload this peer last received, compared in FULL.
|
||||
///
|
||||
/// Seeded to `Absent` rather than `None`: a fresh session starts with
|
||||
/// no band, so the opening state is a fact rather than an absence,
|
||||
/// and seeding it keeps every session from paying one redundant
|
||||
/// `Absent` before it ever shows a panel.
|
||||
///
|
||||
/// `Absent` is authoritative and duplicate-suppressed like any other
|
||||
/// payload — hide and close must both send it, because the receiver
|
||||
/// retains its last valid frame and silence would leave a stale band
|
||||
/// on screen indefinitely (Q#BP15).
|
||||
last_panel_payload: Option<PanelFramePayload>,
|
||||
/// Highest presentation epoch allocated for this session; `0` means
|
||||
/// none has been. Advanced only when a frame is actually shipped, so
|
||||
/// a frame that fails validation does not burn an identity the peer
|
||||
/// never saw.
|
||||
panel_epoch_used: u64,
|
||||
/// Identity behind the `Present` in `last_panel_payload`, or `None`
|
||||
/// when the last payload was `Absent`.
|
||||
///
|
||||
/// Cleared by every `Absent`, which is what makes hide/reopen and
|
||||
/// close/reopen of the **same** persistent buffer allocate a fresh
|
||||
/// epoch — the hole a `buffer_id` alone cannot close (Q#BP16).
|
||||
panel_presentation: Option<PanelPresentation>,
|
||||
/// Whether an invalid panel frame was already reported since the last
|
||||
/// valid one. Bounds the log exactly like `terminal_error_latched`.
|
||||
panel_error_latched: bool,
|
||||
/// The band's last PUBLISHED statusline segments and the side-window
|
||||
/// presentation they belong to (review rounds 1 and 2, R2-4).
|
||||
///
|
||||
/// The band repaints its whole mode line every frame, so
|
||||
/// "publish nothing" has to be expressed as "paint what was published
|
||||
/// last" — there is no wire-level suppression to fall back on the way
|
||||
/// `StatuslineSegments` has. Side affinity can replace a buffer in the
|
||||
/// same window, so both identities are required to prevent one panel
|
||||
/// presentation from inheriting its predecessor's provider text.
|
||||
last_panel_statusline: Option<((WindowId, BufferId), StatuslineWindowSegments)>,
|
||||
}
|
||||
|
||||
/// The presentation identity a shipped [`PanelFrame`] carries.
|
||||
///
|
||||
/// `window_id` moves when a new side window is created and `buffer_id`
|
||||
/// when the panel's buffer is replaced; either one changing allocates a
|
||||
/// new `panel_epoch`, and that is what stops a stale `PanelPointer` from
|
||||
/// addressing a reopened panel as if it were the old one (Q#BP16).
|
||||
/// `WindowId` deliberately stays off the wire — the epoch is the opaque
|
||||
/// stand-in for it.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
struct PanelPresentation {
|
||||
window_id: WindowId,
|
||||
buffer_id: BufferId,
|
||||
panel_epoch: u64,
|
||||
}
|
||||
|
||||
/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start
|
||||
|
|
@ -413,6 +473,7 @@ impl SemanticRenderState {
|
|||
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
|
||||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
||||
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
||||
s
|
||||
}
|
||||
|
||||
|
|
@ -464,9 +525,69 @@ impl SemanticRenderState {
|
|||
last_terminal_frame: None,
|
||||
terminal_error_latched: false,
|
||||
terminal_active: false,
|
||||
peer_knows_panel_frames: true,
|
||||
// Q#BP15: a fresh session has no band, and that is a fact the
|
||||
// peer already holds. Seeding the baseline keeps the first
|
||||
// frame from shipping a redundant authoritative `Absent`.
|
||||
last_panel_payload: Some(PanelFramePayload::Absent),
|
||||
panel_epoch_used: 0,
|
||||
panel_presentation: None,
|
||||
panel_error_latched: false,
|
||||
last_panel_statusline: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Present` panel declaration this session last shipped.
|
||||
///
|
||||
/// The daemon reads it to run steps 3 and 4 of Q#BP16's validation
|
||||
/// ladder: an inbound `PanelPointer` or `PanelResizeRows` must name
|
||||
/// the geometry and presentation epochs of the frame the frontend was
|
||||
/// actually looking at. `None` after an `Absent` — which is precisely
|
||||
/// how `Absent` "clears input authority".
|
||||
#[must_use]
|
||||
pub fn panel_declaration(&self) -> Option<&PanelFrame> {
|
||||
match self.last_panel_payload.as_ref()? {
|
||||
PanelFramePayload::Present(frame) => Some(frame),
|
||||
PanelFramePayload::Absent => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the last shipped declaration is a `Present` whose epochs
|
||||
/// both match an inbound panel event **and** which still describes
|
||||
/// the side window that is live now (Q#BP16 steps 2–4).
|
||||
///
|
||||
/// Rolled into one predicate so no caller can check the geometry
|
||||
/// epoch and forget the presentation epoch: they close different
|
||||
/// holes and neither subsumes the other.
|
||||
///
|
||||
/// `live_presentation` is the frontend's **current** side window and
|
||||
/// its buffer. Comparing it is what closes review round 1's R1-2:
|
||||
/// closing and reopening the same *persistent* buffer inside one
|
||||
/// dispatcher burst leaves the shipped declaration intact while the
|
||||
/// window it describes is already dead, and same-buffer/same-size
|
||||
/// makes the successor indistinguishable by every other field. A
|
||||
/// presentation epoch only identifies a presentation if something
|
||||
/// checks that the presentation it names is still the one on screen.
|
||||
///
|
||||
/// `None` means "no side window right now", which never matches — an
|
||||
/// event cannot address a panel that does not exist.
|
||||
#[must_use]
|
||||
pub fn panel_declaration_matches(
|
||||
&self,
|
||||
geometry_epoch: u64,
|
||||
panel_epoch: u64,
|
||||
live_presentation: Option<(WindowId, BufferId)>,
|
||||
) -> bool {
|
||||
let Some((window_id, buffer_id)) = live_presentation else {
|
||||
return false;
|
||||
};
|
||||
self.panel_presentation.is_some_and(|presentation| {
|
||||
presentation.window_id == window_id && presentation.buffer_id == buffer_id
|
||||
}) && self.panel_declaration().is_some_and(|frame| {
|
||||
frame.geometry_epoch == geometry_epoch && frame.panel_epoch == panel_epoch
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the frontend's declared on-screen byte range. Called by
|
||||
/// the dispatcher when it receives
|
||||
/// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any
|
||||
|
|
@ -616,8 +737,15 @@ impl SemanticRenderState {
|
|||
return messages;
|
||||
}
|
||||
let Some(vp) = self.viewport.clone() else {
|
||||
// Emit nothing before the frontend declares a viewport.
|
||||
return Vec::new();
|
||||
// Emit nothing document-scoped before the frontend declares a
|
||||
// viewport — but the band is a SEPARATE surface (Q#BP15a),
|
||||
// and gating it on the document declaration would make the
|
||||
// first panel unpaintable on a frontend that has not declared
|
||||
// one yet. Its statusline is `None` here for the same reason:
|
||||
// the semantic fan-out is keyed on the declared buffer.
|
||||
let mut out = Vec::new();
|
||||
self.emit_panel_frame(state, None, &mut out);
|
||||
return out;
|
||||
};
|
||||
|
||||
// Evaluate callbacks before any long-lived core borrow and before
|
||||
|
|
@ -825,9 +953,17 @@ impl SemanticRenderState {
|
|||
out.extend(self.theme_facts_msg(state));
|
||||
out.extend(self.font_facts_msg(state));
|
||||
// Q#SL6/Q#SL8: face inventory must precede segment text.
|
||||
// Parent acceptance 45: ONE provider invocation supplies both the
|
||||
// primary-document wire segments and the panel mode line, so the
|
||||
// side half is taken from this same evaluation before it is
|
||||
// consumed.
|
||||
let panel_presentation = self.panel_statusline_presentation(state);
|
||||
let panel_statusline =
|
||||
self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation);
|
||||
if let Some(evaluation) = statusline_evaluation {
|
||||
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
|
||||
}
|
||||
self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -965,9 +1101,17 @@ impl SemanticRenderState {
|
|||
out.extend(self.theme_facts_msg(state));
|
||||
out.extend(self.font_facts_msg(state));
|
||||
// Q#SL6/Q#SL8: face inventory must precede segment text.
|
||||
// The band rides the terminal path too: a frontend whose DOCUMENT
|
||||
// surface is a full-window terminal can still hold a side window,
|
||||
// and suppressing the panel here would leave the peer's retained
|
||||
// band on screen with no way to clear it.
|
||||
let panel_presentation = self.panel_statusline_presentation(state);
|
||||
let panel_statusline =
|
||||
self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation);
|
||||
if let Some(evaluation) = statusline_evaluation {
|
||||
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
|
||||
}
|
||||
self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -1033,6 +1177,231 @@ impl SemanticRenderState {
|
|||
}
|
||||
}
|
||||
|
||||
/// The side window's evaluated segments from **this frame's** single
|
||||
/// provider invocation (parent acceptance 45).
|
||||
///
|
||||
/// Selected by window identity, exactly like the document half: the
|
||||
/// fan-out yields the primary document *and* the visible side window,
|
||||
/// and taking "some context for my frontend" would depend on capture
|
||||
/// order and could paint the document's status text into the panel's
|
||||
/// mode line.
|
||||
///
|
||||
/// The three outcomes are **not** interchangeable, and review round 1
|
||||
/// (R2-4) found two of them collapsed:
|
||||
///
|
||||
/// * `Ready` is authoritative — including an empty result. It
|
||||
/// replaces the retained baseline.
|
||||
/// * `Invalidated` discards all evaluated text: a callback mutated
|
||||
/// registry, layout, or focus mid-evaluation, so the band clears to
|
||||
/// its plain mode line and the baseline dies with it.
|
||||
/// * `NoMessage` means **publish nothing**. Phase 1 was already stale
|
||||
/// — most reachably a buffer-follow mismatch, where the primary
|
||||
/// document window has moved off the buffer the frontend declared.
|
||||
/// The band therefore keeps what it last published. Treating this
|
||||
/// like `Invalidated` *removes* provider text on a transient
|
||||
/// condition that said nothing about it.
|
||||
///
|
||||
/// The baseline is keyed by both window and buffer identity. Side
|
||||
/// affinity deliberately replaces the buffer in an existing side
|
||||
/// window, and that replacement is a new presentation even though the
|
||||
/// `WindowId` is stable.
|
||||
fn panel_statusline_presentation(&self, state: &EditorState) -> Option<(WindowId, BufferId)> {
|
||||
let core = state.core.borrow();
|
||||
let window_id = core.side_window_for(self.frontend_id)?;
|
||||
let buffer_id = core.windows.get(&window_id)?.buffer_id;
|
||||
Some((window_id, buffer_id))
|
||||
}
|
||||
|
||||
fn panel_statusline(
|
||||
&mut self,
|
||||
evaluation: Option<&StatuslineEvaluation>,
|
||||
panel_presentation: Option<(WindowId, BufferId)>,
|
||||
) -> Option<StatuslineWindowSegments> {
|
||||
let Some((side_window, side_buffer)) = panel_presentation else {
|
||||
// No band to publish for; drop any baseline so a later panel
|
||||
// cannot inherit a dead window's text.
|
||||
self.last_panel_statusline = None;
|
||||
return None;
|
||||
};
|
||||
let retained = |state: &Self| {
|
||||
state
|
||||
.last_panel_statusline
|
||||
.as_ref()
|
||||
.filter(|(presentation, _)| *presentation == (side_window, side_buffer))
|
||||
.map(|(_, segments)| segments.clone())
|
||||
};
|
||||
let Some(evaluation) = evaluation else {
|
||||
// No evaluation ran at all (an unsupported peer, or a frame
|
||||
// before the document viewport exists). Nothing was
|
||||
// published, so nothing is retracted.
|
||||
return retained(self);
|
||||
};
|
||||
match &evaluation.outcome {
|
||||
StatuslineEvaluationOutcome::Ready(windows) => {
|
||||
let found = windows
|
||||
.iter()
|
||||
.find(|window| {
|
||||
window.context.frontend_id == self.frontend_id
|
||||
&& window.context.window_id == side_window
|
||||
&& window.context.buffer_id == side_buffer
|
||||
})
|
||||
.cloned();
|
||||
self.last_panel_statusline = found
|
||||
.clone()
|
||||
.map(|segments| ((side_window, side_buffer), segments));
|
||||
found
|
||||
}
|
||||
StatuslineEvaluationOutcome::Invalidated { .. } => {
|
||||
self.last_panel_statusline = None;
|
||||
None
|
||||
}
|
||||
StatuslineEvaluationOutcome::NoMessage(_) => retained(self),
|
||||
}
|
||||
}
|
||||
|
||||
/// Project this frontend's side window as an
|
||||
/// [`InstanceMessage::PanelFrame`] (Q#BP15).
|
||||
///
|
||||
/// Runs on every frame of a panel-capable v21 semantic session,
|
||||
/// independently of the document byte viewport: the band is a
|
||||
/// separate surface, and gating it on a declared viewport would leave
|
||||
/// the first panel unpaintable on a frontend that has not yet
|
||||
/// declared one.
|
||||
///
|
||||
/// Not reset by [`Self::on_buffer_snapshot_sent`]: a `BufferSnapshot`
|
||||
/// resets *document* mirror state, and the band is neither
|
||||
/// buffer-scoped to the document nor rebuilt from it.
|
||||
fn emit_panel_frame(
|
||||
&mut self,
|
||||
state: &EditorState,
|
||||
statusline: Option<&StatuslineWindowSegments>,
|
||||
out: &mut Vec<InstanceMessage>,
|
||||
) {
|
||||
// Q#BP13: capability, not merely wire version. A session that
|
||||
// cannot render a band must not be shipped one — and, on the
|
||||
// production path, is never placed in a side window either.
|
||||
if !self.peer_knows_panel_frames || !state.core.borrow().panel_capable_for(self.frontend_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Q#BP15a: `Present` echoes the daemon's latest ACCEPTED geometry
|
||||
// declaration. Read before painting so the frame cannot answer a
|
||||
// declaration that arrived mid-projection.
|
||||
let geometry = state.core.borrow().frame_geometry_for(self.frontend_id);
|
||||
let projection =
|
||||
geometry.and_then(|_| state.prepare_panel_projection(self.frontend_id, statusline));
|
||||
let (Some(geometry), Some(projection)) = (geometry, projection) else {
|
||||
self.publish_absent_panel(out);
|
||||
return;
|
||||
};
|
||||
let identity = (projection.window_id, projection.buffer_id);
|
||||
let panel_epoch = match self.panel_presentation {
|
||||
Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => {
|
||||
Some(presentation.panel_epoch)
|
||||
}
|
||||
// A new side window, a replaced buffer, or any `Absent` →
|
||||
// `Present` transition (which cleared `panel_presentation`)
|
||||
// takes a fresh identity.
|
||||
_ => self.panel_epoch_used.checked_add(1),
|
||||
};
|
||||
let Some(panel_epoch) = panel_epoch else {
|
||||
// Q#BP15: allocation is checked and exhaustion fails closed
|
||||
// to `Absent`. Wrapping would let a new panel inherit a live
|
||||
// identity and accept gestures aimed at its predecessor.
|
||||
//
|
||||
// **The one knowingly per-frame `Absent` left in this file**,
|
||||
// and it is recorded rather than fixed. Review round 1's R1-1
|
||||
// established that a band cleared on the wire must also move
|
||||
// the durable `panel_hidden` state, or keys keep reaching an
|
||||
// invisible window; this arm cannot, because the producer
|
||||
// holds session state and `panel_hidden` is recomputed by
|
||||
// core reconciliation from geometry alone. Making it durable
|
||||
// needs a new "presentation permanently unavailable" reason
|
||||
// in `FrontendView`, which is machinery for a state that
|
||||
// takes 2^64 shipped presentation changes in ONE session to
|
||||
// reach — unlike the wire-area exhaustion in
|
||||
// `presentable_panel_grid`, which any frontend can trigger
|
||||
// with one declaration. If the epoch ever becomes
|
||||
// frontend-supplied, this stops being unreachable and needs
|
||||
// the durable arm.
|
||||
self.publish_absent_panel(out);
|
||||
return;
|
||||
};
|
||||
let payload = PanelFramePayload::Present(PanelFrame {
|
||||
buffer_id: projection.buffer_id,
|
||||
panel_epoch,
|
||||
geometry_epoch: geometry.geometry_epoch,
|
||||
size: projection.size,
|
||||
cells: projection.cells,
|
||||
cursor: projection.cursor,
|
||||
focused: projection.focused,
|
||||
});
|
||||
// Complete-payload comparison FIRST, like the terminal pass: only
|
||||
// validated payloads are ever stored, so a payload equal to the
|
||||
// baseline has already passed and re-running the per-cell width
|
||||
// and topology checks would recompute a verdict we hold.
|
||||
if self.last_panel_payload.as_ref() == Some(&payload) {
|
||||
self.panel_error_latched = false;
|
||||
return;
|
||||
}
|
||||
let PanelFramePayload::Present(frame) = &payload else {
|
||||
unreachable!("the Present payload was constructed immediately above");
|
||||
};
|
||||
match frame.validate() {
|
||||
Ok(()) => {
|
||||
self.panel_error_latched = false;
|
||||
self.panel_epoch_used = self.panel_epoch_used.max(panel_epoch);
|
||||
self.panel_presentation = Some(PanelPresentation {
|
||||
window_id: projection.window_id,
|
||||
buffer_id: projection.buffer_id,
|
||||
panel_epoch,
|
||||
});
|
||||
self.last_panel_payload = Some(payload.clone());
|
||||
out.push(InstanceMessage::PanelFrame(payload));
|
||||
}
|
||||
Err(error) => {
|
||||
// Atomic rejection: the peer keeps its last valid frame,
|
||||
// this session keeps the presentation identity behind it,
|
||||
// and one bounded log line marks the condition. Advancing
|
||||
// `panel_epoch_used` here would burn an identity the peer
|
||||
// never saw.
|
||||
if !self.panel_error_latched {
|
||||
self.panel_error_latched = true;
|
||||
eprintln!(
|
||||
"pmacs: panel frame for {:?} on {:?} failed validation, \
|
||||
retaining the last valid frame: {error}",
|
||||
projection.buffer_id, self.frontend_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the authoritative `Absent` for every non-presentable state
|
||||
/// (Q#BP15, Q#BP2b).
|
||||
///
|
||||
/// Clears the declared presentation on this side before any later
|
||||
/// event can validate against it — that is what "`Absent` clears
|
||||
/// input authority" means. The whole-frame geometry declaration
|
||||
/// deliberately survives: it is answered by the frontend, not by the
|
||||
/// panel's presence.
|
||||
fn publish_absent_panel(&mut self, out: &mut Vec<InstanceMessage>) {
|
||||
self.panel_presentation = None;
|
||||
// `Absent` also clears the peer's retained mode line. A later
|
||||
// `Present` under `NoMessage` therefore has nothing it can
|
||||
// legitimately retain, even if the same window and buffer reopen.
|
||||
self.last_panel_statusline = None;
|
||||
if self.last_panel_payload.as_ref() == Some(&PanelFramePayload::Absent) {
|
||||
// A duplicate `Absent` does no wire work — but both clears
|
||||
// above still run, so the state stays idempotent rather than
|
||||
// depending on which duplicate arrived first.
|
||||
return;
|
||||
}
|
||||
self.panel_error_latched = false;
|
||||
self.last_panel_payload = Some(PanelFramePayload::Absent);
|
||||
out.push(InstanceMessage::PanelFrame(PanelFramePayload::Absent));
|
||||
}
|
||||
|
||||
fn emit_statusline_payload(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue