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
|
entries when their PR merges; do not let this become a second permanent
|
||||||
backlog.
|
backlog.
|
||||||
|
|
||||||
**No lane below is retained past its merge.** The PTY terminate
|
**No lane below is retained past its merge.** This snapshot removes the
|
||||||
diagnostic (#176) was the last one — retained because rule 4 removes a
|
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
|
merged lane only *after* its durable facts reach
|
||||||
`docs/agent-handoff.md`, and that absorption was unowned. This snapshot
|
`docs/agent-handoff.md`, and that absorption was unowned. The
|
||||||
owns it: #176's facts are now in the handoff (§1's arc bullet and §5's
|
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
|
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
|
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
|
(#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
|
number appears in `git log --first-parent githubsucks/main`, it has
|
||||||
landed regardless of what a lane says.
|
landed regardless of what a lane says.
|
||||||
|
|
||||||
**Two open PRs had no lane here at all before this snapshot** — #174 and
|
**Two open PRs had no lane here at all before the 2026-07-28
|
||||||
#171. An open PR is exactly the volatile work this file exists to
|
snapshot** — #174 and #171. An open PR is exactly the volatile work this
|
||||||
record, so its absence is a ledger defect rather than a tidy omission:
|
file exists to record, so its absence is a ledger defect rather than a
|
||||||
#171 drifted **153 commits** while invisible here, and its still-green
|
tidy omission: #171 drifted **153 commits** while invisible here, and
|
||||||
old CI run describes a tree nobody has looked at since. **When a PR is
|
its still-green old CI run described a tree nobody had looked at since.
|
||||||
opened, give it a lane.** #174 has since merged, so per rule 4 its lane
|
**When a PR is opened, give it a lane.** All three have since merged —
|
||||||
is gone again and its durable lesson is in `docs/agent-handoff.md` §5;
|
#174, #171 and #186 — so per rule 4 their lanes are gone again and
|
||||||
#171's lane is below.
|
their durable facts are in `docs/agent-handoff.md` (§5 for #174's
|
||||||
|
lesson, §1 for the two framings).
|
||||||
|
|
||||||
## Repository authority
|
## 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,
|
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||||
or something else, and therefore has no authority by name alone.
|
or something else, and therefore has no authority by name alone.
|
||||||
- Canonical base at this snapshot:
|
- Canonical base at this snapshot:
|
||||||
`githubsucks/main` @ `0442d78` (the M4 config-sink race fix #174, atop
|
`githubsucks/main` @ `7586905` (the docs-only coherence listview
|
||||||
bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet
|
correction #189, atop the docs-only landed-state refresh #185, the M4
|
||||||
#183, Journey Stage 1a #182 and the previously recorded landed work).
|
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
|
**Protocol schema support is
|
||||||
`v6..=v21`; the production server-first `Hello` still advertises
|
`v6..=v21`; the production server-first `Hello` still advertises
|
||||||
v20** — two different facts, and #184 landed only the first. The
|
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
|
recovery floor advances with it**: the check below now requires
|
||||||
`0442d78` or newer, so a tree at `7fd646d` — or at `6bee09d` — no
|
`7586905` or newer, so a tree at `0442d78` no longer passes. That is
|
||||||
longer passes. That is
|
|
||||||
deliberate — the floor moves with the base, because a check that
|
deliberate — the floor moves with the base, because a check that
|
||||||
accepts an older commit than the declared base passes on a tree the
|
accepts an older commit than the declared base passes on a tree the
|
||||||
rest of this file does not describe.
|
rest of this file does not describe.
|
||||||
|
|
@ -86,7 +103,7 @@ git worktree list
|
||||||
git status --short --branch
|
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
|
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
|
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
|
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
|
never been enforced. Any CI job that compiles the `crdt` targets has to
|
||||||
fix them first or it will be red on arrival.
|
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
|
Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and **Stage 2B-2 are
|
||||||
`main`**. Framing revision 5's three-way split of 2B was explicitly
|
all on `main`**. Framing revision 5's three-way split of 2B was
|
||||||
approved on 2026-07-27; revision 6 records PR #184's review correction.
|
explicitly approved on 2026-07-27; revision 6 records PR #184's review
|
||||||
**2B-2 — the daemon panel projection and epoch machine — is under way**
|
correction. **2B-2 — the daemon panel projection and epoch machine —
|
||||||
on branch `bottom-panel-stage2b2`, worktree `../pmacs-bp-stage2b2`,
|
landed as [PR #187](https://github.com/levineuwirth/pmacs/pull/187)**,
|
||||||
branched fresh from `githubsucks/main` @ `6bee09d`. It is *not* stacked
|
one review round of five findings on top of the implementation, 12/12
|
||||||
on the 2B-1 branch, which is the rule for every slice in this arc. Note
|
green, 22/22 mutations biting. Its durable lessons are in
|
||||||
that `main` has since advanced to `0442d78`; the only difference is the
|
`docs/agent-handoff.md` §1; what remains below is the 2B-3 plan.
|
||||||
test-only #174, so the slice's integration surface is unchanged.
|
|
||||||
|
|
||||||
**2B-2's boundaries, restated because they are easy to overrun:** the
|
**2B-2's boundaries, restated because they are easy to overrun:** the
|
||||||
production `Hello` stays at v20 and `panel_capable` stays `false`. 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
|
2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to
|
||||||
21.
|
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
|
- **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all
|
||||||
twelve checks green on the reviewed head `5539b6e`; two review rounds
|
twelve checks green on the reviewed head `5539b6e`; two review rounds
|
||||||
plus a gate-found follow-up). Branch
|
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
|
`FrontendView.fold_projection` to `true` for semantic frontends, which
|
||||||
Stage 2 deliberately left `false` (Q#FD21).
|
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
|
## Parked lane: kill-ring browser + persistence
|
||||||
|
|
||||||
- Portable branch: `githubsucks/kill-ring-browser`
|
- Portable branch: `githubsucks/kill-ring-browser`
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
# Agent handoff — cross-machine continuity
|
# Agent handoff — cross-machine continuity
|
||||||
|
|
||||||
**Last updated: 2026-07-28, after the M4 config-sink race fix (#174) and
|
**Last updated: 2026-07-28, as bottom-panel Stage 2B-2 (#187) — the
|
||||||
bottom-panel Stage 2B-1 (#184) merged; the canonical landed base is
|
daemon panel projection and the epoch machine — lands on `6c9e765`,
|
||||||
`0442d78`. #174 is test-only. #184 is the substantive one — the reserved
|
which is the dired Stage 2 framing (#171) atop the resource-op delete
|
||||||
protocol-v21
|
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
|
bottom-panel wire family, dark by construction, with the production
|
||||||
handshake deliberately still advertising v20 — following the Journey/GPU
|
handshake deliberately still advertising v20 — following the Journey/GPU
|
||||||
directory-target ratchet (#183), following Journey Stage 1a (#182),
|
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)
|
## 1. Where the project stands (2026-07-28)
|
||||||
|
|
||||||
- `main` @ `0442d78` (the M4 config-sink race fix #174 — test-only —
|
- `main` @ `6c9e765` (the dired Stage 2 framing #171 and the resource-op
|
||||||
atop bottom-panel Stage 2B-1 #184, the Journey/GPU
|
delete guard framing #186 — both framing-only, no runtime code, no
|
||||||
directory-target ratchet #183, Journey Stage 1a #182, incorporating
|
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
|
terminal configuration + copy
|
||||||
mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed
|
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
|
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;
|
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
|
||||||
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
|
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
|
||||||
Stage 2 48. All 12 CI checks green at merge.
|
Stage 2 48. All 12 CI checks green at merge.
|
||||||
- **Stage 2 (the GPU panel band) is FRAMED, and its first two slices
|
- **Stage 2 (the GPU panel band) is FRAMED; its first two slices have
|
||||||
have LANDED** — `docs/bottom-panel-stage2-framing.md` rev 6, four
|
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
|
framing review rounds, no open framing items; the rev-5
|
||||||
implementation split was explicitly approved 2026-07-27 and rev 6
|
implementation split was explicitly approved 2026-07-27 and rev 6
|
||||||
records PR #184's server-first compatibility and gate correction.
|
records PR #184's server-first compatibility and gate correction.
|
||||||
It reserves protocol **v21** and ships as four serial
|
It reserves protocol **v21** and ships as four serial
|
||||||
implementation slices: **2A** classified census routing +
|
implementation slices: **2A** classified census routing +
|
||||||
per-window painter extraction (no wire change, #177), **2B-1** the
|
per-window painter extraction (no wire change, #177), **2B-1** the
|
||||||
wire (#184), **2B-2** the daemon projection and epoch machine,
|
wire (#184), **2B-2** the daemon projection and epoch machine
|
||||||
then **2B-3** the GPU band, compatible v21 activation, and the
|
(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
|
negotiated `panel_capable` flip. Production attachment remains v20
|
||||||
through 2B-2. Parent acceptance 37–55 remains authoritative.
|
through 2B-2. Parent acceptance 37–55 remains authoritative.
|
||||||
Stage 3 is the adopter default flip. **2B-2 is the next slice, and
|
Stage 3 is the adopter default flip. **Do not start 2B-3 until PR
|
||||||
it branches from `0442d78` or newer.**
|
#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
|
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
|
||||||
Projection class (#1–#12, #21–#22) routes through
|
Projection class (#1–#12, #21–#22) routes through
|
||||||
`primary_document_window`; focus/input (#13–#15, #23), focus chrome
|
`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
|
statusline and Vterm Stage 3 ladders still pinned v20 and rejected
|
||||||
v21, in both structural and real-headless-probe form. Grep for the
|
v21, in both structural and real-headless-probe form. Grep for the
|
||||||
outgoing version across `tests/` before calling a bump complete.
|
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**
|
- **GPU initial target LANDED — #148**
|
||||||
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
|
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
|
||||||
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
|
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::async_runtime::SharedAsyncRuntime;
|
||||||
use crate::cell::{CellCoord, CellSize};
|
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::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
|
||||||
use crate::key::{Chord, display_sequence};
|
use crate::key::{Chord, display_sequence};
|
||||||
use crate::keymap_stack::{Action, KeyDispatcher};
|
use crate::keymap_stack::{Action, KeyDispatcher};
|
||||||
|
|
@ -1196,13 +1196,49 @@ impl EditorState {
|
||||||
/// (Q#BP2b / Q#BP15a).
|
/// (Q#BP2b / Q#BP15a).
|
||||||
///
|
///
|
||||||
/// The single seam for grid and `LOCAL` views, whose real attach and
|
/// The single seam for grid and `LOCAL` views, whose real attach and
|
||||||
/// resize sizes ARE the declaration. A semantic view never calls this
|
/// resize sizes ARE the declaration. A semantic view never calls this;
|
||||||
/// in Stage 1; its geometry stays **unknown**.
|
/// its geometry arrives through
|
||||||
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) {
|
/// [`Self::accept_semantic_frame_geometry`].
|
||||||
self.core
|
///
|
||||||
|
/// 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()
|
.borrow_mut()
|
||||||
.declare_frame_geometry(frontend_id, total);
|
.declare_frame_geometry(frontend_id, total);
|
||||||
self.reconcile_panel_layout(frontend_id);
|
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.
|
/// 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.
|
/// Apply a semantic frontend's terminal-cell pointer gesture.
|
||||||
///
|
///
|
||||||
/// The gesture must name the authenticated frontend's active
|
/// The gesture must name the authenticated frontend's active
|
||||||
|
|
@ -1875,6 +1996,257 @@ impl EditorState {
|
||||||
true
|
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.
|
/// Precompute owned terminal view snapshots before entering paint borrows.
|
||||||
pub fn prepare_terminal_views(
|
pub fn prepare_terminal_views(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -3085,6 +3457,59 @@ const SCROLL_LINES: i32 = 3;
|
||||||
/// therefore only appears when a line-number mode reserves a gutter.
|
/// therefore only appears when a line-number mode reserves a gutter.
|
||||||
const FOLD_GUTTER_GLYPH: char = '▸';
|
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.
|
/// Shared outer/content geometry consumed by terminal paint and PTY resize.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) struct WindowPlacement {
|
pub(crate) struct WindowPlacement {
|
||||||
|
|
@ -3943,44 +4368,65 @@ pub fn paint_frame(
|
||||||
let registry = core.registry.clone();
|
let registry = core.registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
let aw = &core.windows[&active];
|
let aw = &core.windows[&active];
|
||||||
let inner_rows = inner_rows(&active_rect);
|
|
||||||
let buf = reg.get(aw.buffer_id).ok()?;
|
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 folds = crate::fold_view::map_for_window(&state.fold_registry, aw);
|
||||||
let cursor = match folds.as_ref() {
|
window_cursor_cell(aw, buf, folds.as_ref(), active_rect)
|
||||||
Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor),
|
}
|
||||||
None => aw.cursor,
|
|
||||||
|
/// 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 disp = window.text_view.pos_to_display(buf, cursor)?;
|
||||||
let row_offset = match folds.as_ref() {
|
let row_offset = match folds {
|
||||||
Some(map) => {
|
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;
|
let row = disp.row as usize;
|
||||||
if row < top {
|
if row < top {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
map.visible_rows_between(top, row)
|
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 {
|
if row_offset >= inner_rows as usize {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// UX gutter: the terminal caret sits in the text area, past the
|
// UX gutter: the caret sits in the text area, past the reserved
|
||||||
// reserved gutter strip (mirrors the viewport shift above).
|
// gutter strip (mirrors the viewport shift in `paint_window_content`).
|
||||||
let gutter_w = {
|
let gutter_w = {
|
||||||
let w = aw.gutter_width();
|
let w = window.gutter_width();
|
||||||
if w >= active_rect.size.cols { 0 } else { w }
|
if w >= rect.size.cols { 0 } else { w }
|
||||||
};
|
};
|
||||||
let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?;
|
let grid_row = rect.origin.row + u32::try_from(row_offset).ok()?;
|
||||||
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1);
|
let max_col = rect.origin.col + rect.size.cols.saturating_sub(1);
|
||||||
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col);
|
let grid_col = (rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||||
Some(CellCoord::new(grid_row, grid_col))
|
Some(CellCoord::new(grid_row, grid_col))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,28 @@ pub struct PanelReconciliation {
|
||||||
pub released_terminal: Option<WindowId>,
|
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
|
/// 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.
|
/// 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 {
|
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)
|
(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
|
/// Grid / `LOCAL` views call this from their real attach and resize
|
||||||
/// sizes with an internally minted epoch; a semantic view stays
|
/// sizes with an internally minted epoch; a semantic view never takes
|
||||||
/// `None` until Stage 2's authenticated declaration. A repeated
|
/// this path at all — it goes through
|
||||||
/// identical size is not a new declaration.
|
/// [`Self::accept_frame_geometry`], which applies the frontend-owned
|
||||||
pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) {
|
/// 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 {
|
let Some(view) = self.views.get_mut(&fid) else {
|
||||||
return;
|
return GeometryUpdate::Rejected;
|
||||||
};
|
};
|
||||||
if view
|
if view
|
||||||
.frame_geometry
|
.frame_geometry
|
||||||
.is_some_and(|geometry| geometry.total == total)
|
.is_some_and(|geometry| geometry.total == total)
|
||||||
{
|
{
|
||||||
return;
|
return GeometryUpdate::Duplicate;
|
||||||
}
|
}
|
||||||
let next = view
|
let next = match view.frame_geometry {
|
||||||
.frame_geometry
|
None => Some(1),
|
||||||
.map_or(1, |geometry| geometry.geometry_epoch.saturating_add(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 {
|
view.frame_geometry = Some(crate::window::DeclaredFrameGeometry {
|
||||||
geometry_epoch: next,
|
geometry_epoch: next,
|
||||||
total,
|
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
|
/// Core half of the idempotent panel-reconciliation transaction
|
||||||
|
|
@ -3285,13 +3460,14 @@ impl EditorCore {
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden);
|
let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden);
|
||||||
// Unknown geometry (a semantic view before Stage 2's declaration)
|
// Unknown geometry (a semantic view before Stage 2's declaration),
|
||||||
// and a zero-column frame are both non-presentable, and follow the
|
// a zero-column frame, a layout that cannot spare the rows, and a
|
||||||
// hidden arm rather than being sized against a placeholder.
|
// grid the shared wire budget cannot carry are ALL non-presentable
|
||||||
let satisfiable = self
|
// and all follow the hidden arm. One derivation, shared with the
|
||||||
.frontend_area_rows(fid)
|
// renderer (R1-1): a condition that only the renderer knew about
|
||||||
.and_then(|rows| self.panel_allocation(fid, rows))
|
// produced a blank band with the durable state still saying
|
||||||
.is_some();
|
// "visible".
|
||||||
|
let satisfiable = self.presentable_panel_grid(fid).is_some();
|
||||||
let Some(view) = self.views.get_mut(&fid) else {
|
let Some(view) = self.views.get_mut(&fid) else {
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,21 @@ use std::path::PathBuf;
|
||||||
// directly.
|
// directly.
|
||||||
pub use pmacs_protocol::*;
|
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
|
// Attachment
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,16 @@ use crate::cell::{CellSize, Style};
|
||||||
use crate::editor::EditorState;
|
use crate::editor::EditorState;
|
||||||
use crate::protocol::{
|
use crate::protocol::{
|
||||||
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
|
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
|
||||||
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment,
|
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION,
|
||||||
StyleSpan,
|
StatuslineSegment, StyleSegment, StyleSpan,
|
||||||
};
|
};
|
||||||
use crate::statusline::{
|
use crate::statusline::{
|
||||||
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
|
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
|
||||||
evaluate_statusline,
|
StatuslineWindowSegments, evaluate_statusline,
|
||||||
};
|
};
|
||||||
use crate::terminal::TerminalFrame;
|
use crate::terminal::TerminalFrame;
|
||||||
|
use crate::window::WindowId;
|
||||||
|
use pmacs_protocol::panel::{PanelFrame, PanelFramePayload};
|
||||||
|
|
||||||
/// The viewport a `semantic_render` frontend last declared.
|
/// The viewport a `semantic_render` frontend last declared.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
|
@ -317,6 +319,64 @@ pub struct SemanticRenderState {
|
||||||
terminal_error_latched: bool,
|
terminal_error_latched: bool,
|
||||||
/// Whether the most recent render pass projected a terminal.
|
/// Whether the most recent render pass projected a terminal.
|
||||||
terminal_active: bool,
|
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
|
/// 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_font_facts = negotiated_protocol_version >= 17;
|
||||||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||||
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
||||||
|
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,9 +525,69 @@ impl SemanticRenderState {
|
||||||
last_terminal_frame: None,
|
last_terminal_frame: None,
|
||||||
terminal_error_latched: false,
|
terminal_error_latched: false,
|
||||||
terminal_active: 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
|
/// Record the frontend's declared on-screen byte range. Called by
|
||||||
/// the dispatcher when it receives
|
/// the dispatcher when it receives
|
||||||
/// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any
|
/// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any
|
||||||
|
|
@ -616,8 +737,15 @@ impl SemanticRenderState {
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
let Some(vp) = self.viewport.clone() else {
|
let Some(vp) = self.viewport.clone() else {
|
||||||
// Emit nothing before the frontend declares a viewport.
|
// Emit nothing document-scoped before the frontend declares a
|
||||||
return Vec::new();
|
// 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
|
// 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.theme_facts_msg(state));
|
||||||
out.extend(self.font_facts_msg(state));
|
out.extend(self.font_facts_msg(state));
|
||||||
// Q#SL6/Q#SL8: face inventory must precede segment text.
|
// 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 {
|
if let Some(evaluation) = statusline_evaluation {
|
||||||
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
|
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
|
||||||
}
|
}
|
||||||
|
self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -965,9 +1101,17 @@ impl SemanticRenderState {
|
||||||
out.extend(self.theme_facts_msg(state));
|
out.extend(self.theme_facts_msg(state));
|
||||||
out.extend(self.font_facts_msg(state));
|
out.extend(self.font_facts_msg(state));
|
||||||
// Q#SL6/Q#SL8: face inventory must precede segment text.
|
// 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 {
|
if let Some(evaluation) = statusline_evaluation {
|
||||||
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
|
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
|
||||||
}
|
}
|
||||||
|
self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out);
|
||||||
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(
|
fn emit_statusline_payload(
|
||||||
&mut self,
|
&mut self,
|
||||||
buffer_id: BufferId,
|
buffer_id: BufferId,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue