mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
* refactor(daemon): share one run_daemon between tty7 and tty7-server
Extract the control-listener-plus-pane-server startup from tty7-server
into tty7_core::daemon::server::run_daemon, and point both binaries at
it. The local daemon now serves the control dialect exactly like a
remote one: one machine = one daemon, whichever binary happens to be
running it.
The bound control socket (and a bind failure) is still reported on
stderr with the historical 'tty7-server:' prefix — a headless server's
log file is off by default, and the remote_router test reads that exact
line back to prove the client derivation and the server bind agree.
* feat(core): daemon-owned machine tree with semantic operations
Add core::machine: the workspace/tab/pane tree a machine's daemon owns
outright, replacing the client-owned-schema model of the opaque record
store. Leaves hold a pane id and nothing else; every fact about a pane
(cwd from OSC 7, title, ssh spec, agent identity) lives once in the
pane registry, which is what makes revival sound: a reopened store
force-clears every live flag, so after a daemon restart the tree itself
says every leaf is awaiting revival — no client-side instance stamps or
id-reuse heuristics required.
Operations (workspace create/rename/delete/touch/set-active-tab, tab
create/close/rename/move/regroup, pane split/close/set-ratio/move/
replace) validate against the held tree, persist atomically, roll back
on a failed write, and broadcast incremental LayoutDelta events with
origin exclusion so a writer never hears its own echo. Persisted to
machine.json beside the old store's file, serde with #[serde(default)]
throughout so the daemon can keep evolving the schema, corrupt files
quarantined instead of overwritten.
* feat(control): machine-tree verbs and incremental Layout deltas
Teach the control dialect the semantic operations the machine tree
serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename /
Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move /
SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies
carry the daemon's own tree types (a created workspace or tab comes
back whole; close operations answer the pane ids that left the tree so
the caller can kill their PTYs), and every operation broadcasts a
ControlEvent::Layout delta to every connection but the writer's — the
same origin-exclusion mechanism the record store uses, one delta at a
time instead of whole-record last-writer-wins.
The server advertises a new 'machine-tree' capability bit only when it
actually carries a MachineStore; both daemons now do, alongside the
retired opaque record store, which keeps serving unchanged while
clients migrate. Delta fan-out rides its own bounded queue and
forwarder thread per connection, so a peer that stopped reading stalls
nobody's edit; the drop-on-overflow tradeoff is documented against the
keepalive that reaps such a peer and the full pull every reconnect
starts with.
The request/reply/event enums lose their Eq derive: split ratios are
f32. End-to-end tests drive the shipped tty7-server binary over real
pipes: capability advertisement, tree ops landing in the server's own
file, dead-pane revival across a real process restart, and delta
delivery between two live clients.
* feat(daemon): pane facts flow from the pane server into the machine tree
The tree's pane records are only worth reviving from if they hold what
the machine itself observed, so the pane server now publishes into the
MachineStore the daemon serves: the reader thread reports OSC 7 / probed
cwd changes and the sniffer's agent facts (identity, native session id,
launch argv, coarse status) after each chunk that changed them, and
DeathReporter::report flips the record to live == false however the
death was noticed — that flag is the client-visible 'awaiting revival'
state, and it now comes from the process that owns the PTYs on the very
event, not only from the next restart.
The store rides a process-wide slot (installed by control_services,
same shape as the control event observer) so the three pane-spawn paths
need not thread it through; without one installed, observing is a
no-op, which keeps unit tests and tree-less servers quiet. Facts are
published outside the pane state lock and only on a real change, so the
reader's hot path pays two clones and a compare. AgentFacts.status
tightens from a free string to the existing AgentStatus enum while no
wire client depends on it.
* feat(ui): hold a supervised control link to the local daemon
The GUI now dials this machine's own daemon over the control dialect,
exactly as it does a remote one: one machine, one daemon, one control
link. The link lives in its own global rather than RemoteConnections —
inserting it there would register a wire-backed Host for this machine
(local files and git must keep going through the in-process LocalHost)
and would break the HostId::LOCAL-never-holds-a-control-connection
invariant. No routing either: the daemon's control socket is right
here, so connecting is a Unix connect plus a ControlHello.
Supervised on its own forever loop at the remote pump's cadence,
because that pump deliberately parks when the last remote workspace
closes and a purely local session is the common case. Each turn also
drains the shared control-event queue, so local pushes (Layout deltas,
Preempted) are delivered under HostId::LOCAL even with the remote pump
stopped; the observer install is shared with the remote supervisor so
whichever comes up first, reader threads never find nobody listening.
Reconnects ride the same 1/2/4/…/30s backoff a remote machine gets,
with ensure_running first — the daemon is the GUI's own child, and a
cold start legitimately races its listener.
Unix-only like the control listener it dials; on Windows the loop
compiles to a supervision no-op and the pane path is untouched.
* feat(control): attachment and takeover ride the machine tree too
WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace
shorthand) now record their data half on whichever workspace stores the
server carries: the retired record store, the machine tree, or — on a
full daemon while clients migrate — both, since they describe the same
workspace. The behavioural contract is untouched and now survives the
record store's retirement: newcomer always wins, the displaced session
is pushed Preempted (and closed only when its link was dedicated), and
a preempted session's tidy-up detach cannot evict the usurper — the
token check lives in the tree's runtime-only attachment exactly as it
did in the store's. A server carrying neither store answers the same
refusal a store-less server always has.
WorkspaceId gains FromStr (the inverse of its Display) because the
attach verbs predate the typed tree and carry the id as a string. The
end-to-end test drives a takeover on a server serving the tree and no
record store at all, asserting the tree's own attachment record moves
with it.
* fix(core): review hardening for the machine-tree foundation
Findings from a correctness review of the new daemon-owned tree,
applied together:
- A dead pane can no longer be resurrected in the tree by its own last
output. On Windows the exit monitor reports the death while the
reader is still draining ConPTY's buffered bytes, and the death
report is latched; the reader's 'output is proof of life' publish now
asserts liveness only while the pane state still says alive.
- Delta delivery is ordered. Mutations were serialized by the state
lock but delivered after releasing it, so one writer's deltas could
overtake another's and leave every mirroring client on the losing
state with no cue to re-pull. A notify-order mutex now spans each
mutation and its own fan-out; cheap, because subscriber callbacks are
enqueue-only by contract.
- Implicit active-tab changes broadcast. tab_create's activation and
the close paths' heal now emit ActiveTabChanged, so a client applying
deltas never re-implements the server's heal rule; the one
inexpressible case (no tabs) needs no delta because it is a fact,
not surgery.
- The coarse agent status no longer drives disk writes: it flips per
hook event and is display-only, so it is outside the changed-facts
gate and merely rides along when a load-bearing fact changes.
- control_services reports which stores it serves on stderr again —
tty7-server configures no log sink, and 'no machine tree' was
invisible exactly where it matters, on a headless box.
- The local link's first connect attempt is immediate instead of one
backoff step late; the observation-slot test withdraws its store so
it cannot swallow later tests' observations; and locked()'s poison
rationale now says what is actually guaranteed.
* feat(control): let clients mint workspace and tab identities on create
A window names its workspace — in the registry, the view file, and any
operation it queues — before its first round trip completes, and the same
holds for a tab the moment the user opens it. Making the daemon the only
minter would force every client to hold its edits until a reply carried
the real id back. Ids are uuids, so a client-minted one is as unique as a
daemon-minted one; WorkspaceCreate and TabCreate now carry an optional
client id, keep it when it is free, and refuse a duplicate rather than
adopt it. Absent (older callers, tests) the daemon mints as before.
* feat(ui): windows speak semantic tree operations for every structural change
The write path of the client migration: each window now keeps a mirror of
what the daemon's tree holds for its workspace, and save_session — the
funnel every structural change already passes through — diffs the window
against that mirror and sends the recovered operations (TabCreate,
PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the
workspace's control link: the LocalLink for this machine, the machine's
RemoteConnections entry otherwise. Consecutive saves differ by exactly one
user action, so the diff recovers that action rather than re-shipping the
layout; changes no single op expresses rebuild the affected tab whole,
matching the delta contract's own granularity.
The mirror advances by running the server's own tree surgery (PaneNode's
split/remove/replace are public now), and any disagreement — a refused op,
a dropped link — resolves by one shared recovery path: drop the queue,
re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their
pane id lands; land_pane's save is when their create goes out. GUI tabs
carry a client-minted TabId, and a primed mirror re-points tabs it
recognizes by their panes, so a rebuilt window adopts the daemon's tabs
instead of churning them.
Workspace-level facts ride along: focus touches, renames, and deletions
now reach the machine's tree too, and the divider drag finally persists
the ratio it lands on (it previously reached disk only as a passenger on
the next structural change).
session.json is still written in parallel; it retires with the read-path
migration.
* feat(ui): local windows restore by asking the daemon's tree
The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.
The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.
Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.
* feat(ui): live windows apply the machine's incremental layout deltas
The pump's event drain now lands ControlEvent::Layout instead of debug-
logging it: each delta advances this client's mirror (by the same
surgery the server ran) and then the live window showing the workspace —
renames, regrouping, moves, active-tab changes and ratio drags in place;
TabCreated by building the tab and attaching its (writer-spawned, so
live) panes; TabRestructured by rebuilding the one tab while reusing the
views of panes the window already shows, because re-attaching a pane
this window holds would steal its own stream. Origin exclusion means
every delta arriving is another client's edit, and applying it to window
and mirror in one step leaves the next local diff with nothing to echo.
A delta that will not apply cleanly — a tab the mirror never heard of, a
drifted window — falls back to re-pulling the workspace and rebuilding
the window from the authoritative tree, the same single recovery path
every other failure already uses.
* feat(daemon): report panes the machine tree no longer references
With the tree now populated by clients' semantic operations, the daemon
can finally see panes nothing references. A periodic sweep reports them —
log-only, deliberately: an unreferenced pane is not proof of a leak (a
native-SSH pane opened inside a remote workspace's window runs in this
daemon while belonging to the other machine's tree), and reclaiming one
wrongly kills a session the user is looking at. The sweep's interval
doubles as a grace period: a pane is reported only after being
unreferenced across two consecutive looks, so an adoption still in
flight is never flagged. Reclamation can be layered on once the log has
shown the false-positive rate is zero.
* feat(ui): remote workspaces read and write the machine tree like local ones
Local and remote are now the same shape end to end. A remote workspace
opens empty unconditionally (connected or not) and is filled by the same
tree hydration a local window uses; the connect supervisor's landing
replaces the opaque-record refresh with it — a blinked link relinks the
pane streams and hydrates whatever opened empty meanwhile, a replaced
server process resyncs the window from the tree, whose force-cleared
live flags are what make every leaf revive. The remote picker lists
workspaces from MachineGet, deriving names from the tree the way a
local workspace derives its own; creating one lets the hydration's
WorkspaceCreate mint it on the machine; the record push, pull, refresh
(WorkspaceChanged) and remote delete paths are gone client-side.
Windows that have not yet seen their machine's tree sync additively: a
window that opened empty ahead of its pull may add tabs but never prunes
ones it has not displayed, so its ignorance can no longer read as 'close
everything' — the diff takes an explicit scope, and only hydration (or a
deliberately authoritative open, like restore-off) grants the full one.
* refactor(core): retire the client-side pane-identity defenses
The machine tree made this whole family unnecessary, so it goes rather
than lingers: daemon_instance stamps (a restarted daemon's tree says
live=false about every pane — a fact, where the stamp was a heuristic),
forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon
refuses a pane appearing twice in its tree, so there is no duplicate to
mop up client-side), the claim/record instance plumbing, and the
whole-record halves of the storage split (to_remote_json,
apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the
store's apply_remote / remote_payload), together with their tests.
forget_pane_ids stays for now: it clears the client's cached copy, which
still serves as the one-time import fallback until the view file slims
down to pure view state.
* refactor(ui): a local daemon restart rebuilds from the tree too
The tree file survives the restart and the fresh daemon force-clears
every pane's live flag, so the resync path already expresses exactly
what the hand-rolled saved-session rebuild did: every leaf revives as a
fresh shell in its recorded cwd with its agent resumed. The pull waits
out the local link reconnecting to the fresh daemon.
* docs(core): drop a stale reference to the retired record verbs
* fix(ui): close the review findings on the tree migration
Review fixes, worst first:
- Pane ids never alias across daemon restarts: the pane registry seeds
its counter past everything the persisted tree references. A fresh
process minting from 1 handed new shells ids that dead leaves still
claimed — the tree marked the wrong pane live, revival stalled forever
on 'already part of this machine's tree', and an attach by the stale
id stole another workspace's stream. Ids are names now, not slots.
- An empty window only licenses WorkspaceRemove once it is *informed*:
a window whose hydration has not answered is empty because it is
waiting, and closing or swapping it mid-pull was deleting populated
trees. Remote workspaces also hydrate regardless of the restore
setting — their panes are running sessions, not a saved layout, and
the restore-off swap used to open them empty-and-authoritative and
close every tab on the machine.
- Tabs whose panes are all still spawning are *held*, not pruned: they
are invisible in the desired tree without being absent, and the Full
diff was closing them (spending the records the landing spawns'
PaneReplace needed) on every remote revival.
- A preempted window stays passive under deltas: applying the usurper's
TabCreated/TabRestructured attached to their fresh panes and stole the
streams they were typing into. The mirror is dropped instead; taking
the workspace back re-pulls it whole.
- Delta TabClosed tracks the active tab by identity (closing a tab to
the left no longer shifts focus and pushes the wrong active tab back).
- The hydrate/resync path drops the op queue like desync does, so ops
computed against an abandoned mirror cannot drain after the snapshot.
- A rebuilt remote tab no longer matches a native-SSH leaf's *local*
pane id against remote ids; delta-applied ratios clamp to the GUI
band; async completions use get_mut so a forgotten window's sync state
is not resurrected.
* feat(ui): a per-machine mirror of each daemon's tree feeds the read surfaces
The switcher, the Window menu, the title bar, the rename seeds, the
stop/delete confirmation and the liveness sweep all answered their
questions (display name, subject path, pane ids, pane count) from the
client's cached copy of the layout. The machine's tree owns the layout
now, so a new per-host MachineMirrors global holds each machine's last
pulled tree — filled by a MachineGet whenever a control link comes up
(and for free off every hydration, which already pulls the whole
machine), advanced by the same Layout delta stream the windows consume,
plus explicit notes for this client's own operations, which origin
exclusion keeps out of that stream.
The readers move over wholesale. A machine not pulled yet reads as
not-knowing rather than a stale guess: pickers show the shared fallback
for a beat (against the local daemon the pull lands within a frame),
and the pane-count prompt says the machine could not be asked instead
of counting against a cache. tree_display_name moves out of the remote
picker into the mirror as display_name_of — it was always the tree
flavour of Workspace::display_name, and now everything shares it.
This is the read-model half of retiring the client's layout cache; the
persistence shrink to pure view state follows on top of it.
* refactor(ui): client persistence shrinks to pure window views
The client file stops carrying layout. session.json's Workspace — id,
name, a whole embedded Session, geometry, open, last_active, host —
becomes WindowView { id, window, open, last_active, host } in a fresh
views.json (no migration by design; an old session.json is simply
ignored, and its panes revive from the machine tree like any daemon
restart). Everything the embedded layout used to answer already moved
to the per-machine mirror, so this deletes the write half:
- WorkspaceStore::claim answers only the id; record shrinks to
record_geometry. claimable_session / record_session — the
reachability-gated layout cache — go entirely, and with them the
one-time empty-tree import in finish_hydration: with no cached copy
there is nothing to import, and the machine answering "no tabs" is
the layout.
- The user-set name is purely the machine's fact now. rename /
rename_locally leave the store; the chip and switcher renames fire
WorkspaceRename directly (tree_sync::rename_workspace), the
WorkspaceRenamed delta needs nothing from the window because the
mirror already applied it, and WorkspaceCreate seeds no name.
- forget_pane_ids / blank_pane_ids and the layout-derived getters
(display_name, dominant_repo, first_cwd, pane_count, pane_ids) are
deleted with their tests — each had grown a mirror-side twin.
- switch_workspace always hydrates: with the tree as the only layout
source, restore-off governs what launch comes back to, not what a
deliberate switcher pick shows.
The retired opaque record store loses its one test that asserted its
file parses as a client Workspaces document — that coupling is the
thing this migration ends, and the store itself is next to go.
* refactor(server): retire the opaque workspace record store
Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:
- core::workspace_store is deleted. Attachment and the data-directory
resolution (TTY7_DATA_DIR, XDG fallback chain) move into
core::machine, which was already their only consumer; Attachment
loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
payload they answered with, and the WorkspaceChanged event. Their
serde names (and the workspace-store capability bit) are recorded as
burned rather than reserved by any mechanism — the dialect has no
numbered slots to hold, so a comment at each site is the guard, plus
the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
per-connection store subscription and its WorkspaceChanged forwarder,
and the store half of attach/detach/teardown. Attachment data now
lives solely in the tree: a workspace the tree does not list records
no data half (the registry's live handles still move, so takeover
behaviour is unchanged), and it appears the moment the workspace
does. Services::with_workspaces/and_machine collapse into
with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
to workspaces created in a real tree; the record-store round-trip and
fan-out tests go (tests/machine_tree.rs has carried the tree
equivalents since the verbs landed), and tests/workspace_store.rs is
deleted with the serde_json dev-dependency that existed only for it.
machine.rs gains the two guarantees the old suite held uniquely: an
attachment dies with its workspace structurally, and the default path
resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.
* refactor(ui): rename RemoteConnections to HostLinks
Purely mechanical, plus the doc sentences that carry the model: the
table holds one control link per machine, and the local machine is a
machine like any other — its link just lives in its own global
(LocalLink) because it is in-process rather than wire-backed. The old
name framed the table as remote-only plumbing, which the tree
migration made false in spirit: local and remote windows speak the
same operations over whichever link their machine answers on.
* fix(ui): a tree-driven tab rebuild keeps the native-SSH split it cannot name
A native-SSH pane opened inside a remote workspace's window runs in
this client's own daemon and is deliberately absent from the remote
machine's tree (its local id would collide with an unrelated remote
pane). The TabRestructured rebuild therefore had no leaf for it and
dropped its view on the floor: the local session kept running,
invisible from every surface — a true orphan only the daemon's log-only
sweep would ever mention.
The rebuild now sets such leaves aside while harvesting reusable views
and appends each back as a fresh half-and-half split on the right once
the tree's own panes are built. The old split geometry is unknowable
from the delta (the tree never held it), so the appended shape is the
one a split created it in; the next save changes nothing, because the
diff already lowers a remote window without its ssh leaves.
The resync path (a delta that fails to apply, a replaced server) still
rebuilds the whole window from the tree and drops such views — that
path discards every view it has by design, and is left as a known
residual. TerminalView grows a test-only ssh-marked pane constructor so
the kept-split property is pinned by a gpui test.
* docs(core): finish pointing the last session.json references at views.json
* fix(ui): kick every local window's sync when the local link comes up
A window built while the local control link was still dialing parks as
Unprimed { dirty } — start_prime's unreachable arm leaves the retry to
"the reconnect-triggered save", but the local link supervisor never
triggered one. On a first launch (window built before the auto-spawned
daemon binds its socket) nothing else re-enters sync_window until the
next structural change, so quitting before one loses the window's
layout: the machine never heard of it.
Reproduced end-to-end on a scratch daemon: fresh launch, no user
action, quit — the relaunch came up empty. With the link supervisor
calling tree_sync::on_link_up on connect, the same launch syncs the
tree within one pump tick.
* fix(ui): read a deleted workspace's kill list before the removal blanks the mirror
delete_workspace fired WorkspaceRemove first, and fire_workspace_op folds
the removal into the machine mirror synchronously on its way out — so the
kill list stop_workspace_keeping then read off that mirror was always
empty, and 'Delete Workspace' ended zero of the sessions its confirm
prompt promised to end. The kill list is now read before the op fires,
and both destructive paths receive it explicitly so the ordering is a
signature rather than a convention.
* fix(control): bump both dialect versions and gate tree verbs on the machine-tree bit
The tree migration deleted four control verbs and added seventeen, but
CONTROL_VERSION stayed at 2 — two builds that cannot understand each
other's requests would have shaken hands as equals. It is now 3, with
the history entry the file's format asks for.
PROTOCOL_VERSION moves to 4 for the service change underneath: a
pre-tree 'tty7 --daemon' has no control listener at all, so a GUI from
this build silently adopting one connects its control link into the
void forever and every window hydrates from a tree that never answers.
The bump routes that meeting into ensure_running's existing
keep-or-restart prompt.
Clients now also consume the machine-tree capability bit before any
tree traffic: a connected peer without it (a server with no home
directory keeps serving files and panes) classifies as a distinct
'unserved' state that is logged once and skipped, instead of a refused
round trip per operation.
* fix(ui): preempted windows stay passive and take-back rebuilds from the tree
Two halves of the same takeover contract were broken.
A preempted window kept pushing: sync_window had no preemption check, so
a click on the read-only tab strip sent WorkspaceSetActiveTab against
the usurper's session, and the next save Full-diffed the stale layout —
rolling the usurper's edits back wholesale. sync_window now returns
early for a preempted workspace, and preemption itself drops the
window's queue, mirror and 'informed' licence (tree_sync::on_preempted,
shared with the delta path's existing reset).
Take Back never rebuilt: the recovery attach ran the ordinary IfEmpty
hydration, which skips any non-empty window — and a preempted window is
by definition non-empty with the pre-takeover layout. retry_now now
marks the workspace as reclaiming, and finish_attempt rebuilds marked
(or still-preempted) windows via Adopt::Replace, honouring the 'take
back re-pulls whole' promise the delta path documents.
* fix(ui): delta application survives pulls in flight
Three overlap bugs between the incremental delta stream and the full
pulls it has no ordering barrier with:
- A TabCreated straddling a pull was applied by both — the snapshot
already carried the tab, and the delta inserted a second copy into
the machine mirror and the window mirror, and rebuilt a second GUI
tab whose attach stole the pane's single stream from the window
itself. All three application sites now replace by id.
- A delta arriving while a window's prime/hydration was in flight was
applied to the window even though the mirror side skipped it — a
TabCreated landing in a still-empty window made finish_hydration
read 'the user got here first' and skip adopting the tree, leaving
the window with only the concurrently-created tab forever. Window
application is now gated on the mirror being primed; the pull's
snapshot carries the delta's effect.
- A prime answered after a newer cycle (hydration, desync, preemption)
replaced it would install its stale tree over a mirror that had since
advanced, and the next diff would re-emit the rollback as operations.
Every cycle now stamps an epoch, and pulls landing under an old one
are dropped.
* fix(ui): apply ratio deltas in the server's clamp band
set_gui_ratio clamped to 0.1-0.9 while the server accepts 0.05-0.95, so
another client's 0.07 arrived as 0.1 — and the next save's ratio diff
pushed the rewrite back at the machine, silently moving their divider.
* fix(core): machine-store hardening around seeds and unreadable files
- A PaneSeed entered the registry live:true unconditionally. A pane
that died between its spawn and its adopting operation had its death
observation dropped (note_pane_facts ignores panes the tree does not
hold), and nothing ever flipped the record back — the leaf claimed a
live pane forever and revival was never offered. The daemon now
installs a liveness probe on the store (registry-backed), consulted
at registration; without one (tests, clients) the seed is trusted.
- seed_ids_past computed max + 1, which panics a debug daemon at
startup when the persisted tree names u64::MAX. saturating_add parks
the counter at the ceiling instead.
- load_machine quarantined an unparseable file but not an unreadable
one: a read failure logged, started empty, and the first mutation
overwrote the very file that could not be read. Read failures now
quarantine too — by rename, since a copy would need the read
permission that just failed.
Also de-flakes the pre-existing spawn_writer test: the first write into
a freshly-closed socket can succeed before the kernel processes the
close, so the poll loop now keeps the writer fed until a write fails.
* feat(control): announce dropped layout deltas so lagged clients resync
A connection whose per-link delta queue overflowed lost an edit it will
never hear again — the server logged the drop, and the client mirrored
a tree it was no longer looking at until something else happened to
fail. The subscriber callback now flags the connection lagged, and the
layout forwarder sends the new ControlEvent::LayoutResync ahead of the
next delta it delivers (the flag is only ever set with a full queue
behind it, so the announcement never waits on a quiet tree). The client
answers by re-pulling the machine mirror and resyncing every window on
that machine — the same recovery an unappliable delta already uses,
announced instead of stumbled into. WatchOverflow is the precedent.
* fix(ui): a pure native-SSH tab is invisible to the tree, not held forever
Held means 'spawns are landing, wait before ordering' — but a remote
window's tab that is native-SSH through and through can never land: its
panes live in this client's daemon and are deliberately unnameable in
the remote machine's tree. Filing it as held made every diff return
before the ordering and active-tab passes, freezing tab order and
activation sync for the whole window for as long as the tab existed —
and a mixed tab whose last remote pane was closed kept its dead leaf on
the machine for ever, because the held id shielded the daemon tab from
the close.
Such tabs are now classified permanently invisible: not desired, not
held. Ordering resumes, and the mixed tab's daemon twin closes when its
last tree-visible pane goes. Pending leaves (a connecting spawn, an
empty slot) still read as held.
* docs(core): drop the dead instance helper, the stale title field, and two doc lies
- local_daemon_instance() lost its last caller when the client-side
pane-identity defenses were retired; deleted.
- DaemonVersion::instance's doc pointed at Workspace::daemon_instance
(deleted with the record store) and claimed pane ids restart from 1 —
no longer true of a tree-carrying daemon, which seeds its ids past
everything the tree names. Rewritten to describe what the field
actually backs now.
- PaneRecord::title claimed to label panes awaiting revival, but no
code ever wrote it: the pane's title is a live foreground-process
query at PaneInfo time, not state the facts path observes. The field
is deleted (serde-compatible: unknown fields are ignored on read) and
the decision recorded where it lived; revival labels derive from cwd
and agent.
* fix(ui): converge the tree after adopting a delta-created tab
Adopting a TabCreated delta whose pane is dead on arrival attaches
nothing and spawns a fresh pane under a new id — and nothing on the
delta path saved afterwards, so the tree kept the dead leaf: other
clients saw a dead tab, and a relaunch would spawn a second successor
beside the leaked first. Reproduced end-to-end (external client creates
a tab with an unspawned pane; the GUI adopted it and the tree never
learned the successor's id).
One sync_window after a clean apply closes it: free when window and
mirror agree (the diff is empty), and exactly the PaneReplace that
spends the dead record when adoption had to spawn.
* fix(core): review follow-ups on the daemon-owned tree
Nine findings from a review pass over the branch. One commit because
they cross the same files, and splitting them would leave an
intermediate that does not build on Windows.
- A dropped delta announced a LayoutResync and then delivered the
backlog behind it. The queue is FIFO, so everything still in it is
*older* than the gap: the peer re-pulled on the notice and was then
walked back through history it had already left — TabRestructured
restoring the shape a tab used to have, with window and mirror
agreeing on the stale answer so nothing recovered a second time. The
forwarder now drops the superseded queue and sends the resync in its
place.
- Pane facts persisted the whole document, with an fsync, from the PTY
reader thread — once per OSC 7, so once per prompt per pane — while
holding the lock that orders every other client's edits. A shell
looping over directories was a write per iteration. Observations
(pane facts, workspace_touch) now take Persist::Soon: the delta still
goes out at once, the file catches up within FACT_FLUSH_INTERVAL, and
the daemon flushes on the way out. The layout itself is never
deferred.
- An ordinary output chunk paid two AgentFacts clones and a
clone-to-compare for facts it could not have changed. Gated on the
signals that can move one, and the compare no longer clones.
- machine.json was created 0644, naming every workspace's directories,
the SSH user and host of every native-SSH pane, and each agent's
session id. It is written owner-only from the first instant the final
name exists, and a second corruption no longer overwrites the rescue
copy of the first.
- Windows had no control listener, so on the one platform where the
tree is the only layout store, tabs did not come back at all. It now
serves the dialect over the transport its pane socket already uses: a
loopback listener whose port and 256-bit token live in a user-private
control.port beside daemon.port — its own token, not the pane
endpoint's — refusing to rebind over a live one, since binding is
what writes the marker. run_daemon and the GUI's local link are one
code path again.
- Workspace names and paths came only from the machine's mirror, so a
laptop shut since Friday listed every row as "Untitled" with a blank
subtitle, in the picker whose whole job is offering workspaces on
machines that are asleep. WindowView carries the label and subject
the machine last gave, stamped on save and on detach; the tree still
wins whenever it answers.
- liveness_of read "the mirror has not been pulled yet" as Stopped,
which tells the user their sessions are gone on the strength of our
own ignorance. Unknown is what that state is for.
- A WorkspaceRemove that never reached its machine was a debug line,
though the client had already forgotten the workspace. It is now a
warning that says what was left where.
- MachineMirrors::install landed a pull without a repaint; the two tests
the record store's retirement took with it (a closed connection stops
being a subscriber, concurrent connections can all write) are back
against the tree; and CHANGELOG records the migration's one-time
layout loss and the Windows gap this closes.
Suites green: tty7-core 675, tty7 819, tty7-server 9/5/3/3/51, fmt and
clippy clean. The Windows listener is unverified by a compiler here — a
C dependency in the tree blocks cross-checking from macOS — so CI's
Windows job is its first build.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@thomass-Mini.lan>
1403 lines
56 KiB
Rust
1403 lines
56 KiB
Rust
//! A binary split-pane tree for a single tab. Each leaf is a terminal; splits
|
|
//! divide the available space along an axis at an adjustable ratio (default
|
|
//! 50/50, draggable via the divider between the two children). The tree is small
|
|
//! and mutated in place (split / close-and-collapse), and rendered recursively
|
|
//! with flex.
|
|
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
|
|
use gpui::{App, Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, Window, canvas, div};
|
|
use gpui::{Axis, Entity, prelude::*, px};
|
|
use gpui_component::ActiveTheme as _;
|
|
|
|
use crate::terminal::view::TerminalView;
|
|
use crate::ui::pending_pane::PendingPane;
|
|
|
|
/// Legal band for a split's `a`-child ratio; keeps both panes usable.
|
|
const MIN_RATIO: f32 = 0.1;
|
|
const MAX_RATIO: f32 = 0.9;
|
|
/// Thickness (px) of the draggable divider between two split children.
|
|
const DIVIDER_THICKNESS: f32 = 5.;
|
|
|
|
/// What occupies one leaf of the tree.
|
|
///
|
|
/// A pane is a terminal in every state the user cares about — but a *remote*
|
|
/// one is not a terminal for the first few hundred milliseconds of its life,
|
|
/// because building it means waiting on another computer. Rather than block the
|
|
/// window for that (which is what happened before; see
|
|
/// [`ui::pending_pane`](crate::ui::pending_pane)), the slot goes into the tree
|
|
/// straight away and holds the wait.
|
|
///
|
|
/// The tree itself is indifferent to which variant a leaf holds: splitting,
|
|
/// closing, geometry and directional focus all work off identity and shape.
|
|
/// Only [`render`](Pane::render) and the handful of places that genuinely need
|
|
/// a *terminal* — writing input, saving the session, killing a pane — care, and
|
|
/// those ask with [`terminal`](PaneSlot::terminal).
|
|
#[derive(Clone)]
|
|
pub enum PaneSlot {
|
|
Ready(Entity<TerminalView>),
|
|
/// Still connecting, or failed and offering a retry.
|
|
Connecting(Entity<PendingPane>),
|
|
}
|
|
|
|
impl PaneSlot {
|
|
/// Identity, for the by-identity tree operations. Both variants are gpui
|
|
/// entities, so one id space covers them and a slot keeps the same identity
|
|
/// across the swap only if the caller asks for it (it does not — the swap
|
|
/// is `replace_leaf`, matched on the *pending* id).
|
|
pub fn entity_id(&self) -> gpui::EntityId {
|
|
match self {
|
|
PaneSlot::Ready(v) => v.entity_id(),
|
|
PaneSlot::Connecting(v) => v.entity_id(),
|
|
}
|
|
}
|
|
|
|
/// The terminal, or `None` while this slot is still connecting.
|
|
///
|
|
/// Deliberately an `Option` rather than something that waits: every caller
|
|
/// of this is answering a question about *now* (what is focused, what to
|
|
/// save, where to send this keystroke), and "there is no terminal here yet"
|
|
/// is a real answer to all of them.
|
|
pub fn terminal(&self) -> Option<&Entity<TerminalView>> {
|
|
match self {
|
|
PaneSlot::Ready(v) => Some(v),
|
|
PaneSlot::Connecting(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Whether focus is inside this slot.
|
|
///
|
|
/// `contains_focused`, not `is_focused`: a leaf is "active" when its
|
|
/// terminal surface *or any descendant* holds focus. The inline input
|
|
/// editor is a child with its own focus handle, so while the shell idles at
|
|
/// its prompt focus lives there, not on the terminal's own handle — an
|
|
/// exact `is_focused` check would miss the active pane.
|
|
pub fn contains_focused(&self, window: &Window, cx: &App) -> bool {
|
|
match self {
|
|
PaneSlot::Ready(v) => v.read(cx).focus_handle.contains_focused(window, cx),
|
|
PaneSlot::Connecting(v) => v.read(cx).focus_handle.contains_focused(window, cx),
|
|
}
|
|
}
|
|
|
|
/// This slot's focus handle, so a connecting pane can be focused like any
|
|
/// other — a tab whose only pane is still connecting must not be a tab with
|
|
/// nowhere for focus to go.
|
|
pub fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
|
match self {
|
|
PaneSlot::Ready(v) => v.read(cx).focus_handle.clone(),
|
|
PaneSlot::Connecting(v) => v.read(cx).focus_handle.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The leaf payload is generic (defaulting to the real pane slot) so the pure
|
|
/// tree logic can be exercised in tests with plain values; at runtime `Pane` is
|
|
/// always `Pane<PaneSlot>`.
|
|
pub enum Pane<L = PaneSlot> {
|
|
Leaf(L),
|
|
Split {
|
|
axis: Axis,
|
|
a: Box<Pane<L>>,
|
|
b: Box<Pane<L>>,
|
|
/// Fraction of the split occupied by `a` (clamped to `MIN..=MAX_RATIO`).
|
|
/// Stored in a shared cell so the divider's drag closure can update it
|
|
/// without having to locate this node by path in the tree.
|
|
ratio: Rc<Cell<f32>>,
|
|
/// Whether the divider is currently being dragged. Lives in the node so
|
|
/// the in-progress drag survives the re-renders it triggers.
|
|
dragging: Rc<Cell<bool>>,
|
|
},
|
|
/// Transient placeholder used only while collapsing a split; never rendered.
|
|
Empty,
|
|
}
|
|
|
|
/// A direction for pane focus / resize, mapped from the arrow-key actions.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Dir {
|
|
Left,
|
|
Right,
|
|
Up,
|
|
Down,
|
|
}
|
|
|
|
impl Dir {
|
|
/// The split axis this direction operates along: Left/Right divide width
|
|
/// (a horizontal split), Up/Down divide height (a vertical split).
|
|
fn axis(self) -> Axis {
|
|
match self {
|
|
Dir::Left | Dir::Right => Axis::Horizontal,
|
|
Dir::Up | Dir::Down => Axis::Vertical,
|
|
}
|
|
}
|
|
|
|
/// Whether this direction *grows* the focused pane (Right/Down) as opposed
|
|
/// to shrinking it (Left/Up).
|
|
fn grows(self) -> bool {
|
|
matches!(self, Dir::Right | Dir::Down)
|
|
}
|
|
}
|
|
|
|
/// A leaf's normalized rectangle within the tab (the whole tab is the unit
|
|
/// square `0,0 → 1,1`). Derived purely from split axes and ratios, so directional
|
|
/// focus is a geometry query independent of the actual pixel layout.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Rect {
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub w: f32,
|
|
pub h: f32,
|
|
}
|
|
|
|
/// Overlap length of two 1-D intervals `[a0, a0+alen)` and `[b0, b0+blen)`
|
|
/// (0 when they don't overlap). Used to score how well two panes line up on the
|
|
/// axis perpendicular to a move.
|
|
fn overlap_1d(a0: f32, alen: f32, b0: f32, blen: f32) -> f32 {
|
|
((a0 + alen).min(b0 + blen) - a0.max(b0)).max(0.0)
|
|
}
|
|
|
|
/// Result of attempting to close the focused leaf.
|
|
pub enum CloseOutcome {
|
|
/// No focused leaf in this subtree.
|
|
NotFound,
|
|
/// A leaf was removed and the tree collapsed around it.
|
|
Collapsed,
|
|
/// This node *is* the focused leaf; the caller should drop it (e.g. close
|
|
/// the whole tab when it was the tab's only pane).
|
|
RemoveSelf,
|
|
}
|
|
|
|
/// Structural tree operations, independent of what a leaf holds. Matching a
|
|
/// specific leaf is expressed as a predicate so the focus- and identity-based
|
|
/// public API (below) can share one implementation with the tests.
|
|
impl<L: Clone> Pane<L> {
|
|
pub fn leaf(view: L) -> Self {
|
|
Pane::Leaf(view)
|
|
}
|
|
|
|
/// Construct a split node from two already-built children. Used when
|
|
/// rebuilding a saved session tree from disk: `ratio` is clamped to the
|
|
/// legal band and the divider starts un-dragged.
|
|
pub fn split_node(axis: Axis, ratio: f32, a: Pane<L>, b: Pane<L>) -> Self {
|
|
Pane::Split {
|
|
axis,
|
|
a: Box::new(a),
|
|
b: Box::new(b),
|
|
ratio: Rc::new(Cell::new(ratio.clamp(MIN_RATIO, MAX_RATIO))),
|
|
dragging: Rc::new(Cell::new(false)),
|
|
}
|
|
}
|
|
|
|
pub fn collect_leaves<'a>(&'a self, out: &mut Vec<L>) {
|
|
match self {
|
|
Pane::Leaf(v) => out.push(v.clone()),
|
|
Pane::Split { a, b, .. } => {
|
|
a.collect_leaves(out);
|
|
b.collect_leaves(out);
|
|
}
|
|
Pane::Empty => {}
|
|
}
|
|
}
|
|
|
|
pub fn leaves(&self) -> Vec<L> {
|
|
let mut v = Vec::new();
|
|
self.collect_leaves(&mut v);
|
|
v
|
|
}
|
|
|
|
pub fn first_leaf(&self) -> Option<L> {
|
|
match self {
|
|
Pane::Leaf(v) => Some(v.clone()),
|
|
Pane::Split { a, b, .. } => a.first_leaf().or_else(|| b.first_leaf()),
|
|
Pane::Empty => None,
|
|
}
|
|
}
|
|
|
|
/// The leaf satisfying `pred`, or the first leaf if none does. Used to
|
|
/// restore a remembered pane (matched by identity) when switching back to a
|
|
/// tab, gracefully degrading to the first leaf when that pane has since
|
|
/// closed.
|
|
pub fn leaf_matching_or_first(&self, pred: impl Fn(&L) -> bool) -> Option<L> {
|
|
self.leaves()
|
|
.into_iter()
|
|
.find(|l| pred(l))
|
|
.or_else(|| self.first_leaf())
|
|
}
|
|
|
|
/// Split the first leaf matching `is_target` along `axis`, inserting `new`
|
|
/// as the second child — or as the *first* when `before`, which is what
|
|
/// puts a pane to the left of / above its source rather than right of /
|
|
/// below it. Returns whether a matching leaf was found.
|
|
fn split_leaf_where(
|
|
&mut self,
|
|
is_target: &impl Fn(&L) -> bool,
|
|
axis: Axis,
|
|
before: bool,
|
|
new: L,
|
|
) -> bool {
|
|
match self {
|
|
Pane::Leaf(v) => {
|
|
if is_target(v) {
|
|
let old = Pane::Leaf(v.clone());
|
|
let new = Pane::Leaf(new);
|
|
let (a, b) = if before { (new, old) } else { (old, new) };
|
|
*self = Pane::split_node(axis, 0.5, a, b);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
Pane::Split { a, b, .. } => {
|
|
a.split_leaf_where(is_target, axis, before, new.clone())
|
|
|| b.split_leaf_where(is_target, axis, before, new)
|
|
}
|
|
Pane::Empty => false,
|
|
}
|
|
}
|
|
|
|
/// Replace the first leaf matching `is_target` with `new`, keeping the tree
|
|
/// shape (used for in-place SSH reconnect: the dead pane's slot gets a fresh
|
|
/// connection). Returns whether a match was found.
|
|
fn replace_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool, new: L) -> bool {
|
|
match self {
|
|
Pane::Leaf(v) => {
|
|
if is_target(v) {
|
|
*v = new;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
Pane::Split { a, b, .. } => {
|
|
a.replace_leaf_where(is_target, new.clone()) || b.replace_leaf_where(is_target, new)
|
|
}
|
|
Pane::Empty => false,
|
|
}
|
|
}
|
|
|
|
/// Remove the first leaf matching `is_target` (depth-first, `a` before
|
|
/// `b`), collapsing its parent split into the sibling.
|
|
fn close_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool) -> CloseOutcome {
|
|
match self {
|
|
Pane::Leaf(v) => {
|
|
if is_target(v) {
|
|
CloseOutcome::RemoveSelf
|
|
} else {
|
|
CloseOutcome::NotFound
|
|
}
|
|
}
|
|
Pane::Split { .. } => {
|
|
// Recurse into `a` first (borrow scoped to this block).
|
|
let a_outcome = if let Pane::Split { a, .. } = self {
|
|
a.close_leaf_where(is_target)
|
|
} else {
|
|
unreachable!()
|
|
};
|
|
match a_outcome {
|
|
CloseOutcome::RemoveSelf => {
|
|
// Collapse: replace self with its `b` child.
|
|
if let Pane::Split { b, .. } = std::mem::replace(self, Pane::Empty) {
|
|
*self = *b;
|
|
}
|
|
return CloseOutcome::Collapsed;
|
|
}
|
|
CloseOutcome::Collapsed => return CloseOutcome::Collapsed,
|
|
CloseOutcome::NotFound => {}
|
|
}
|
|
|
|
let b_outcome = if let Pane::Split { b, .. } = self {
|
|
b.close_leaf_where(is_target)
|
|
} else {
|
|
unreachable!()
|
|
};
|
|
match b_outcome {
|
|
CloseOutcome::RemoveSelf => {
|
|
if let Pane::Split { a, .. } = std::mem::replace(self, Pane::Empty) {
|
|
*self = *a;
|
|
}
|
|
CloseOutcome::Collapsed
|
|
}
|
|
other => other,
|
|
}
|
|
}
|
|
Pane::Empty => CloseOutcome::NotFound,
|
|
}
|
|
}
|
|
|
|
/// Push a mutable reference to every leaf payload, depth-first (`a` before
|
|
/// `b`), matching `leaves()` order. Used by `swap_leaf_indices`.
|
|
fn collect_leaves_mut<'a>(&'a mut self, out: &mut Vec<&'a mut L>) {
|
|
match self {
|
|
Pane::Leaf(v) => out.push(v),
|
|
Pane::Split { a, b, .. } => {
|
|
a.collect_leaves_mut(out);
|
|
b.collect_leaves_mut(out);
|
|
}
|
|
Pane::Empty => {}
|
|
}
|
|
}
|
|
|
|
/// Swap the payloads of the leaves at ordered indices `i` and `j` (indices
|
|
/// into `leaves()`), leaving the tree *structure* untouched — only the two
|
|
/// terminals trade places. Returns whether the swap happened (false for
|
|
/// `i == j` or an out-of-range index).
|
|
pub fn swap_leaf_indices(&mut self, i: usize, j: usize) -> bool {
|
|
if i == j {
|
|
return false;
|
|
}
|
|
let mut refs: Vec<&mut L> = Vec::new();
|
|
self.collect_leaves_mut(&mut refs);
|
|
let (lo, hi) = (i.min(j), i.max(j));
|
|
if hi >= refs.len() {
|
|
return false;
|
|
}
|
|
// Split so the two `&mut L` come from disjoint slices — the borrow
|
|
// checker won't let us index the same slice mutably twice.
|
|
let (left, right) = refs.split_at_mut(hi);
|
|
std::mem::swap(&mut *left[lo], &mut *right[0]);
|
|
true
|
|
}
|
|
|
|
/// The normalized rectangle of every leaf within the unit-square tab, in
|
|
/// `leaves()` order. A horizontal split divides width at its ratio (`a` left,
|
|
/// `b` right); a vertical split divides height (`a` top, `b` bottom) — the
|
|
/// same geometry `render` lays out with flex.
|
|
pub fn leaf_rects(&self) -> Vec<(L, Rect)> {
|
|
let mut out = Vec::new();
|
|
self.collect_rects(
|
|
Rect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
w: 1.0,
|
|
h: 1.0,
|
|
},
|
|
&mut out,
|
|
);
|
|
out
|
|
}
|
|
|
|
fn collect_rects(&self, area: Rect, out: &mut Vec<(L, Rect)>) {
|
|
match self {
|
|
Pane::Leaf(v) => out.push((v.clone(), area)),
|
|
Pane::Split {
|
|
axis, a, b, ratio, ..
|
|
} => {
|
|
let r = ratio.get().clamp(MIN_RATIO, MAX_RATIO);
|
|
match axis {
|
|
Axis::Horizontal => {
|
|
let aw = area.w * r;
|
|
a.collect_rects(Rect { w: aw, ..area }, out);
|
|
b.collect_rects(
|
|
Rect {
|
|
x: area.x + aw,
|
|
w: area.w - aw,
|
|
..area
|
|
},
|
|
out,
|
|
);
|
|
}
|
|
Axis::Vertical => {
|
|
let ah = area.h * r;
|
|
a.collect_rects(Rect { h: ah, ..area }, out);
|
|
b.collect_rects(
|
|
Rect {
|
|
y: area.y + ah,
|
|
h: area.h - ah,
|
|
..area
|
|
},
|
|
out,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Pane::Empty => {}
|
|
}
|
|
}
|
|
|
|
/// The ordered index of the pane adjacent to leaf `from` in direction `dir`,
|
|
/// or `None` at the edge. tmux semantics: among panes whose edge sits on the
|
|
/// far side of `from` in that direction and which overlap it on the
|
|
/// perpendicular axis, pick the nearest edge, breaking ties by the largest
|
|
/// overlap.
|
|
pub fn neighbor_in_direction(&self, from: usize, dir: Dir) -> Option<usize> {
|
|
let rects = self.leaf_rects();
|
|
let f = rects.get(from)?.1;
|
|
const EPS: f32 = 1e-4;
|
|
let mut best: Option<(usize, f32, f32)> = None; // (index, edge distance, overlap)
|
|
for (i, (_, c)) in rects.iter().enumerate() {
|
|
if i == from {
|
|
continue;
|
|
}
|
|
let (dist, overlap) = match dir {
|
|
Dir::Left => (f.x - (c.x + c.w), overlap_1d(f.y, f.h, c.y, c.h)),
|
|
Dir::Right => (c.x - (f.x + f.w), overlap_1d(f.y, f.h, c.y, c.h)),
|
|
Dir::Up => (f.y - (c.y + c.h), overlap_1d(f.x, f.w, c.x, c.w)),
|
|
Dir::Down => (c.y - (f.y + f.h), overlap_1d(f.x, f.w, c.x, c.w)),
|
|
};
|
|
// Must lie in the requested direction (distance ≥ 0) and share some
|
|
// perpendicular extent, or it isn't a real neighbor.
|
|
if dist < -EPS || overlap <= EPS {
|
|
continue;
|
|
}
|
|
let better = match best {
|
|
None => true,
|
|
Some((_, bd, bo)) => dist < bd - EPS || (dist <= bd + EPS && overlap > bo + EPS),
|
|
};
|
|
if better {
|
|
best = Some((i, dist, overlap));
|
|
}
|
|
}
|
|
best.map(|(i, _, _)| i)
|
|
}
|
|
|
|
/// Grow or shrink the focused pane along `dir` by `step`, by nudging the
|
|
/// ratio of its nearest enclosing split whose axis matches `dir`. `step`
|
|
/// grows the focused pane when `dir` is Right/Down and shrinks it when
|
|
/// Left/Up, regardless of which side of the split it sits on. Ratios stay
|
|
/// clamped to the legal band. Returns whether a matching split was found.
|
|
/// Takes `&self`: split ratios live in shared `Cell`s, so no `&mut` needed.
|
|
pub fn resize_focused(&self, is_focused: &impl Fn(&L) -> bool, dir: Dir, step: f32) -> bool {
|
|
let mut path: Vec<(&Pane<L>, bool)> = Vec::new();
|
|
if !self.focus_path(is_focused, &mut path) {
|
|
return false;
|
|
}
|
|
let target_axis = dir.axis();
|
|
// Nearest enclosing matching-axis split = deepest entry in the path.
|
|
for (node, went_a) in path.iter().rev() {
|
|
if let Pane::Split { axis, ratio, .. } = node {
|
|
if *axis == target_axis {
|
|
// ratio is `a`'s share; +step enlarges `a`. Growing the
|
|
// focused pane means +step when it's in `a` and we grow, or
|
|
// in `b` and we shrink (== moves the divider toward `b`).
|
|
let delta = if *went_a == dir.grows() { step } else { -step };
|
|
let r = (ratio.get() + delta).clamp(MIN_RATIO, MAX_RATIO);
|
|
ratio.set(r);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Record the path of splits from the root down to the focused leaf, each
|
|
/// tagged with whether the leaf lies in the split's `a` (true) or `b`
|
|
/// (false) child. Returns whether the focused leaf was found.
|
|
fn focus_path<'a>(
|
|
&'a self,
|
|
is_focused: &impl Fn(&L) -> bool,
|
|
path: &mut Vec<(&'a Pane<L>, bool)>,
|
|
) -> bool {
|
|
match self {
|
|
Pane::Leaf(v) => is_focused(v),
|
|
Pane::Split { a, b, .. } => {
|
|
path.push((self, true));
|
|
if a.focus_path(is_focused, path) {
|
|
return true;
|
|
}
|
|
path.pop();
|
|
path.push((self, false));
|
|
if b.focus_path(is_focused, path) {
|
|
return true;
|
|
}
|
|
path.pop();
|
|
false
|
|
}
|
|
Pane::Empty => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Focus- and render-aware operations on the concrete pane tree.
|
|
impl Pane<PaneSlot> {
|
|
/// The currently focused leaf, if any.
|
|
pub fn focused_leaf(&self, window: &Window, cx: &App) -> Option<PaneSlot> {
|
|
match self {
|
|
Pane::Leaf(v) => v.contains_focused(window, cx).then(|| v.clone()),
|
|
Pane::Split { a, b, .. } => a
|
|
.focused_leaf(window, cx)
|
|
.or_else(|| b.focused_leaf(window, cx)),
|
|
Pane::Empty => None,
|
|
}
|
|
}
|
|
|
|
/// The operation target as a *slot*: the focused leaf, or the first leaf if
|
|
/// none is focused. For the operations that work on a pane regardless of
|
|
/// whether its terminal has arrived — focusing it, closing it.
|
|
pub fn focused_or_first_slot(&self, window: &Window, cx: &App) -> Option<PaneSlot> {
|
|
self.focused_leaf(window, cx).or_else(|| self.first_leaf())
|
|
}
|
|
|
|
/// The operation target's *terminal*: the standard "act on the current
|
|
/// pane" selection rule, skipping a slot that has not finished connecting.
|
|
///
|
|
/// Still the name every caller had, because it is still what they meant.
|
|
/// A pane that is mid-connect has no terminal, and every one of these
|
|
/// operations — write input, resize, kill, read the cwd — is a no-op on it
|
|
/// rather than something to queue up and replay.
|
|
pub fn focused_or_first(&self, window: &Window, cx: &App) -> Option<Entity<TerminalView>> {
|
|
self.focused_or_first_slot(window, cx)
|
|
.and_then(|slot| slot.terminal().cloned())
|
|
}
|
|
|
|
/// Every leaf that is a live terminal, in `leaves()` order.
|
|
pub fn terminals(&self) -> Vec<Entity<TerminalView>> {
|
|
self.leaves()
|
|
.iter()
|
|
.filter_map(|slot| slot.terminal().cloned())
|
|
.collect()
|
|
}
|
|
|
|
/// The pane adjacent to the focused one in direction `dir`, matched by
|
|
/// normalized geometry (tmux directional focus). `None` when nothing is
|
|
/// focused or the focused pane is already at that edge.
|
|
pub fn neighbor_in_dir(&self, dir: Dir, window: &Window, cx: &App) -> Option<PaneSlot> {
|
|
let focused = self.focused_leaf(window, cx)?;
|
|
let leaves = self.leaves();
|
|
let from = leaves
|
|
.iter()
|
|
.position(|l| l.entity_id() == focused.entity_id())?;
|
|
let target = self.neighbor_in_direction(from, dir)?;
|
|
leaves.get(target).cloned()
|
|
}
|
|
|
|
/// Resize the focused pane along `dir` by `step` (see the generic
|
|
/// `resize_focused`). Returns whether a matching split was adjusted.
|
|
pub fn resize_focused_pane(&self, dir: Dir, step: f32, window: &Window, cx: &App) -> bool {
|
|
let Some(focused) = self.focused_leaf(window, cx) else {
|
|
return false;
|
|
};
|
|
self.resize_focused(&|v| v.entity_id() == focused.entity_id(), dir, step)
|
|
}
|
|
|
|
/// The ordered index of the focused leaf within `leaves()`, if any. Lets the
|
|
/// shell pick the swap partner (`index ± 1`) without re-walking the tree.
|
|
pub fn focused_index(&self, window: &Window, cx: &App) -> Option<usize> {
|
|
let focused = self.focused_leaf(window, cx)?;
|
|
self.leaves()
|
|
.iter()
|
|
.position(|l| l.entity_id() == focused.entity_id())
|
|
}
|
|
|
|
/// Split a specific leaf (matched by entity identity) along `axis`,
|
|
/// inserting `new` as the second child — or the first when `before`, which
|
|
/// is how "Split Left" / "Split Up" differ from their opposites. The target
|
|
/// must be captured *before* creating `new`, since constructing a terminal
|
|
/// steals window focus.
|
|
pub fn split_leaf(
|
|
&mut self,
|
|
target: gpui::EntityId,
|
|
axis: Axis,
|
|
before: bool,
|
|
new: PaneSlot,
|
|
) -> bool {
|
|
self.split_leaf_where(&|v| v.entity_id() == target, axis, before, new)
|
|
}
|
|
|
|
/// Replace the leaf with entity id `target` with `new`, preserving the tree
|
|
/// shape.
|
|
///
|
|
/// Two callers, and the second is why this takes a bare id rather than a
|
|
/// slot: the in-place SSH reconnect (PRD FR-E4) has the dead pane in hand,
|
|
/// but a pane that finished connecting has only the id of the placeholder
|
|
/// it is replacing — the placeholder entity may already be dropped by the
|
|
/// time the terminal lands.
|
|
pub fn replace_leaf(&mut self, target: gpui::EntityId, new: PaneSlot) -> bool {
|
|
self.replace_leaf_where(&|v| v.entity_id() == target, new)
|
|
}
|
|
|
|
/// Remove the focused leaf, collapsing its parent split into the sibling.
|
|
pub fn close_focused(&mut self, window: &Window, cx: &App) -> CloseOutcome {
|
|
self.close_leaf_where(&|v| v.contains_focused(window, cx))
|
|
}
|
|
|
|
/// Remove a specific leaf (matched by entity identity), collapsing its
|
|
/// parent split into the sibling. Used when a pane closes for a reason
|
|
/// other than user focus — its child exited on its own — so the leaf to
|
|
/// remove is the exited one, wherever focus happens to be.
|
|
pub fn close_leaf(&mut self, target: gpui::EntityId) -> CloseOutcome {
|
|
self.close_leaf_where(&|v| v.entity_id() == target)
|
|
}
|
|
|
|
/// Render the subtree. `dim_inactive` fades every leaf but the focused one;
|
|
/// the caller decides it — it is off for an unsplit tab (nothing to
|
|
/// distinguish) and off when the user turned `dim_inactive_panes` off. Kept
|
|
/// a parameter rather than a `Config` global read here so the tree stays
|
|
/// renderable without one, as the rest of this module is.
|
|
pub fn render(
|
|
&self,
|
|
dim_inactive: bool,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> gpui::AnyElement {
|
|
match self {
|
|
Pane::Empty => div().into_any_element(),
|
|
Pane::Leaf(v) => {
|
|
let focused = v.contains_focused(window, cx);
|
|
// No full border (it reads as a hard rectangle).
|
|
div()
|
|
.size_full()
|
|
.relative()
|
|
.overflow_hidden()
|
|
// Inactive panes fade back so the focused terminal reads as
|
|
// foreground without a hard border. Element opacity multiplies
|
|
// through the whole subtree (terminal glyphs + cell fills),
|
|
// unlike a background-tinted scrim which is near-invisible on a
|
|
// light theme (white on white). Applied to the container, so a
|
|
// click still lands on the terminal and focuses it.
|
|
.when(dim_inactive && !focused, |d| d.opacity(0.55))
|
|
.map(|d| match v {
|
|
PaneSlot::Ready(t) => d.child(t.clone()),
|
|
PaneSlot::Connecting(p) => d.child(p.clone()),
|
|
})
|
|
.into_any_element()
|
|
}
|
|
Pane::Split {
|
|
axis,
|
|
a,
|
|
b,
|
|
ratio,
|
|
dragging,
|
|
} => {
|
|
let row = *axis == Axis::Horizontal;
|
|
// Current ratio for `a`, always within the legal band.
|
|
let r = ratio.get().clamp(MIN_RATIO, MAX_RATIO);
|
|
|
|
let idle = cx.theme().border;
|
|
let active = cx.theme().drag_border;
|
|
|
|
// Per-frame cell carrying the split container's pixel bounds. It
|
|
// is filled by the backing canvas during prepaint and read by
|
|
// the drag listener to convert a pointer position into a ratio.
|
|
// Recreated each frame; only `dragging`/`ratio` persist.
|
|
let container: Rc<Cell<Option<Bounds<Pixels>>>> = Rc::new(Cell::new(None));
|
|
|
|
// Backing canvas: measures the container and installs
|
|
// window-level mouse listeners so a drag keeps tracking even
|
|
// when the pointer outruns the thin divider.
|
|
let backing = canvas(
|
|
{
|
|
let container = container.clone();
|
|
move |bounds, _window, _cx| container.set(Some(bounds))
|
|
},
|
|
{
|
|
let container = container.clone();
|
|
let ratio = ratio.clone();
|
|
let dragging = dragging.clone();
|
|
move |_bounds, _state, window, _cx| {
|
|
// Track the pointer while the divider is held.
|
|
window.on_mouse_event({
|
|
let container = container.clone();
|
|
let ratio = ratio.clone();
|
|
let dragging = dragging.clone();
|
|
move |ev: &MouseMoveEvent, _phase, window, _cx| {
|
|
if !dragging.get() {
|
|
return;
|
|
}
|
|
let Some(b) = container.get() else {
|
|
return;
|
|
};
|
|
// Map the pointer onto a 0..1 ratio along
|
|
// the split axis (Pixels / Pixels -> f32).
|
|
let span = if row { b.size.width } else { b.size.height };
|
|
// A transiently zero-measured container would make
|
|
// the division `NaN`; `f32::clamp` passes `NaN`
|
|
// through (NaN comparisons are false), poisoning the
|
|
// stored ratio and `flex_grow(NaN)`. Skip instead.
|
|
if span.as_f32() <= 0.0 {
|
|
return;
|
|
}
|
|
let offset = if row {
|
|
ev.position.x - b.origin.x
|
|
} else {
|
|
ev.position.y - b.origin.y
|
|
};
|
|
let new_ratio = offset / span;
|
|
ratio.set(new_ratio.clamp(MIN_RATIO, MAX_RATIO));
|
|
window.refresh();
|
|
}
|
|
});
|
|
// End the drag on release — and persist the ratio
|
|
// it landed on. The drag itself only moves the
|
|
// shared cell; without this save the new ratio
|
|
// reached disk (and now the machine's tree) only as
|
|
// a passenger on some later structural change.
|
|
window.on_mouse_event({
|
|
let dragging = dragging.clone();
|
|
move |_ev: &MouseUpEvent, _phase, window, cx| {
|
|
if dragging.get() {
|
|
dragging.set(false);
|
|
if let Some(app) =
|
|
crate::ui::windows::WindowRegistry::app_in(cx, window)
|
|
{
|
|
app.update(cx, |app, cx| app.save_session(cx));
|
|
}
|
|
window.refresh();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
},
|
|
)
|
|
.absolute()
|
|
.size_full();
|
|
|
|
// The draggable divider: a comfortable invisible hit-area holding
|
|
// a centered 1px hairline so the rule reads thin, not as a thick
|
|
// band. The line brightens on hover or while dragging.
|
|
let line_color = if dragging.get() { active } else { idle };
|
|
let divider = div()
|
|
.group("split-divider")
|
|
.flex_none()
|
|
.flex()
|
|
.items_center()
|
|
.justify_center()
|
|
.when(row, |d| {
|
|
d.w(px(DIVIDER_THICKNESS)).h_full().cursor_col_resize()
|
|
})
|
|
.when(!row, |d| {
|
|
d.h(px(DIVIDER_THICKNESS)).w_full().cursor_row_resize()
|
|
})
|
|
.child(
|
|
div()
|
|
.when(row, |d| d.w(px(1.)).h_full())
|
|
.when(!row, |d| d.h(px(1.)).w_full())
|
|
.bg(line_color)
|
|
.group_hover("split-divider", |s| s.bg(active)),
|
|
)
|
|
.on_mouse_down(MouseButton::Left, {
|
|
let dragging = dragging.clone();
|
|
move |_ev, window, _cx| {
|
|
dragging.set(true);
|
|
window.refresh();
|
|
}
|
|
});
|
|
|
|
div()
|
|
.size_full()
|
|
.relative()
|
|
.flex()
|
|
.when(row, |d| d.flex_row())
|
|
.when(!row, |d| d.flex_col())
|
|
// Backing measurer/listener sits behind the children.
|
|
.child(backing)
|
|
.child(
|
|
div()
|
|
.flex_grow(r)
|
|
.flex_shrink(1.)
|
|
.flex_basis(px(0.))
|
|
.min_w_0()
|
|
.min_h_0()
|
|
.child(a.render(dim_inactive, window, cx)),
|
|
)
|
|
.child(divider)
|
|
.child(
|
|
div()
|
|
.flex_grow(1. - r)
|
|
.flex_shrink(1.)
|
|
.flex_basis(px(0.))
|
|
.min_w_0()
|
|
.min_h_0()
|
|
.child(b.render(dim_inactive, window, cx)),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// In tests a leaf is just an id: the tree logic only ever clones leaves
|
|
/// and asks a predicate whether one is the operation target.
|
|
type TestPane = Pane<u32>;
|
|
|
|
/// Predicate matching the leaf with the given id (the test stand-in for
|
|
/// "is this the focused terminal" / "is this the split target").
|
|
fn is(id: u32) -> impl Fn(&u32) -> bool {
|
|
move |v| *v == id
|
|
}
|
|
|
|
/// Walk the tree asserting the structural invariants the live UI relies
|
|
/// on: no transient `Empty` placeholder survives an operation, every
|
|
/// split has two real children, and every stored ratio stays inside the
|
|
/// legal band.
|
|
fn assert_well_formed(pane: &TestPane) {
|
|
match pane {
|
|
Pane::Leaf(_) => {}
|
|
Pane::Split { a, b, ratio, .. } => {
|
|
let r = ratio.get();
|
|
assert!(
|
|
(MIN_RATIO..=MAX_RATIO).contains(&r),
|
|
"split ratio {r} escaped the legal band"
|
|
);
|
|
assert!(!matches!(**a, Pane::Empty), "split kept an Empty `a` child");
|
|
assert!(!matches!(**b, Pane::Empty), "split kept an Empty `b` child");
|
|
assert_well_formed(a);
|
|
assert_well_formed(b);
|
|
}
|
|
Pane::Empty => panic!("Empty node left in a live tree"),
|
|
}
|
|
}
|
|
|
|
/// Split leaf `target`, inserting `new` as its second sibling, asserting
|
|
/// the target was found.
|
|
fn split(pane: &mut TestPane, target: u32, axis: Axis, new: u32) {
|
|
assert!(
|
|
pane.split_leaf_where(&is(target), axis, false, new),
|
|
"split target {target} not found"
|
|
);
|
|
}
|
|
|
|
// Splitting a lone leaf must turn it into a split on the requested axis,
|
|
// with the original terminal kept first and an even 50/50 ratio.
|
|
#[test]
|
|
fn split_leaf_replaces_target_with_split_keeping_original_first() {
|
|
let mut pane = TestPane::leaf(0);
|
|
assert!(pane.split_leaf_where(&is(0), Axis::Horizontal, false, 1));
|
|
match &pane {
|
|
Pane::Split {
|
|
axis, a, b, ratio, ..
|
|
} => {
|
|
assert!(matches!(axis, Axis::Horizontal));
|
|
assert_eq!(ratio.get(), 0.5);
|
|
assert!(matches!(**a, Pane::Leaf(0)));
|
|
assert!(matches!(**b, Pane::Leaf(1)));
|
|
}
|
|
_ => panic!("split_leaf should replace the leaf with a Split node"),
|
|
}
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// `before` is what makes "Split Left" / "Split Up" differ from their
|
|
// opposites: same axis, the new pane just takes the first slot. Only the
|
|
// targeted leaf moves — its siblings keep their order.
|
|
#[test]
|
|
fn split_leaf_before_puts_the_new_pane_first() {
|
|
// [0 | 1] -> split 1 horizontally with 2, before -> [0 | [2 | 1]]
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
assert!(pane.split_leaf_where(&is(1), Axis::Horizontal, true, 2));
|
|
assert_eq!(pane.leaves(), vec![0, 2, 1]);
|
|
match &pane {
|
|
Pane::Split { a, b, .. } => {
|
|
assert!(matches!(**a, Pane::Leaf(0)), "sibling must not move");
|
|
match &**b {
|
|
Pane::Split { a, b, .. } => {
|
|
assert!(matches!(**a, Pane::Leaf(2)));
|
|
assert!(matches!(**b, Pane::Leaf(1)));
|
|
}
|
|
_ => panic!("targeted leaf should have become a nested split"),
|
|
}
|
|
}
|
|
_ => panic!("root should still be the original horizontal split"),
|
|
}
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// A split must land on exactly the targeted leaf, leaving every other
|
|
// subtree untouched (guards against splitting the first leaf found).
|
|
#[test]
|
|
fn split_leaf_splits_only_the_matching_leaf() {
|
|
// [0 | 1] -> split 1 vertically with 2 -> [0 | [1 / 2]]
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
split(&mut pane, 1, Axis::Vertical, 2);
|
|
|
|
match &pane {
|
|
Pane::Split { axis, a, b, .. } => {
|
|
assert!(matches!(axis, Axis::Horizontal));
|
|
assert!(
|
|
matches!(**a, Pane::Leaf(0)),
|
|
"untargeted leaf must stay a leaf"
|
|
);
|
|
match &**b {
|
|
Pane::Split { axis, a, b, .. } => {
|
|
assert!(matches!(axis, Axis::Vertical));
|
|
assert!(matches!(**a, Pane::Leaf(1)));
|
|
assert!(matches!(**b, Pane::Leaf(2)));
|
|
}
|
|
_ => panic!("targeted leaf should have become a nested split"),
|
|
}
|
|
}
|
|
_ => panic!("root should still be the original horizontal split"),
|
|
}
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// A split aimed at a leaf that is not in the tree must report failure and
|
|
// leave the tree exactly as it was.
|
|
#[test]
|
|
fn split_leaf_reports_missing_target_without_changing_tree() {
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
assert!(!pane.split_leaf_where(&is(99), Axis::Vertical, false, 2));
|
|
assert_eq!(pane.leaves(), vec![0, 1]);
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// Ratios restored from a saved session may be out of range; split_node
|
|
// must clamp them into the legal band so both panes stay usable.
|
|
#[test]
|
|
fn split_node_clamps_restored_ratio_into_legal_band() {
|
|
for (given, expected) in [
|
|
(0.0, MIN_RATIO),
|
|
(-1.0, MIN_RATIO),
|
|
(1.0, MAX_RATIO),
|
|
(7.5, MAX_RATIO),
|
|
(0.3, 0.3),
|
|
] {
|
|
let node = TestPane::split_node(Axis::Vertical, given, Pane::Leaf(1), Pane::Leaf(2));
|
|
match &node {
|
|
Pane::Split { ratio, .. } => assert_eq!(ratio.get(), expected),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Leaf traversal drives pane cycling and session persistence: it must be
|
|
// depth-first with `a` before `b`, and first_leaf must agree with it.
|
|
#[test]
|
|
fn leaves_and_first_leaf_follow_depth_first_a_before_b_order() {
|
|
// [[0 / 3] | [1 / 2]]
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
split(&mut pane, 1, Axis::Vertical, 2);
|
|
split(&mut pane, 0, Axis::Vertical, 3);
|
|
assert_eq!(pane.leaves(), vec![0, 3, 1, 2]);
|
|
assert_eq!(pane.first_leaf(), Some(0));
|
|
}
|
|
|
|
// Restoring a tab's remembered pane: `leaf_matching_or_first` returns the
|
|
// matched leaf when present, and degrades to the first leaf when the
|
|
// remembered pane has closed (predicate matches nothing). This is the pure
|
|
// core of the "switching tabs keeps the active pane" fix.
|
|
#[test]
|
|
fn leaf_matching_or_first_prefers_the_match_then_falls_back_to_first() {
|
|
// [0 | [1 / 2]] → leaves = [0, 1, 2]
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
split(&mut pane, 1, Axis::Vertical, 2);
|
|
assert_eq!(pane.leaves(), vec![0, 1, 2]);
|
|
|
|
// A remembered pane that still exists is restored (not the first leaf).
|
|
assert_eq!(pane.leaf_matching_or_first(is(2)), Some(2));
|
|
assert_eq!(pane.leaf_matching_or_first(is(1)), Some(1));
|
|
// A remembered pane that has since closed falls back to the first leaf.
|
|
assert_eq!(pane.leaf_matching_or_first(is(99)), Some(0));
|
|
// On an empty tree there is nothing to restore or fall back to.
|
|
assert_eq!(TestPane::Empty.leaf_matching_or_first(is(0)), None);
|
|
}
|
|
|
|
// Closing the tab's only pane must not mutate the tree; the caller reacts
|
|
// to RemoveSelf by closing the whole tab.
|
|
#[test]
|
|
fn closing_the_root_leaf_defers_removal_to_the_caller() {
|
|
let mut pane = TestPane::leaf(7);
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(7)),
|
|
CloseOutcome::RemoveSelf
|
|
));
|
|
assert!(matches!(pane, Pane::Leaf(7)));
|
|
}
|
|
|
|
// Closing the first child of a split must promote the second child to
|
|
// take the split's place, leaving no Empty placeholder behind.
|
|
#[test]
|
|
fn closing_first_child_promotes_second_child_to_root() {
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(0)),
|
|
CloseOutcome::Collapsed
|
|
));
|
|
assert!(matches!(pane, Pane::Leaf(1)));
|
|
}
|
|
|
|
// Same as above, mirrored: closing the second child promotes the first.
|
|
#[test]
|
|
fn closing_second_child_promotes_first_child_to_root() {
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(1)),
|
|
CloseOutcome::Collapsed
|
|
));
|
|
assert!(matches!(pane, Pane::Leaf(0)));
|
|
}
|
|
|
|
// Closing a nested leaf must collapse only its own parent split; the
|
|
// grandparent keeps its axis and (dragged) ratio.
|
|
#[test]
|
|
fn closing_nested_leaf_collapses_only_its_parent_split() {
|
|
// [1 |(0.3) [2 / 3]] -> close 2 -> [1 |(0.3) 3]
|
|
let mut pane = TestPane::split_node(
|
|
Axis::Horizontal,
|
|
0.3,
|
|
Pane::Leaf(1),
|
|
Pane::split_node(Axis::Vertical, 0.7, Pane::Leaf(2), Pane::Leaf(3)),
|
|
);
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(2)),
|
|
CloseOutcome::Collapsed
|
|
));
|
|
match &pane {
|
|
Pane::Split {
|
|
axis, a, b, ratio, ..
|
|
} => {
|
|
assert!(matches!(axis, Axis::Horizontal));
|
|
assert_eq!(
|
|
ratio.get(),
|
|
0.3,
|
|
"outer split ratio must survive the collapse"
|
|
);
|
|
assert!(matches!(**a, Pane::Leaf(1)));
|
|
assert!(matches!(**b, Pane::Leaf(3)));
|
|
}
|
|
_ => panic!("outer split must survive an inner collapse"),
|
|
}
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// When the surviving sibling is itself a split, the whole subtree must be
|
|
// promoted intact, keeping its axis and ratio.
|
|
#[test]
|
|
fn closing_a_leaf_promotes_entire_sibling_subtree() {
|
|
// [[1 /(0.7) 2] | 3] -> close 3 -> [1 /(0.7) 2]
|
|
let mut pane = TestPane::split_node(
|
|
Axis::Horizontal,
|
|
0.5,
|
|
Pane::split_node(Axis::Vertical, 0.7, Pane::Leaf(1), Pane::Leaf(2)),
|
|
Pane::Leaf(3),
|
|
);
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(3)),
|
|
CloseOutcome::Collapsed
|
|
));
|
|
match &pane {
|
|
Pane::Split {
|
|
axis, a, b, ratio, ..
|
|
} => {
|
|
assert!(matches!(axis, Axis::Vertical));
|
|
assert_eq!(ratio.get(), 0.7, "promoted subtree must keep its own ratio");
|
|
assert!(matches!(**a, Pane::Leaf(1)));
|
|
assert!(matches!(**b, Pane::Leaf(2)));
|
|
}
|
|
_ => panic!("sibling subtree should have been promoted to the root"),
|
|
}
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// With no focused/matching leaf anywhere, close must be a no-op reporting
|
|
// NotFound (e.g. focus is in another tab).
|
|
#[test]
|
|
fn close_reports_not_found_and_leaves_tree_untouched() {
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(99)),
|
|
CloseOutcome::NotFound
|
|
));
|
|
assert_eq!(pane.leaves(), vec![0, 1]);
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// Even if the predicate matches several leaves, exactly one close happens:
|
|
// the first match in `a`-before-`b` order (guards the short-circuit).
|
|
#[test]
|
|
fn close_removes_only_first_match_in_traversal_order() {
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
split(&mut pane, 1, Axis::Vertical, 2);
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&|_| true),
|
|
CloseOutcome::Collapsed
|
|
));
|
|
assert_eq!(pane.leaves(), vec![1, 2]);
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
// Drive a deep nested split/close sequence against a flat model of the
|
|
// expected leaf order; after every step the tree must stay well-formed
|
|
// and agree with the model. (A split inserts the new leaf right after its
|
|
// target; a close removes exactly its target.)
|
|
#[test]
|
|
fn deep_split_close_sequence_preserves_invariants_and_leaf_order() {
|
|
enum Op {
|
|
Split(u32, Axis, u32),
|
|
Close(u32),
|
|
}
|
|
use Op::*;
|
|
let script = [
|
|
Split(0, Axis::Horizontal, 1),
|
|
Split(1, Axis::Vertical, 2),
|
|
Split(0, Axis::Vertical, 3),
|
|
Split(2, Axis::Horizontal, 4),
|
|
Split(3, Axis::Horizontal, 5),
|
|
Close(1),
|
|
Close(0),
|
|
Close(4),
|
|
Split(2, Axis::Vertical, 6),
|
|
Close(5),
|
|
Close(3),
|
|
Close(6),
|
|
];
|
|
|
|
let mut pane = TestPane::leaf(0);
|
|
let mut model = vec![0u32];
|
|
for op in script {
|
|
match op {
|
|
Split(target, axis, new) => {
|
|
split(&mut pane, target, axis, new);
|
|
let at = model.iter().position(|&v| v == target).unwrap();
|
|
model.insert(at + 1, new);
|
|
}
|
|
Close(target) => {
|
|
assert!(
|
|
matches!(pane.close_leaf_where(&is(target)), CloseOutcome::Collapsed),
|
|
"closing {target} should collapse a split"
|
|
);
|
|
model.retain(|&v| v != target);
|
|
}
|
|
}
|
|
assert_well_formed(&pane);
|
|
assert_eq!(pane.leaves(), model, "tree leaves diverged from the model");
|
|
}
|
|
}
|
|
|
|
// Closing panes one by one must collapse down to a single leaf, and only
|
|
// the very last close switches to RemoveSelf (close-the-tab boundary).
|
|
#[test]
|
|
fn closing_down_to_the_last_pane_hits_remove_self_boundary() {
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
split(&mut pane, 1, Axis::Vertical, 2);
|
|
split(&mut pane, 0, Axis::Vertical, 3);
|
|
|
|
while pane.leaves().len() > 1 {
|
|
let target = pane.first_leaf().unwrap();
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(target)),
|
|
CloseOutcome::Collapsed
|
|
));
|
|
assert_well_formed(&pane);
|
|
}
|
|
|
|
let last = pane.first_leaf().unwrap();
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(last)),
|
|
CloseOutcome::RemoveSelf
|
|
));
|
|
assert!(
|
|
matches!(pane, Pane::Leaf(_)),
|
|
"last pane is dropped by the caller, not the tree"
|
|
);
|
|
}
|
|
|
|
// The transient Empty placeholder (also used for the settings tab) must
|
|
// ignore every operation instead of panicking.
|
|
#[test]
|
|
fn empty_placeholder_ignores_all_operations() {
|
|
let mut pane: TestPane = Pane::Empty;
|
|
assert!(pane.leaves().is_empty());
|
|
assert_eq!(pane.first_leaf(), None);
|
|
assert!(!pane.split_leaf_where(&is(0), Axis::Horizontal, false, 1));
|
|
assert!(matches!(
|
|
pane.close_leaf_where(&is(0)),
|
|
CloseOutcome::NotFound
|
|
));
|
|
assert!(matches!(pane, Pane::Empty));
|
|
}
|
|
|
|
/// The rect for leaf `id` in a pane, by value.
|
|
fn rect_of(pane: &TestPane, id: u32) -> Rect {
|
|
pane.leaf_rects()
|
|
.into_iter()
|
|
.find(|(v, _)| *v == id)
|
|
.map(|(_, r)| r)
|
|
.unwrap()
|
|
}
|
|
|
|
/// Assert two rects match within floating-point tolerance (ratios multiply
|
|
/// out to values like 0.39999998, so exact equality is too strict).
|
|
fn assert_rect(got: Rect, want: Rect) {
|
|
let close = |a: f32, b: f32| (a - b).abs() < 1e-5;
|
|
assert!(
|
|
close(got.x, want.x)
|
|
&& close(got.y, want.y)
|
|
&& close(got.w, want.w)
|
|
&& close(got.h, want.h),
|
|
"rect {got:?} != {want:?}"
|
|
);
|
|
}
|
|
|
|
// Nested splits with non-even ratios must tile the unit square exactly:
|
|
// a horizontal split divides width, a nested vertical split divides its
|
|
// child's height, and the pieces stay gap-free and non-overlapping.
|
|
#[test]
|
|
fn leaf_rects_tile_the_unit_square_with_nested_ratios() {
|
|
// [0 |(0.25) [1 /(0.6) 2]]
|
|
let pane = TestPane::split_node(
|
|
Axis::Horizontal,
|
|
0.25,
|
|
Pane::Leaf(0),
|
|
TestPane::split_node(Axis::Vertical, 0.6, Pane::Leaf(1), Pane::Leaf(2)),
|
|
);
|
|
assert_rect(
|
|
rect_of(&pane, 0),
|
|
Rect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
w: 0.25,
|
|
h: 1.0,
|
|
},
|
|
);
|
|
assert_rect(
|
|
rect_of(&pane, 1),
|
|
Rect {
|
|
x: 0.25,
|
|
y: 0.0,
|
|
w: 0.75,
|
|
h: 0.6,
|
|
},
|
|
);
|
|
assert_rect(
|
|
rect_of(&pane, 2),
|
|
Rect {
|
|
x: 0.25,
|
|
y: 0.6,
|
|
w: 0.75,
|
|
h: 0.4,
|
|
},
|
|
);
|
|
// Rects come back in leaves() order.
|
|
assert_eq!(
|
|
pane.leaf_rects()
|
|
.iter()
|
|
.map(|(v, _)| *v)
|
|
.collect::<Vec<_>>(),
|
|
pane.leaves()
|
|
);
|
|
}
|
|
|
|
// Directional focus is edge-adjacency: right of 0 is 1, and from 1 the pane
|
|
// to the left is 0. A pane with no neighbor in a direction returns None.
|
|
#[test]
|
|
fn neighbor_in_direction_finds_the_adjacent_pane() {
|
|
// [0 | 1]
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
let idx = |id: u32| pane.leaves().iter().position(|v| *v == id).unwrap();
|
|
assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Right), Some(idx(1)));
|
|
assert_eq!(pane.neighbor_in_direction(idx(1), Dir::Left), Some(idx(0)));
|
|
// Nothing above/below in a purely horizontal split.
|
|
assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Up), None);
|
|
assert_eq!(pane.neighbor_in_direction(idx(1), Dir::Right), None);
|
|
}
|
|
|
|
// When several panes sit in the requested direction, the one with the
|
|
// largest perpendicular overlap wins (tmux's "line up with the cursor").
|
|
#[test]
|
|
fn neighbor_in_direction_prefers_the_largest_overlap() {
|
|
// Left column is 0 (full height); right column is stacked [1 /(0.7) 2].
|
|
// Moving right from 0 should land on 1 — it covers 70% of the shared
|
|
// edge versus 2's 30%.
|
|
let pane = TestPane::split_node(
|
|
Axis::Horizontal,
|
|
0.5,
|
|
Pane::Leaf(0),
|
|
TestPane::split_node(Axis::Vertical, 0.7, Pane::Leaf(1), Pane::Leaf(2)),
|
|
);
|
|
let idx = |id: u32| pane.leaves().iter().position(|v| *v == id).unwrap();
|
|
assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Right), Some(idx(1)));
|
|
}
|
|
|
|
// Resize nudges the nearest matching-axis ancestor's ratio and always grows
|
|
// the focused pane on Right/Down, whichever side it's on.
|
|
#[test]
|
|
fn resize_grows_the_focused_pane_from_either_side() {
|
|
let build = || TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1));
|
|
let ratio = |p: &TestPane| match p {
|
|
Pane::Split { ratio, .. } => ratio.get(),
|
|
_ => unreachable!(),
|
|
};
|
|
// Focus in `a` (left): Right grows a → ratio up.
|
|
let p = build();
|
|
assert!(p.resize_focused(&is(0), Dir::Right, 0.05));
|
|
assert!((ratio(&p) - 0.55).abs() < 1e-6);
|
|
// Focus in `b` (right): Right grows b → ratio down.
|
|
let p = build();
|
|
assert!(p.resize_focused(&is(1), Dir::Right, 0.05));
|
|
assert!((ratio(&p) - 0.45).abs() < 1e-6);
|
|
// Left shrinks the focused pane (focus in a → ratio down).
|
|
let p = build();
|
|
assert!(p.resize_focused(&is(0), Dir::Left, 0.05));
|
|
assert!((ratio(&p) - 0.45).abs() < 1e-6);
|
|
}
|
|
|
|
// A resize whose axis matches no ancestor split is a no-op: a purely
|
|
// horizontal split has no vertical divider to move.
|
|
#[test]
|
|
fn resize_without_a_matching_axis_is_a_noop() {
|
|
let pane = TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1));
|
|
assert!(!pane.resize_focused(&is(0), Dir::Up, 0.05));
|
|
assert!(!pane.resize_focused(&is(0), Dir::Down, 0.05));
|
|
// An unfocused/absent target also reports no-op.
|
|
assert!(!pane.resize_focused(&is(99), Dir::Right, 0.05));
|
|
}
|
|
|
|
// Resize with a nested tree targets the *nearest* enclosing matching-axis
|
|
// split, not an outer one of the same axis.
|
|
#[test]
|
|
fn resize_targets_the_nearest_matching_axis_ancestor() {
|
|
// [0 |(0.5) [1 |(0.5) 2]] — two nested horizontal splits.
|
|
let pane = TestPane::split_node(
|
|
Axis::Horizontal,
|
|
0.5,
|
|
Pane::Leaf(0),
|
|
TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(1), Pane::Leaf(2)),
|
|
);
|
|
assert!(pane.resize_focused(&is(1), Dir::Right, 0.05));
|
|
// Inner split moved; outer untouched.
|
|
match &pane {
|
|
Pane::Split { ratio, b, .. } => {
|
|
assert!(
|
|
(ratio.get() - 0.5).abs() < 1e-6,
|
|
"outer split must not move"
|
|
);
|
|
match &**b {
|
|
Pane::Split { ratio, .. } => {
|
|
assert!(
|
|
(ratio.get() - 0.55).abs() < 1e-6,
|
|
"inner split should grow 1"
|
|
);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
|
|
// Swapping two leaves trades their payloads but keeps the tree shape and
|
|
// leaf *positions* — only the values at those positions change.
|
|
#[test]
|
|
fn swap_leaf_indices_trades_payloads_in_place() {
|
|
// [[0 / 3] | [1 / 2]] → leaves = [0, 3, 1, 2]
|
|
let mut pane = TestPane::leaf(0);
|
|
split(&mut pane, 0, Axis::Horizontal, 1);
|
|
split(&mut pane, 1, Axis::Vertical, 2);
|
|
split(&mut pane, 0, Axis::Vertical, 3);
|
|
assert_eq!(pane.leaves(), vec![0, 3, 1, 2]);
|
|
// Swap positions 0 and 2 (values 0 and 1).
|
|
assert!(pane.swap_leaf_indices(0, 2));
|
|
assert_eq!(pane.leaves(), vec![1, 3, 0, 2]);
|
|
assert_well_formed(&pane);
|
|
// No-op cases.
|
|
assert!(!pane.swap_leaf_indices(1, 1));
|
|
assert!(!pane.swap_leaf_indices(0, 99));
|
|
assert_eq!(pane.leaves(), vec![1, 3, 0, 2]);
|
|
}
|
|
}
|