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