Refs #738.
Keys the origin memory by the pane a move lands on as well as the direction: the per-direction array meant a two-step walk clobbered the first step, so Left, Left, Right, Right ended in the wrong pane. Entries are pruned on write when either side names a pane the tab no longer holds.
Refs #751.
The PR body's rationale is stale as of #788: build_font now emits calt:0 liga:0 clig:0 and gpui's DirectWrite backend zeroes all three, so ligatures are off by default on Windows and the Calibri office/waffle repro no longer fires with default settings. This is a fix for users who opt ligatures back on, and for any face on any platform that collapses glyph count.
Reconciled onto #783: the fit flag is gone, so seg_budget now takes ink_covers_segment and the shaping moved above the budget.
Refs #750; only half fixed — the US SHIFTED_GLYPHS table means secondary-shift-]/[ stay unpressable on German/French/Nordic layouts. A layout-correct fold needs KeyBinding::load with the real keyboard mapper.
Also runs the control-code guard over the folded spelling, so ctrl-shift-2 no longer installs ctrl-@ (NUL) beside it.
Refs #710; does not close it (the request was a jump list opening a chosen existing workspace).
Registers NewWindow globally as well as on the render root, since with the tray icon on (the default) closing the last window retires to the tray and leaves no window to dispatch it.
Refs #721.
Reconciled onto the virtualised row list from #799: the selection is keyed on RowAt { path, RowId } rather than the flat list index, since collapsing a file above the selection re-points flat indices.
26.9.0's tray-retire model made closing the last window the "keep the daemon,
drop the UI process weight" gesture, but that path was reachable only from the
OS red close button: no action, no palette entry, nothing to bind. `⌘W` closes
a pane or tab, `⌘H` hides the window but keeps it in memory, and `⌘Q` stops the
server. There was nothing that closed the window and left the shells running.
`CloseWindow` is that action, with no default key — the slot is left free.
The logic that decided what a window close means (detach the workspace, and
on the last window retire to the tray if an icon is actually up, otherwise
quit) moves out of `on_window_should_close` into `prepare_window_close`, so
the button and the action share one decision instead of two that can drift.
Notably not routed through `close_window_for`: that one recycles the last
window onto a fresh workspace, which is what deleting a workspace wants and
not what closing a window wants.
It reaches the command palette, the Keybindings UI, and the reference table
of actions with no default key. In the palette it sits beside Quit, because
that pair is the whole point of the action: both end the window in front of
you and only one takes your shells with it. Their subtitles now say which —
including Quit's, which had been promising "shells keep running" while
calling `daemon::spawn::stop()`.
Co-authored-by: bytehello <bytehello@users.noreply.github.com>
https://claude.ai/code/session_01LKMZVh6mUBxXAn6v7P6JC6
The overlay built its whole patch as a nested element tree on every frame:
a card per file, a header per hunk, six elements per line. gpui notifies the
view on each scroll wheel event, so a few hundred lines of diff rebuilt tens
of thousands of elements tens of times a second, and the window stalled.
Flatten the tree into one row per line in a new `diff_list` module and draw it
with `gpui::list`, which builds only the rows on screen. The rows are rebuilt
only when what they are built from changes, so scrolling no longer re-splits
hunks or re-clones every line, and a change to one file splices just the rows
it touched rather than resetting the list and losing the scroll position.
The key that decides a rebuild takes the snapshot each frame was asked about
even when it matched only by contents. A probe that finds nothing new still
lands a fresh `Arc` over an equal snapshot; a key left pointing at the old one
would go on walking the whole patch to prove the two equal, once per wheel
event, which is the cost the key exists to avoid.
A list counts a row it has not laid out yet as zero tall, which left the
scrollbar reading an 800-line patch as one viewport: its thumb filled the
track, and a drag from top to bottom travelled 248px and stopped. The rows
below the fold are counted at the 19px both views already give a line of a
patch, through `ListState::with_size_hint` — added to the gpui fork for this,
`Cargo.lock` following its `tty7` branch to `ece710e3`.
A card cannot survive that flattening — its rows are separate items now — so
the frame it drew is gone, and with it the grey header bars and hunk bands
that made the overlay the one view in the app still speaking gpui-component's
default container language. The rows take the source control panel's own
measurements instead: 26px, 10px inset, 5px radius, colour only under the
pointer. The title bar's view switch loses its border for the same reason.
Reverting the projects layer took CONTROL_VERSION back to 7 with the
verbs it had been raised for. The dialect that number describes is
correct — this build speaks v7's messages again, one for one — but the
number is not, because v8 is already deployed.
A version that moves backwards stops being an identity. A 7 on the wire
would mean "before projects" or "after them" depending on which build
sent it, and the handshake has nothing but the number to tell those
apart: a v8 peer would read our 7 as an older server it may keep
talking to, and every project verb it pushed would fail to decode and
drop the link.
v9 is a number no peer has seen, so a v8 peer is turned away at the
handshake instead. Nothing else changes; every other reference to the
constant is relative to it.
Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
Reverts cebd871c. The declared layer sat beside the derived repo groups
rather than replacing them, so the sidebar carried two kinds of heading
that look alike and behave differently, plus the verbs to create, rename,
re-root, reorder and delete one. That is more interface than the problem
was worth, and the derived grouping already covers the common case.
Everything the commit touched goes back: the Project entity on the
machine, the per-tab reference, the six control verbs and their layout
deltas, the sidebar rail and tab strip surfaces, the mirror and tree-sync
paths, and the i18n strings.
CONTROL_VERSION drops back to 7 with the verbs it was raised for. A
remote box still serving the v8 build will be turned away at the
handshake until its tty7-server is replaced.
Sessions written while projects existed still load: `projects` and a
tab's `project` are unknown fields now, and serde drops them.
Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
A daemon that died without unlinking its Unix socket stopped every later
daemon on that machine from ever starting. The client launched one, it
exited on the bind, the client launched another, forever.
run_with does clear a stale endpoint, but only after a probe it skipped
whenever the recorded daemon was known dead — reasoning that the bind below
would overwrite the file. That is true of a Windows port file, and the whole
of #639, where the skip came from, is Windows ports: it added a connect
timeout so a firewalled stale port fails fast. On Unix the endpoint is a
socket file and bind refuses any path that exists, so the one path that
reached the bind with a leftover still there was the one that skipped its
removal.
What decides the removal is now the single-server seat, not the pidfile.
Holding it means nobody else can be serving this config dir, so anything
still at the endpoint belongs to a process that is gone — safe by
construction, which is the property `singleton` exists to provide. Removing
on one failed connect instead is the race that module was written to retire,
so it is used only where there is no seat, and even there a socket that
answers is refused rather than removed.
Reproduced on a Linux box: with a socket nothing was behind and a pidfile
naming a dead pid, main exits 1 on the bind and this starts and serves.
Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
A remote workspace could sit in a loop nobody could get out of: every
reconnect failed with "started but nothing was answering on the control
socket after 15s", the strip showed a copy bar frozen at 100%, and no
button was offered.
The daemon is the root of it. When its control listener would not open it
logged one line and kept running — and a running daemon holds the
single-server lock, so every later --daemon stood down at once and every
client probe failed, forever. Whether something else is serving cannot be
read off the errno: bind_control_socket clears the leftovers it can, but a
path it cannot clear comes back AddrInUse in the same words a live server
does. Ask by connecting, and exit when nothing answers.
The reason was thrown away twice over: the daemon's stdout and stderr went
to /dev/null, and the readiness probe kept only out.success(). Both are
kept now — output and exit status land beside the binary, stamped with the
launch's own nonce so a restart never reads the outgoing daemon's status as
the incoming one's. A start that has already failed no longer waits out the
full timeout.
The UI half: an automatic reconnect never retired its install progress, and
a leftover entry draws an install in flight instead of the failure and its
button. And a long error stretched the status card to 1978px in a 1440px
window, taking the retry button off the screen with it.
Closes part of #774. The Vim :wq cursor and the btop re-attach items in that
issue are not touched.
Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
* feat(sidebar): add projects as a declared layer beside the derived groups
The sidebar's repo groups are derived: a group's identity is a path
recomputed every frame from a leaf's cwd, it appears when a tab lands in
it and vanishes with its last tab. That layer cannot carry a name of its
own, cannot be created before a tab is opened in it, and orphans anything
keyed to it when a directory is renamed or moved.
Add a Project as a real entity on the workspace — an id, an optional
name, a root — and an optional reference to one on each tab. Nothing
probes it: a tab joins a project only by an explicit action, and a tab
that leaves one lands back in the group the probe would have put it in,
so declaration and inference never disagree and no third membership
state is needed.
The derived grouping, the cwd probe, the write-back and the
SidebarGrouping config are untouched; the only difference is the tab list
they are fed. A server that predates the projects feature ignores the new
array and serves today's sidebar.
Closes#756
* fix(sidebar): stop a searched row claiming a chord it does not own
A live search deliberately ignores a folded heading — the query is asking
about tabs — so the rail draws rows the chord order has taken out. The badge
was read from a `Vec<usize>` that started at zero, so every one of those rows
claimed ⌘1 while ⌘1 opened something else. It is `Option<usize>` now, built by
`badge_positions` off the same order `activate_visual` walks, and a row the
order left out wears no badge at all.
Also in the rail: the block loop reads "declared" off the section key rather
than the position it happens to sit at, and an unreachable `continue` for a
folded empty derived block is gone — `sidebar_sections` never makes one.
Projects:
- `MAX_PROJECTS` is held on the window side too. The machine refuses past it
and a refusal resynchronizes, which would re-push the project this window
kept and be refused again. Checked before the folder panel opens, so a full
workspace says so before asking for a folder rather than after.
- `set_project_root` keeps the one-project-per-directory rule `declare_project`
holds on the way in; pointing one project at another's folder reached the
two-headers-that-mean-the-same-thing state by the back door.
- Opening a rename box over one already on another project commits it instead
of dropping it with its subscription, which threw the typing away.
Sync:
- Project reordering moves after `retire_projects`. `to` indexes the machine's
whole list, so a project on its way out pushed the survivors along and spelled
a move for one already in place.
- `adopt_projects` reports whether it changed anything and the callers repaint
when it did; it was mutating the window's list with nothing to notify.
- `migrate_panes` gets its doc comment back — `reconcile_projects` had been
inserted between it and the comment describing it.
Dead `L10nKey::ProjectNew` removed: translated four times, used nowhere.
* fix(control): move the dialect to v8 for the project verbs
`CONTROL_VERSION`'s own doc says to move it whenever a variant is added to
`ControlRequest`, `ReplyOk` or `ControlEvent`, and says why the feature strings
are not a substitute: they cover what a peer can safely ignore — a field added
to a message it already decodes — while a variant it has never heard of fails
to decode and takes the whole link down with it.
The project verbs shipped behind a `projects` feature string instead. That
gates what a client *sends*, so a v7 server never saw a verb it could not read,
but nothing gates what a server *pushes*: a v7 client meeting a v8 server that
had grown a project would take the `ProjectCreated` delta, fail to decode the
frame, and lose the link — `read_until_closed` calls `fail_all` on any decode
error. Only the number can turn that pairing away at the handshake.
So the number moves and the feature goes. It was redundant even for the
direction it did cover: `MACHINE_TREE` and `PROJECTS` were pushed under the
same `services.machine.is_some()`, so within one build they were always equal
and only a cross-version pairing could tell them apart — which is exactly what
v8 now refuses at the handshake. Keeping both would be two mechanisms for one
job, and the weaker one silently covering half the problem.
Removed with it: `is_project_op` and the `pump` filter it fed.
Disk compatibility is a separate axis and is untouched — `Workspace::projects`
and `Tab::project` keep their `serde(default)`, and the test that reads a tree
written before either still passes.
Remote workspaces need their `tty7-server` pushed before they will connect.
That is the dialect-refusal path v7 was minted to make reachable: the parked
strip and its Update Server button.
Also: the two sidebar `+` buttons now fade in on their own heading's hover
rather than the whole rail's, so a control appears where the pointer is.
One hairline value served every line in the app: the outline that closes a
menu, tooltip or card floating over other content, the rule under a header, and
the seam where the sidebar meets the terminal. Those are not the same job. The
first two are the only thing saying where an edge is; the last runs between two
panes that already carry their own fills, so painting it at full weight makes a
workspace read as boxes bolted together instead of one surface.
Split the derivation into two tiers off the same blend. `border` keeps the 1.5:1
floor for outlines and in-pane rules; `divider` takes 1.2:1 and feeds
`sidebar_border`, which is already used at exactly the six pane seams that want
it — the tab sidebar, the right panel, the document column, and the two
workspace edges in `app.rs`. On the default light theme that moves the seam from
#c8c8c8 to #dfdfdf and leaves every popover outline where it was.
`right_panel`'s rule under the tab row goes back to `border`: same fill above
and below, so the line is carrying the separation alone.
Worth stating because the code hid it: the `mix(bg, fg, 0.16)` seed clears
neither floor in any builtin theme, so both values are decided entirely by the
constants. Lowering the seed changes nothing — that is now in the comment, and
`DIVIDER_FLOOR` is the knob if the light tier turns out too faint.
Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
* fix(daemon): let a clean version probe clear the mismatch record
The restart prompt was armed from a global that only ever accumulated:
`note_daemon_mismatch` could set it, and nothing could take it away
except the window that consumed it. `ensure_running`'s agreeing path
never touched the record at all.
That matters because `ensure_running` is the first thing every
control-link reconnect attempt runs, and a mismatched daemon is one no
connect succeeds against. The link backed off and retried, arming the
prompt again each time round — including in the seconds the user spent
reading the dialog it had already opened. Restarting the daemon then
fixed the daemon and not the record, so the next window built took that
last arming and asked a second time about a server that was already
gone.
Make a probe's verdict settle the record rather than only add to it: a
daemon found to be ours wipes what an earlier probe left. The probe
judgement moves into `judge_probe`, and the handoff's own return
judgement into `judge_handoff_return` / `land_handoff_return`, so both
are testable apart from the sockets it takes to reach them.
Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
* fix(daemon): tell a silent control socket apart from an agreeing one
`control_dialect_refusal` returned `Option<DialectRefusal>`, and `None` meant
both "it answered with our own dialect" and "it never answered at all" — a
connect that failed, the handshake timing out, a peer that hung up before
`HelloOk`. That conflation was harmless while silence only meant "record
nothing". It stopped being harmless when a clean verdict started *clearing* the
mismatch record: a control socket that times out now wipes a refusal the
control link had already met, and the window built next opens with no tabs and
nothing on screen to explain why — the exact state the record exists to
prevent. The function's own doc comment still promised the opposite.
Give the answer three shapes (`DialectAnswer::{Agrees, Refuses, Silent}`) and
carry the distinction through to the verdict (`MismatchVerdict::{Clear, Found,
Unchanged}`). Only `Agrees` clears. Silence leaves the record exactly as it
stands, which is the cheap side of the asymmetry: a stale record costs one
prompt about a daemon that turned out fine and the next probe takes it away,
while a wrongly cleared one costs a window its tabs.
The landing also moves out of `ensure_running` into `land_probe`, so the
logging and the record write are one thing a second caller can reuse.
Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
* fix(daemon): settle the mismatch record on the restart path too
`ensure_running` only settles the record on the branch where a daemon is
already listening and answers. `restart()` is `stop()` + `ensure_running()`, so
by the time it runs the connect is refused, the endpoint is reaped, a daemon is
spawned, and the startup poll loop returns having written `note_local_daemon`
and nothing else. The record still describes the daemon the user just killed.
That is the path a mismatch is most likely to take. A daemon from before
protocol versioning reports no version at all, so `local_daemon_supports`
answers false for the handoff feature and the prompt's Restart takes
`restart()`, not `hand_off()` — the one branch that was given a clear.
Judge the freshly spawned daemon with `judge_probe` and land it with
`land_probe`, the same pair the already-running branch uses. The control
listener is up before the pane endpoint binds, so the dialect can be asked this
early; a daemon that answers neither handshake is still recorded as nothing,
same as before.
Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
* fix(daemon): drop a probe verdict about a daemon that is already gone
A probe is not one instant: it connects, asks the pane endpoint and the control
socket, and only then writes what it found. In between, this build can stop the
daemon, hand it off, or spawn a new one — and now that a verdict *settles* the
record rather than only adding to it, a late one is not a stale read but a
wrong write.
Against a mismatched daemon that is the normal case, not a corner. The control
link retries on a backoff and every retry runs `ensure_running`, so when
`land_handoff_return` clears the record, a probe that connected to the outgoing
image before the exec lands afterwards and re-arms the prompt about the daemon
the user just replaced. The clear was best-effort against its own retry loop.
Stamp each verdict with a counter that moves whenever this build deliberately
changes which process serves — `stop`, `reap_stranded`, `spawn_detached`, and
the handoff at the point the exec is asked for — and drop a landing whose stamp
is stale. Probes against the same daemon are still last-one-wins, which is what
a record of "what is running now" should do.
Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
* fix(terminal): recall the last matching command on ↑ and Ctrl+P
The prompt editor walked history in file-load order and ignored the
prefix on the line, so the first press showed whatever had been
concatenated last — often an old tty7 record, or a command that had
nothing to do with what was already typed. Keep the prefix from when
navigation started, the way zsh's up-line-or-beginning-search does,
and order merged history files by timestamp.
* fix(terminal): search history on the text left of the cursor
up-line-or-beginning-search matches on $BUFFER[1,CURSOR], not on the
whole line, so Ctrl+A followed by UP has to walk every entry rather than
filter on text the user is about to type in front of. Keep the search
prefix and the line stashed for DOWN in separate fields: restoring what
was typed still needs the part sitting right of the cursor.
Also cover the borrowed-mtime path, which had no test: untimestamped
bash lines must take the file mtime, keep a real timestamp when they
have one, and survive an unreadable mtime untouched.
Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(ssh): allow remote image clipboard writes
* fix(ssh): keep a profile's clipboard grant across a re-attach
A native ssh pane's OSC 5522 permission is decided by the spec that
dialled the host, and the daemon is the only side that holds it. A window
reopening onto a pane that outlived it attaches by pane id, has no spec
to read, and sends `allow_remote_clipboard_write: false` — which the
daemon took as the new answer and the pane's own view took as a refusal.
Both sides then said no, so the first restart after switching the
permission on turned every copy into an `EPERM` with the switch still
reading "on".
Pin the spec's answer in the pane and route both attach and detach
through one decision point, so a pane that carries a spec keeps that
spec's answer whatever an attaching client claims, and a pane without one
— everything on a remote `tty7-server` — is exactly as permitted as its
controller says. On the client side, refuse only what the pane can see is
forbidden and leave the verdict to the daemon otherwise.
Also: release a failed transfer's buffered bytes instead of parking up to
`MAX_CLIPBOARD_BYTES` per pane until the next request, and answer the
capability probe with the permission actually in force rather than a
constant that always reads as "off".
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* feat(settings): add interface font family configuration in appearance typography
* fix(settings): let the interface font go back to the system face
Three things the new **Interface font family** row spelled once and needed
twice.
`apply_theme` only wrote `Theme.font_family` when the setting was `Some`,
and `Theme::change` never puts it back — it rewrites the field only when a
theme config names a face, and none of ours does. So picking a font worked,
and picking **Default** back saved `None`, redrew every window in the font
the user had just cleared, and only came true at the next launch: a setting
that looked like it had applied instantly and had not. The face is now
assigned in both directions, against the stock value read once before
anything overrode it.
The dropdown's first row borrowed the bold/italic label, "Default (match
primary)" — which promises the *terminal's* primary family. The interface
falls back to the system UI font instead, so the row said the chrome would
come out in Hack while the description beside it said the opposite. It gets
its own label in all three locales.
`ui_font_family` was also the one key in `config.json` that disappeared when
unset; every other optional key is written as `null`. Dropped the
`skip_serializing_if` so the file still lists it, and documented the key in
the two tables that enumerate the typography settings.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
A turn's row is a link back into the scrollback, and `scroll_to_agent_turn`
refuses two cases: a turn with no anchor, and a pane sitting on the alternate
screen with no scrollback behind it. The panel only checked the first. So a
conversation recorded under the classic renderer kept its anchors, the user
switched the agent into a full-screen renderer — Claude Code's `/tui
fullscreen` — and every row went on drawing a pointer and a hover fill while
swallowing the click in silence.
Both conditions now live in one predicate the panel and the view agree on, and
a row that goes nowhere says why on hover: grey text reads as "less important"
long before it reads as "not a link".
Claude-Session: https://claude.ai/code/session_01A8Hiu4o14SkF5bpoPiV7Ko
A pane takes the window's focus while it is being built, and a pane whose
leaf is not in the element tree never receives the blur that goes with
losing it again. The cached `focused` flag it left behind therefore says
the reader is watching a pane nobody is looking at, and a turn that
finishes there never raises its unread badge.
Read the window's focus handle where the badge is decided, the way the
cursor paint and blink paths already do, and drop the cached flag: with
the badge moved off it, nothing read it any more.
* feat(remote): auto-relink dead workspace panes and name their tabs
A workspace pane whose stream died while its machine's control link stayed
up was invisible to the reconnect supervisor: the tab sat on 'tty7 —
disconnected' until the workspace was reopened by hand.
- The link supervisor's pump now sweeps for such panes and asks for them
back on the existing per-workspace backoff (1s doubling to 30s). A
refusal — the machine says the pane is gone — is final for that pane;
transient failures keep the clock running.
- open_relink waits for the daemon's verdict on the Attach instead of
handing an unclassifiable stream to the reader; refusals are typed
(AttachRefused) so the retry loop can tell them from transport trouble.
- PaneWorkspace carries the workspace's display name, and the pane adopts
it as its default title, so a dead link reads 'hummingbot — disconnected'
instead of the bare app name (the workspace-pane half of #438).
- The reader's teardown logs which way the link died (EOF / read error /
protocol error); until now all three were indistinguishable afterwards.
Claude-Session: https://claude.ai/code/session_016s4fehNxNDXfJ1AfeTNo6y
* fix(remote): keep two relink paths from dialling the same pane at once
The daemon keeps one subscriber per pane: a second `Attach` for a pane_id
kicks the first off. After a machine-level reconnect, `relink_panes` dials
every pane and can sit up to fifteen seconds waiting for the far end's
verdict — and the pump's own sweep, which runs every 250 ms and still reads
those panes as dead, fired a second `Attach` for each of them.
Panes are now claimed for the duration of an attempt. Both askers set the
claim before dialling and release it when the attempt reports back, so a
pane in flight asks for nothing; the workspace's retry clock likewise
survives a sweep that finds no dead panes only because a batch holds them.
Claude-Session: https://claude.ai/code/session_016s4fehNxNDXfJ1AfeTNo6y
1. Gate blink ticks on each pane's live GPUI focus handle.
2. Render inactive prompt cursors as steady hollow blocks.
3. Cover split focus and caret decisions with portable regressions.
* fix(sidebar): activate the row whose counts were clicked before opening its diff (#706)
Each sidebar row's `+N −M` opens that row's diff. The counts sit inside
the row and swallow the press so the row does not double-act, but the
row's `on_click` is the only thing that activates a tab, so the click
never switched tabs — and `open_diff_overlay` writes to `self.active`.
Click the counts of an inactive tab B while A is showing and B's
repository, branch and diff landed in A's document area, with A's
`overlay_top` flipped and its own overlay state overwritten by B's
path. Later reads keyed on the active tab carried that state on as A's.
The handler now activates its own row first, so the tab on screen, the
tab the overlay is stored on and the repository shown are one tab. On
the row already active it still toggles, so a second click on the same
counts closes what the first opened; on any other row it opens rather
than toggles, since switching to a tab to see its diff must not close
the diff that tab already had up when it happened to be the same one.
That decision is a small pure function with a test, beside
`diff_click_cwd`, which is the same shape.
Reported with the trace and the fix by @IhpEcVns in #706.
* fix(sidebar): drop the branch whose arms were the same call
counts_click_toggles gated toggle_diff_overlay against open_diff_overlay,
but the first forwards to the second with exactly those arguments — the
toggle lives inside open_diff_overlay, keyed on host/cwd/source/focus and
was_front. Both arms did the same thing, so the helper, its doc and its
test described behaviour the code did not have.
The fix for #706 is the activate() the handler was missing; that stays.
Clicking an inactive row's counts still toggles against that tab once it
is active, which is what shipped before and what ships now.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(tree-sync): let a window arriving at a workspace speak for nothing in it (#716)
A remote client connected to a machine holding nineteen panes and came
back to one tab. The workspace kept its id and its shells kept running —
`pane ls --all` listed them live, owned by the workspace, held by no tab
— but its tab tree was gone, and every GUI on that machine lost the
layout at once.
The tabs were closed by the window that arrived. `switch_workspace`
claims the workspace, then hands `adopt_workspace` an empty session to
put up while the real one is pulled — and `adopt_workspace` saves what
it put up. That save syncs: a window showing no tabs at all, against
whatever the last visit to that workspace left in the tree-sync map.
Left Primed and informed, the diff runs at `SyncScope::Full`, where
every mirror tab the window is not showing is a tab the user closed. It
queued nineteen `TabClose`s and pumped them before `hydrate_window_with_tabs`
on the next line had ordered the pull that would have populated the
window. `tab_close` removes the tab and the pane records under it and
returns the orphaned ids for the caller to hang up, which the CLI does
and the GUI does not — hence shells still running under no tab.
The workspace is now forgotten on the way in as well as on the way out.
An unprimed state has no mirror to diff against, so the empty session
goes up, is saved, and closes nothing; the pull lands, the rebuild puts
the real tabs up, and `settle_rebuild` hands back the licence to a
window that has actually seen what it is speaking for.
That the licence outlived the arrival was the whole vulnerability, and
it is what the test holds: the closes it authorises are queued and
pumped inside `adopt_workspace`, and the hydrate on the next line clears
the queue, so the ops are gone by the time a test can look at them
either way.
This is the local half. A remote client also renames on arrival and the
reported workspace came back under the other machine's user name, which
`settle_chosen_name` will fire at whatever workspace the window landed
on when a parked name differs from the machine's — it cannot tell a
name it created a workspace with from one it adopted. Left alone here;
it loses a name, not a layout.
Reported by xAlisher in #716, with the daemon state that identified it.
* fix(test): gate the arrival test on unix, like the harness it uses
harness_with_pane is #[cfg(unix)], so the new test broke the Windows
build. Its sibling above already carries the same gate.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
A remote link worked for a while, then every operation on it failed with
"could not identify the remote machine: could not open a command channel:
Failed to open channel (ConnectFailed)", and Try Again only made it worse.
sshd was refusing the session channel: its stock MaxSessions is ten, and
tty7 had left ten open on the cached connection.
russh closes a channel in exactly one case. When the server sends
CHANNEL_CLOSE first, the session task answers it on arrival. Dropping a
`Channel` sends nothing — the one close-on-drop it has sits behind
`into_stream`, which the remote link and SFTP already ride and which a
command whose output is read with `wait` does not. So a command that ran
and exited cost nothing, and a channel abandoned while the far side was
still running it cost a session for the life of the connection. There
were four ways to abandon one. The installer's `exec` returned early on a
failed exec request, and more to the point was dropped mid-drain by the
timeouts in `run` and `spawn_detached`: a `uname` that hangs or a daemon
launch that does not answer within its budget is what those timeouts are
for, and each one pinned a session. The shell and env probes broke out of
their drain on EOF or at their output limit and dropped the channel. And
`drive_channel`, the pane's own shell, closed only on the pane's Close: a
pane whose reader had gone while the shell still ran broke out of its
loop and left that shell's session held for as long as the cached
connection lived. That last one is the "after some use".
A command now rides a `CommandChannel`, which closes on drop: the `?`
after the open, the normal return and the timeout's cancellation all
queue the CHANNEL_CLOSE for the session task, the way russh's own
close-on-drop does. The runtime it spawns on is taken at construction,
on the runtime by definition, rather than looked up from whichever
thread the drop lands on. The probes ride the same type. `drive_channel`
closes after its loop on every exit; after a close the server sent
first, russh has already taken the channel out of its table and the
redundant EOF and CLOSE put nothing on the wire.
The safety net, for a leak this change did not find: a connection whose
session open comes back ConnectFailed marks itself dead, and `is_alive`
is what the cache consults before handing a connection out again, so the
next Connect — Try Again included — dials afresh instead of retrying a
link that will refuse forever. It is not the fix: a fresh connection to
a leaking client is ten operations from the same wall. The shell probe
also no longer remembers a "no integration" it got from a link that
refused it a channel, which would have kept integration off that host
for the rest of the run.
The install layer is tested against `FakeRemote`, which has no wire, so
none of this was visible. An SSH server now runs in the test process —
russh's server half, accepting every session up to a limit and answering
`exec` as a command that exits or one that hangs — and counts the
channels the client opened and closed. It shows a timed-out command
closing its channel, twelve abandoned commands against a limit of ten
with none refused, a finished command's close answered exactly once, a
gone pane closing the shell behind it, and a refused open retiring the
connection. Each was checked against the old code. What it cannot show
is sshd's own accounting; the reporter did that, with a paramiko script
that exec'd freely while closing each channel and was refused on the
eleventh it left open.
Diagnosis and reproduction by xAlisher.
A tab's title only exists as live terminal state: the OSC that set it was
emitted screens ago, and the on-disk snapshot is capped at 256 KiB, so the
bytes that would restore it are almost always trimmed away. Since #681 made
a silent attach fall through to a fresh spawn, a slow daemon on reopen turns
every such tab into the default "tty7".
Store the pane's last OSC title beside the snapshot's segments (as a
trailing field old readers skip and old files simply lack), and replay it as
a fresh BEL-terminated OSC 0 -- control bytes stripped so a stored title
cannot terminate the sequence early -- before the restore preamble. The new
pane's daemon record inherits the title too, so the switcher and CLI agree.
* fix(terminal): give an overflowing emoji room instead of shaving it flat (#697)
* fix(terminal): do not lend a blank cell that draws a rule of its own
has_room_after let a segment borrow the next cell whenever it was blank
with no background and no selection. A blank carrying an underline, a
strikethrough or a link hover is none of those, but it becomes a Run of
its own and is painted after the segment beside it -- so an emoji that
leaned into it had that stroke drawn straight across its face.
Reuse the existing draws_on_blanks predicate, which is already what
segment_row uses to decide such a blank is worth painting.
* fix(switcher): keep an orphan pane's Close button on screen
The owner an orphan carries is a WorkspaceId, printed whole: 36 characters
of UUID that say nothing to a reader and, in a flex row whose text child
never shrank, pushed the Close button clean past the edge of the card. The
row named a live pane and offered no way to stop it.
Name the workspace when this machine still has one, fall back to the 8-char
prefix PANE WS OWNER CWD LIVE
%665 76698a44 - /Users/thomas/repo/025/dex-arb-hunter yes
%711 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes
%725 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes
%642 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes
%726 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes
%671 927fd6b8 - /Users/thomas yes
%716 76698a44 - /Users/thomas/repo/025/tty7 yes
%703 76698a44 - /Users/thomas/repo/025/tty7/.claude/worktrees/input-bar-width yes
%623 76698a44 - /Users/thomas/repo/025/delta yes
%719 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes
%720 76698a44 - /Users/thomas/repo/025/tty7 yes
%632 76698a44 - /Users/thomas/repo/025/hummingbot yes
%727 76698a44 - /Users/thomas/repo/025/tty7 yes
%617 927fd6b8 - /Users/thomas/repo/kalo/data-ai-service/.claude/worktrees/feat+comment-insight-5day-cache-impl yes
%645 927fd6b8 - /Users/thomas/repo/025/telegram-cli yes
%718 76698a44 - /Users/thomas/repo/025/hummingbot yes
%728 76698a44 - /Users/thomas/repo/025/tty7 yes
%627 76698a44 - /Users/thomas/repo/025/claude-statusline yes
%630 76698a44 - /Users/thomas/repo/025/dex-arb-hunter yes
%651 76698a44 - /Users/thomas/repo/025/CloddsBot yes
%668 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes
%721 927fd6b8 - /Users/thomas/repo/025/deepagents yes
%699 76698a44 - /Users/thomas/repo/025/tty7/.claude/worktrees/audit-fixes yes
%661 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes
%590 76698a44 - /Users/thomas yes
%724 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes
%717 76698a44 - /Users/thomas/repo/025 yes
%643 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes
%723 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes
%713 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes
%722 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes prints, and let the text shrink (flex_1 +
min_w_0 + truncate) while the button holds its width.
* docs(switcher): put the owner-label rationale on the function it explains
`codesign -d -r-` writes the requirement to stdout and puts only the `-d`
display header (`Executable=…`) on stderr. `signing_requirement` searched
stderr, so the `designated => ` prefix could never match and every in-app
update on macOS ended at "codesign did not report a designated
requirement" — every build, every channel, with nothing a user could do
but download the app again by hand.
Verified against codesign rather than reasoned about:
$ codesign -d -r- /bin/ls
stdout: designated => identifier "com.apple.ls" and anchor apple
stderr: Executable=/bin/ls
Both streams are read now, stdout first. Which half goes where is
codesign's own business and has moved before; a requirement printed
anywhere in the output is the requirement, and the updater has no reason
to be the stricter party about where it appeared.
The parse is split out of the process call, which is the part that
matters for it staying fixed. Fused to `Command::output`, it could only
run against a real signed bundle, so nothing in a test suite ever
executed it — that is why a total failure of the macOS update path
shipped and stayed. `/bin/ls` is the bundle it was missing: Apple-signed,
on every macOS, and it answers `-d -r-` with a requirement of its own, so
the stream split is now asserted against the tool instead of against our
belief about it.
Both tests were run against the old stderr-only parse; both fail there.
* fix(terminal): keep a stalled remote link off the UI thread
A pane's writing half was a blocking socket with no write timeout, written
to synchronously from gpui event handlers. When the far end stopped
draining — a congested remote workspace, where the router's
copy_bidirectional stops reading our half — the send buffer filled and
write(2) parked in the kernel. One UI thread draws every window, so that
was every window frozen until the link recovered. macOS gives a unix
stream 8K, which is about 1400 keystrokes: a single paste.
Move the socket onto a sender thread. write/resize/respond_auth/Detach
now encode a frame, push it onto a bounded queue and return; the sender
writes with the lock released and is welcome to park for as long as the
far end makes it. A second handle on the socket is kept for shutdown,
which returns at once even while another thread is parked in write(2) —
the only way teardown can break that state.
The backlog is bounded at 4 MiB. Reaching it is a dead link rather than a
slow one, and is reported through the same path — and once — as an
outright refused write. Refusals are now met on the sender thread, so a
pane learns of one a moment after the keystroke rather than during it.
Teardown gives what is queued 50ms to go out before cutting the socket:
on a draining link the sender is idle and Detach leaves in microseconds,
and on a stalled one it never leaves at all, which closing a pane must
not wait to find out.
* fix(terminal): a big paste is a paste, and a retired link keeps its own tongue
Review follow-ups on the pane-writer queue.
The "said it once" flag lived on the pane and was cleared on relink, but
the retiring sender still held the same `Arc`. A doomed write completing
after the reset spent the new link's one chance to speak, and the next
real refusal went unreported. The flag belongs to a link, not a pane, so
`LinkWriter::new` now mints its own.
A frame can be over the whole backlog bound on its own — `paste` sends
the clipboard as one `Input` — and refusing it marked a perfectly healthy
pane gone. An oversized frame onto an empty queue now goes through and
lifts the bound by its own size while it is outstanding, so what queues
behind it is still held to four megabytes.
Also: `close` is idempotent, so the teardown that calls it twice does not
spend two grace periods; a sender that has given up closes the queue
behind it rather than letting keystrokes pile to the bound it will never
drain; and the #673 note that was dropped in the move is back.
The backlog test passed with 27K of margin against a send buffer that is
8K on macOS but 212K on Linux, where the sender discounts what it got
onto the wire — it queues twice the bound now.
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn
Five fixes from a whole-codebase audit, in one sweep because they share
the paths they touch.
Path quoting had two implementations. file_tree::shell_quote_for wrapped
the path in quotes and picked the right ones per shell (#593);
view::shell_escape_path escaped with backslashes, which is POSIX-only
and collides head-on with the Windows path separator, so a dropped file,
a pasted path, a staged image path and an accepted completion candidate
all lost their separators there. completion::complete_path stripped the
same backslashes back off before looking a path up, so inline path
completion could never resolve a directory on Windows either. Both now
go through one core::shell_quote module, and shell_word_start tracks
quoting across the word so a second Tab still finds the word it just
inserted.
"Checkout to..." was registered, listed in the palette, bindable, and
handled by an empty match arm — invoking it did nothing at all. It now
opens an inline input row in the SCM panel, the twin of the existing
"create branch" one.
RemoteTerminal's Spawn read the daemon's reply with no deadline, while
Attach in the same file and PaneSession::spawn_over in core both bound
theirs. A daemon caught mid-restart accepts the connection and never
serves it, and the local route spawns synchronously on the UI thread, so
the silence froze the window on "new tab".
Two Windows papercuts: client_hostname spawned a console program from a
GUI process (a visible console flash) where COMPUTERNAME already has the
answer, and completion generators were a silent no-op with no way to
tell "produced nothing" from "never ran".
Three duplicated implementations merged: proc_name existed twice in the
daemon with a different fallback in each, the GUI's control link was the
one client socket that skipped transport::tune, and fps.rs and perf.rs
were the same windowed meter copied twice.
* refactor(completion): stop declaring spec fields nothing reads
The Fig spec structs mirrored seven keys the completer never looks at,
each held up by its own #[allow(dead_code)]. Serde ignores unknown
fields by default, so dropping the declarations parses the same specs
and drops the attributes with them.
* refactor(daemon): delete the loopback-forward management pipeline
Two protocol messages, their kind codes, encode and decode arms, two
daemon dispatch arms, two wire structs and two GUI client wrappers all
existed to reach SshManager::list_loopback_forwards and
close_loopback_forward, which were hardcoded to Vec::new() and false.
Nothing called the client wrappers either.
The kind codes are left as holes rather than renumbered, the way 13
already is, so the wire format is unchanged for every other message.
known-hosts management looks like the same shape but is not: its backend
parses the real file, fingerprints keys and rewrites through a 0600 temp
file. That one keeps its client half and gains a comment saying it is an
interface waiting for a screen.
* test(ssh): cover the host-key policy table and both proxy handshakes
The host-key decision is lifted out of check_server_key into
host_key_action, so what to do about Known/Unknown/Changed/
ChangedAlgorithm/Revoked can be read and tested without a server, a
broker or a known_hosts file. Eight tests pin it, including the two
subtleties the comments already claimed: verify_host_keys=false still
rejects a revoked key, and a new algorithm asks the unknown-host prompt
rather than a new variant older peers cannot decode.
socks5_connect and http_connect are split into connect + handshake, the
handshake generic over the stream, so nine tests drive them from an
in-memory duplex: length-prefix framing, the variable-length bound
address, auth refusal, reply codes, and the header terminator.
* test(cli,daemon): cover server binary resolution and the procargs parser
server_exe is split into environment lookup and resolve_server_exe, the
latter taking its three sources and an is_exe predicate so seven tests
can pin the precedence without touching the filesystem. Holding the
sibling to is_file rather than exists fixes a directory named
tty7-server shadowing the real binary on PATH.
parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout:
exec-path skipping, however many bytes of alignment padding follow it,
argc bounding argv so the environment stays out, truncation, and a short
buffer.
* test(ui): cover the host-op pool decisions and the local reconnect schedule
The pool's retire condition moves into should_retire with the reason
named: a worker must not retire on the timeout alone, because submit
counted it as idle and so did not spawn a replacement for the job that
landed meanwhile.
LocalLink::tick's schedule moves into due(), taking the clock and the
link's state as arguments. The first attempt going out immediately, the
backoff only applying from the second, and a pending deadline not being
pushed further out by later ticks are now pinned. The identical
scheduler in remote_workspace had TestAppContext coverage; this one,
which every launch depends on, had none.
* fix(completion): unquote across the whole word, not just its first character
The round-trip test caught two things the first cut got wrong. A quote
can open partway into a word — quote_for_shell emits ~/'My Documents' so
the shell still expands the tilde — and a single-quoted body is literal
all through, so unescaping backslashes inside one took the separators
out of 'C:\Users\me'. Scanning with a quote state handles both, and
makes the '\'' seam fall out of the state changes rather than needing a
case of its own.
The GPUI test for accepting a candidate follows the insertion from
backslash escaping to quoting.
* fix(windows): unbreak the Windows build and quote for PowerShell's own dialect
`Instant` was moved behind `#[cfg(unix)]` while the generator cache still
uses it unconditionally, so the Windows target stopped compiling.
The quoting module treated every shell but cmd.exe as POSIX, including
PowerShell. PowerShell does not join a quoted string to the bare word beside
it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as
three tokens, and the completion un-quoter turned the apostrophe back into a
backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX)
chosen once and threaded through completion in place of the escapes flag.
* test(file-tree): name the shell where the quoting rule is the POSIX one
`shell_quote_for(_, None)` answers from the platform, so an assertion about
the `'\''` seam has to say which shell it means or it fails on Windows,
where the unnamed shell is PowerShell.
* fix(input-bar): read column widths from unicode-width, not a hand-rolled table
The input bar scored every character against a hand-written list of code-point
ranges. Anything the list missed counted as one plain column, so `🀄`, `⌚` and
every combining mark pulled the rest of the row a column left, and clicks,
wrapping and the caret all landed off by that much (#701).
The grid gets its widths from `unicode-width` by way of `alacritty_terminal`,
so read the same table. Zero-width characters then need a cell to ride in:
group each base with the marks that follow it, so the shaper sees one run and
composes `é` instead of setting `e` and its accent side by side. An emoji
presentation sequence is re-scored as a string the way the grid re-scores it,
so `❤️` is two columns in the bar as well.
A ZWJ sequence stays two cells on purpose — that is what the grid makes of it,
and composing it here would put the bar a column off from where the text lands.
* fix(input-bar): derive click and wrap geometry from the cells the bar draws
`input_cells` re-scores an emoji presentation sequence to two columns and
hands a stranded combining mark a column of its own, but `input_char_positions`
kept walking the text character by character — so `❤️` was drawn two columns
wide and counted as one. Everything geometric read the short count: a click on
`X` in `❤️X` selected past it, wrapping broke a column early, and vertical
caret motion aimed at the wrong column.
Walk the same cells instead. Only the base of a cell carries the width, so a
click still lands on the base rather than a mark riding on it, and the riders
sit at the column the caret takes after the cell.
A cell now also tints as a unit when a selection covers any character in it —
it is one glyph, so half-highlighting it drew a mark unselected next to its
selected base.