mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
8071eddb5b320f401121e4c5a8dc7aea24e2a50a
728
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8071eddb5b |
fix(ui): clamp the font metrics to the config's own range, and stop buckets mislabeling a hand-set value (#550) (#572)
* fix(ui): clamp the font metrics to the config's own range, and stop buckets mislabeling a hand-set value (#550) The settings steppers and the Ctrl+=/Ctrl+- keys clamped font size to 6-48 and line height to 1.0-2.0, while `sanitize` allows 4-256 and 0.5-4.0. A value inside the config range but outside the GUI's got pushed the wrong way by a single step — `font_size: 50` shrank to 48 on "+" — and `set_font_size` writes the result back to the file, so one misclick permanently changed a value it only meant to nudge. The bounds move into tty7-core beside `sanitize` (the `ui_font_size` precedent), one shared range for validation, the steppers, and the keyboard path. The scrollback and notify-threshold preset rows had the matching display bug: the highlight matched a *range*, so a hand-set 5000 lit up "10,000" and 20s lit up "30s", and clicking that cell silently overwrote the real value with the bucket's. The segmented control now highlights a bucket only on an exact match and otherwise shows a "Custom (N)" cell that names the live value and is not a button. * fix(ui): name a custom preset the way the cells beside it are written Review follow-up on #550. The "Custom (N)" cell rendered the raw integer, so a documented `scrollback_limit: 50000` read "Custom (50000)" between cells reading "10,000" and "100,000" — the one number on the row not written like a count. It is grouped now, and the presets and their labels are one pair of lists each, checked against each other, so a cell cannot come to show one number and write another. The bucket match moves out of the render bodies into `preset_choice`, which is what makes the exact-match rule the issue asked for testable: the presets the default lands on, the 50,000 the example config in `docs/reference/configuration.mdx` carries, and 20s on the notify row. The core test claimed to pin "the GUI steps within the range sanitize allows", but only asserted that sanitize agrees with the constants it is written in terms of — true by construction, and its line-height case took the reset path rather than the clamp, so it passed without touching LINE_HEIGHT_MIN at all. It now pins the published numbers themselves, the clamp in both directions, and the two values the issue was reported with. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
5dd60a6660 |
fix(ui): rebuild the keymap when a hand-edited binding reloads (#548) (#571)
* fix(ui): rebuild the keymap when a hand-edited binding reloads (#548) The config watcher reloaded locale, theme, menus and shells but never the keymap, so a hand-edited `keybindings` entry appeared in the settings list off the live global while the key itself stayed dead until restart — the settings page showed a binding gpui would never dispatch. Two changes make driving a rebind from the watcher safe. First the rebind clears before it binds: gpui `Keymap::add_bindings` only ever pushes — it never dedups and nothing retires a binding — so the old append-only rebind leaked one full table per call, and every keystroke walks the whole map. `rebuild_keymap` now replays the action bindings plus the fixed terminal/modal bindings onto a cleared map, and `init` shares it. Second the watcher gates on it. It fires for every write under the config dir — including the apps own `save()`, which a sidebar drag or a palette open triggers — so it compares the `(keybindings, keybinding_preset, prefix)` triple before and after the reload and only rebinds when a binding actually moved, keeping a no-op save from churning the keymap. Tests pin the triple only moving on a real binding change and the map not growing across repeated rebinds. Per the review this lands after #537 (the watcher keeping the old config on a parse failure): without that, a broken edit would swap in defaults and this rebind would then wipe the users keys from the live keymap too. The unknown-action surface and the prefix-conflict detection the review also scoped are follow-ups. * fix(ui): carry the inherited bindings through a keymap rebuild `rebuild_keymap` clears the whole map, and the map is not tty7's alone: `gpui_component::init` runs first and installs the `Input` context's editing table, the list and menu navigation and the escape that closes a dialog, with no entry point to install them a second time. Clearing and replaying only tty7's bindings dropped all of it — at `init`, so every text field in the app lost backspace, the arrows, enter and escape from the first frame, and the test that pinned the map's size could not see it because it measured after `init` had already cleared. Snapshot whatever is in the keymap before tty7 binds anything and lay it back down first on every rebuild, in the order it was added, so tty7's bindings still win. Drop the `BoundKeystrokes` global with them: the retire-one-`NoAction`-at-a-time path it fed is gone, and its last reader with it. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
e1efae2809 |
fix(ui): read the Info panel agent row off one pane, and shorten Windows paths to their leaf (#543, #544) (#570)
* fix(ui): read the Info panel agent row off one pane, and shorten Windows paths to their leaf (#543, #544) Two rows in the Info panel were misreading a split tab or a Windows path. The agent row took its name from `tab.agent` (the first leaf with an agent) and its status from `tab.agent_status` (the most urgent across the whole tab); the two resolve independently, so a split tab running two agents could show one pane name beside the other pane state — a row no leaf ever had. Both now come from the detail pane when it has an agent, falling back to the tab aggregate only when the focused leaf has none, so the row holds while focus sits on a plain shell but never splices. The cwd row split the path on `/` only, so a backslash-spelled path — any agent-reported cwd (`agent_session.cwd` arrives as `C:\…`), a cmd pane, a shell-integration-off pane, or the seed cwd before the first prompt — elided its tail and hid the directory own name. The split now takes the last of either separator, and is not cfg-gated: the panel shows remote paths, so a Windows build describes Unix paths and vice versa. The `~` shortening moves into a shared `path_display` helper reading `USERPROFILE` as well as `HOME` and comparing with separators normalized and case folded; the tab strip `abbreviate_home` and the home picker `display_path` had the same HOME-only miss and now use it too. Tests pin backslash, mixed-separator, drive-root and UNC shapes. * fix(ui): hold the agent row's fallback to one leaf, and stop the home test leaking Review follow-ups on the #543/#544 pair. - The fallback branch still spliced. When the focused leaf carries no agent the row fell back to `tab.agent` + `tab.agent_status`, which is exactly the pair the fix set out to break up: the first leaf with an agent, beside the highest urgency anywhere in the tab. A three-way split with focus on a plain shell could still read `Claude · Working` off two different panes. `Tab::agent_row` now picks the most urgent agent leaf and answers both halves out of it; `agent_status` is that pair's status, so the tab strip's badge is unchanged. - `path_display`'s test home was a process-global that no test ever cleared, so once one of these tests ran, every later `abbreviate_home` in the binary read the pinned home — including `ui::home`'s own test, which sets `HOME` and expects to see it. Ordering decided whether it passed. The comparison moves into `abbreviate_under(path, home)` and the tests hand their home in; nothing global is left to leak. Adds the trailing-separator home and non-ASCII component cases the byte boundary reasoning turns on. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
557bfd3f3a |
fix(ui): resync local windows when the daemon comes back as a new process (#553) (#569)
* fix(ui): resync local windows when the daemon comes back as a new process (#553) From the client's side a killed daemon is indistinguishable from one whose shells all exited at once — its DeathReporter says nothing while it shuts down, and a taskkill says nothing ever — so the window kept showing every pane with its last title, and the reconnect then pushed that dead layout back up as the new daemon's truth. On Windows a force-killed daemon can even leave the shells themselves alive but permanently unreachable. The control handshake already carries an instance id per server process, and the remote path already compared it (server_restarted); the local link never did. Now LocalLink remembers the hello instance — across invalidate(), deliberately, or the restart path's own first move would blind the comparison — and a changed instance on reconnect routes every local window through resync_after_local_daemon_change instead of on_link_up: the dead link is dropped first, then hydrate rebuilds from the machine tree and each pane comes back from its scrollback snapshot with the "new shell" banner. note_instance moves to tree_sync as the shared comparison (empty instance = server predates the field, never a restart, never overwrites), with the remote callers and their tests repointed. The restart-server action now uses the same helper instead of open-coding invalidate + resync, which also fixes it rebuilding only the current window when several local windows are open. Per the review, the title wording ("process exited" while the link was down) and the missing Respawn menu item are a separate PR: the current wording is pinned by a_dropped_link_does_not_claim_the_process_exited and changing it is its own decision. * docs(changelog): entry for the local-daemon reconnect resync (#553) * fix(ui): keep the reconnect's own link when the daemon came back new The reconnect installs the new daemon's client and then, on a changed instance, called the helper that starts by dropping it. The link the handshake had just proved good was thrown away, every window's pull went out with nothing to send it on, and the layout had to wait for the next tick to connect a second time. That is not only wasted work. `hydrate` gives the link fifteen seconds to come back and then owes the window a `Replace` — and a `Replace` is abandoned the moment the window has tabs, which in this scenario it always does: the dead ones still on screen are the whole bug. So a slow second connect turned the resync into nothing at all, silently. Split the helper: `resync_after_local_daemon_change` still invalidates first, for the restart-server path whose link really does point at a server it killed, and `resync_local_windows_from_tree` is the half the reconnect wants, with the fresh link left in place. The two note_instance tests that moved here with the function now say what tree_sync's own tests say; the one that is this module's — the slot is per host — stays. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
14ab96284c |
fix(config): quarantine an unparseable config.json instead of overwriting it (#537) (#565)
* fix(config): quarantine an unparseable config.json instead of overwriting it (#537) A config.json that failed to parse was replaced by in-memory defaults with only a log line, and the next write of any setting — a dragged sidebar divider, Ctrl+=, anything saved in Settings — serialized those defaults over the file wholesale: one typo, and every hand edit was gone. views.json and machine.json already kept a corrupt file aside for exactly this reason; config.json was the one that did not. A failed parse now parks the file's contents as config.json.corrupt and hands back defaults marked quarantined — a non-serialized flag that makes Config::save refuse to run, so all twenty-plus write call sites are covered without touching them. Config::load_with_outcome returns the verdict beside the values (Parsed / Absent / Quarantined) so the hot-reload watcher can tell a broken file from a missing one: a broken one keeps the settings the app is running on instead of swapping defaults in, and says so in a toast; a startup on defaults after a quarantine says so too. A read failure (not merely a parse failure) warned nowhere at all — it logs, and suppresses writes the same way, without parking a copy there may be nothing readable to take. * fix(config): keep one copy of a broken config, and one word about it `Config::load` runs on every pane spawn and every palette command, so a file left unparseable was quarantined again and again: opening a couple of tabs filled the config directory with eight identical .corrupt files and then overwrote the first. A copy that already holds those bytes is the copy the call would make, so it is not made again. The hot-reload watcher covers the themes directory too, and returning early on a broken config.json took theme hot-reload down with it and re-announced the same breakage on every theme save. Themes now reload either way, and the toast speaks once per breakage. A file that cannot be read parks nothing, so it no longer reports itself as quarantined and no longer sends the user after a .corrupt file that was never written; and the reload toast no longer claims settings stop saving, which is true at startup but not mid-session, where the running config is kept and stays writable. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
d343fd8a13 |
feat(links): open file links in tty7, resolved on the pane's own host (#568)
* feat(links): open file links in tty7, resolved on the pane's own host A clicked file path now opens in the built-in editor at the line and column the link named, and the Files panel reveals it; a directory link opens the panel on that directory. Settings -> Terminal -> Links -> Open files with picks between the built-in editor, the OS file association and a command, migrating anyone who had already set link_file_command. Detection is split into a filesystem-free candidate parser and a probe callback, so a pane whose paths live on another machine resolves them there instead of against the local filesystem -- an absolute path used to open this machine's copy silently. A pane running ssh typed into a local shell can answer for neither side and no longer offers file links at all. Relative paths are measured from the directory the work is happening in (the agent's, not the shell's kernel cwd) and then from the repository around it, and a path that matches nothing under either now says so instead of the click doing nothing. * fix(links): keep a remote path off the local openers, and off a dead end Review follow-ups on the file-link work. - A file resolved on another machine now opens in the built-in editor whatever `link_file_open` says. Under `system` or `command` the path was handed to a local `open` / `code --goto`, which threw away the resolution just done on the pane's host and silently showed this machine's copy — the same bug this branch set out to fix, left live for two of the three modes. A directory outside every tree root says so instead of opening a local file manager on a path that belongs to the far side. - `flush_link_probes` takes the host before it takes the wanted paths. `take_wanted` moves them into the in-flight set on the promise that a call is carrying them; a host that had gone away broke that promise for good and left those paths permanently unanswered — no underline, and a click that says nothing. - `~` no longer borrows this machine's `$HOME` for a pane whose paths are elsewhere. A cwd outside `/home` and `/Users` used to fall back to it, so `~/.zshrc` on a Linux box became `/Users/me/.zshrc` and was asked about — and possibly answered — over there. - An unresolved absolute or `~`-rooted path no longer claims it was looked for under the pane's directory. It never was: roots are only for relative paths. - A pending tree reveal counts down whether or not its row was found. A row that never reported bounds kept the request alive for good, re-issuing a scroll on every render and holding the column against a hand scroll. - The repo root comes from `GitStatusCache` when the git-status probe has already asked about that directory, rather than a second round trip. Tests: the migration `link_file_open` exists for (an old config with a command lands on Command, one without on the editor), a probe with no host staying wanted, and `~` refusing this machine's home for another one. * test(links): only claim a leading slash is absolute where it is `is_rooted` asks `Path::is_absolute`, the same question `FileCandidate::paths` asks before it decides the roots do not apply — and on Windows `/etc/hosts` answers no to both. The predicate is consistent; the assertion was not, so it now lives in a unix-gated test of its own next to the untouched one. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
c7207e990e |
fix(tabs): stop a drag crashing the strip once its chips overflow (#563)
`visible_chips` renders only the window of chips that fits, but the drag seeds one slot per tab and lets each chip's canvas fill its own. Every chip scrolled off the strip therefore kept the `Bounds::default()` it was seeded with, and `held_origin` read those zeroes as a real origin — clamping the dragged chip to an inverted range, which panics. The first drag of any tab killed the window as soon as there were more tabs than fit. A zero extent now means "off screen" rather than "empty" everywhere the geometry asks: `held_origin` spans only measured slots and floors its upper bound, `target` leaves an unmeasured slot on the side of the dragged chip it started on, and `displacement` stops summing phantom extents into the slide animation. The sidebar surfaces were never affected — they size their slots to what they draw, so every slot they allocate gets measured. |
||
|
|
6f6eee7eda |
fix(switcher): give a machine name its own line above the address (#560)
A machine group header in the workspace switcher drew the machine name and its endpoint (`user@host:port`) on one row. The endpoint was a fixed-width flex item — up to `TAB_PATH_W`, never shrinking — so it claimed its space first and the name, `flex_1` off a zero basis, collapsed into whatever was left. A machine whose name runs long read as a stub: `y...`, `GAEM...`. The "other machines" rows paired label and address the same way and squeezed the same way. Both now use the two-line shape the workspace rows under them already use: the name on the main line, backed by the full row width and truncated only when the name alone cannot fit, and the endpoint plus link status underneath in the dimmer secondary style. Rows with neither endpoint nor status — the local group — stay single-line, and header height grows with its content (`min_h` plus padding) instead of holding a fixed 34px. On the second line the endpoint keeps its natural width and the status word yields, so the address stays readable; `TAB_PATH_W` has no callers left and is gone. |
||
|
|
100904a313 |
fix(switcher): name another workspace's tabs from their terminal titles (#558)
A window names its own tabs from its live terminals' OSC titles; every other workspace it lists it reads out of the machine tree, which recorded each pane's foreground process name and never its title. So the naming fell through to the agent, and switching workspaces — which happens in place and drops the terminals the window was reading — turned the tabs of the workspace just left into a column of identical "Claude Code" rows. The daemon now sniffs OSC 0/2 and records the title beside the pane's cwd, capped at 256 characters, and `TabLabel` ranks it second only to a name someone gave the tab. The switcher puts it through the same abbreviation the tab strip uses, so a shell's `user@host:~/dir` reads `…/dir` in both places, and in a split the pane running an agent names the tab rather than whichever shell happens to be first. `tty7 tab ls` and workspaces on a remote machine were reading the same missing field and are named the same way now. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
9101200170 |
fix(search): keep highlights on their text while a pane is printing (#559)
A match point is a line of the grid it was scanned from, so every line that scrolls off the top leaves each highlight washing the row its text has just left. Slide the stored points along with the text on every wakeup — their absolute rows do not move, so re-deriving each line from its anchor is enough, and it reads nothing off the grid. The rescan behind that was purely debounced, so a pane that never pauses — an agent streaming a reply — never got one at all: the count froze and highlights sat on text that had since been rewritten in place. Cap how far output can push the deadline out, at four windows. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
d81bb5e617 |
fix(scm): put the Source Control panel back on the interface font scale (#557)
* fix(scm): make the commit body's fold actually fold Show more / Show less flipped `body_expanded` and relabelled itself correctly the whole time. What never moved was the body. `line_clamp` only limits the *wrapped* lines within one logical line: `shape_text` splits the string on `\n` first and shapes every hard line it finds, feeding the clamp to `layout_wrapped_line` per line rather than to the document. A commit message is the one string this panel draws that is always hard-wrapped, so a clamp of four over a thirty-line body laid out all thirty, and the fold was a label with nothing behind it. Fold by clipping a height instead. The body's leading is spelled out — 18px is what `phi()` already resolved 11px type to, so nothing moves by naming it — and four of those plus the padding is the folded height. The string is truncated to `BODY_LINES` as well, which keeps the shaper off lines nobody can see; the height is what guarantees the fold, because in a column this narrow those four lines can each wrap again. Guarded by a test that clicks the row in a real window and measures the body's bounds on both sides of both clicks. The flag alone proves nothing here — it was right all along. * fix(scm): put the Source Control panel back on the interface font scale The Source Control tab has been a step smaller than the rest of the right panel since the day it was written, and did not respond to `ui_font_size` at all. Nothing chose that. The branch was cut from main at 00:51 and copied `right_panel.rs`'s ramp as it stood — 12/11/10.5, in px. At 14:09 the same day the interface font scale landed on main: it moved that ramp to 14/13/12/11 and onto rems, so every size in the window became a multiple of `window.rem_size()`, which the root sets from `ui_font_size`. It rewrote the three files drawn inside this panel that existed on main at the time; `scm/` was still on a branch, so nothing rewrote it. The merge the next day had nothing to conflict over, because the two sides had touched different files. So the panel's *primary* text sat at the size its neighbours use for *secondary* text, and the Files tab beside it — `text_sm()`, the same rem under another name — rendered a step larger again. `git_badge` and `info_chip` are shared, so a file row already mixed a rems status letter with a px path. Every size a reader can read is now `rems(TEXT)` and its four siblings. `px` stays on what type sits inside: row pitch, tiles, the marks. Those geometries move with the type they hold — the file row is 26 around a 21px mono line, a graph row 24 around a 23px subject, and the history section's resting and minimum heights grow to match, because both are counted in commits and holding the pixels would have bought the file list 40px by quietly showing two fewer. The lane gutter is untouched: a taller row against the same lane width steepens a merge's diagonal, which is the direction that pitch was guarding. Three smaller corrections fall out of it. The branch trigger is `.small()`, not `.xsmall()` — the file names under it moved to `TEXT_MONO`, and a `.xsmall()` label would have left the branch quieter than the paths it scopes. The commit body's fold is in rems now, so a clip that used to land on a line boundary still does when the rem moves. And `detail.rs` reads `panel.rs`'s row constants instead of holding a matching pair kept in step by a comment. There is one prior verdict this reverses. `detail.rs` recorded that a 14/12/11.5 ramp had been tried "through the whole panel" and read as too big for a 260px column and too loud beside the graph. That pass ran at 14:35 on a branch where everything else was still 12/11/10.5, so what it measured was this view standing a step above its neighbours — which is the rule it was defending and which this change keeps. The neighbours have moved; being at 12 is now the thing that reads as pasted in. Guarded by a test that greps `scm/`'s own source for `text_size(px(`. Assertions over the constants would only prove the constants agree with themselves; what went wrong was a literal at a call site, so that is what it reads. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
f792cfb8c1 |
fix(switcher): drop the stray guide rail under a remote machine (#536)
Expanding a remote machine in the workspace switcher drew a 1px vertical line down its rows, positioned left of the workspace avatars. Local groups never drew it, so it read as a stray line rather than an indent guide, and it stopped short of the last row's midpoint, leaving a dangling stub. Remote rows now indent exactly like the SSH-host rows below them. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
4da3868797 |
feat(shells): let the new-tab menu carry entries the user wrote (#534)
Closes #443 |
||
|
|
b7196ae49a |
Give the side panel's Info tab rows that do what they show (#531)
* feat(right-panel): give the Info tab rows that do what they show The panel's Session table rendered every fact the same inert way, and its two actions sat in a strip of their own under the whole list — unlabelled, four rows below the path they acted on. Rows now carry their own shape: `changes` is the sidebar's green-and-red `+N −M` and opens the same diff overlay under the same setting, an agent wears the same status dot its tab does, and what a row can do appears at the end of it on hover, in the strip Source Control rows already use. A port row hands over its address instead of leaving it to be retyped, and the lit panel tile closes the panel the way every other activity bar does. * fix(right-panel): answer for the row the pointer is actually on Review of #531 found the new rows promising more than they could keep. Port rows keyed their element id on the port alone, but a port is only unique with its pid — a pre-forking server puts one row per worker on screen, and gpui handed them a single interactive state, so a click on one lit the tooltip and the pressed fill on all of them. The `changes` counts were read off `Tab::git_status`, which resolves a split tab to its *first* leaf, while the click target came from `detail_pane`, which resolves it to the *last focused* one. Inert text could disagree harmlessly; a button could not, and clicking `+2 −0` opened another pane's repository. Both now come from the pane the rest of the rows describe. A port is only `localhost` if localhost reaches it. `lsof`'s bind address was parsed and dropped, so a server on `172.17.0.1:8080` was offered as `localhost:8080` — a refused connection, or somebody else's service. `PortEntry` carries the address (`serde(default)`, so an older daemon still answers), and the wildcard and loopback binds keep the `localhost` spelling anyone would type. The browser tile hung off `remote_context()` — where the *shell* is — though the ports come from the pane's own process tree either way. It hid the tile on the one pane where it works, a `ssh -L` forward listening on this machine. It is about the host now. The action strip is opaque and pinned to the row's right edge, so on the working-directory row it covered the leaf that the head-first elision exists to preserve. The value holds that width back for good rather than on hover: taking it on hover would re-elide the path under the pointer, which is the pixel-shifting the strip is absolutely positioned to avoid. Also: the counts were `flex_1`, so the whole rest of the line was the button and empty space underlined numbers it was nowhere near; the agent pip was pinned in pixels inside rem-sized text and slid off its line at any interface scale but 100%, and drew Waiting as a thin ring where the tab strip punches a hole in a filled dot — one rule, two dialects; the panel-toggle chrome tile, which on macOS lives inside the panel it closes, still dropped focus into the destroyed element and left ⌘J dead; and `scm/detail.rs` kept a third copy of `ROW_INSET`. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
4b7719ba5f |
fix(watch): stop a filesystem watch feeding itself on Linux (#523)
On Linux, an idle window with a repository open ran `git status -uall` about 2.6 times a second, forever. `notify`'s inotify backend subscribes with `WatchMask::OPEN`, so every `open(2)` under a watched directory is an event — and `git status` opens `.git/index`, `.git/HEAD` and `refs/heads/*`, all of which the source control watch covers. The read that answers "did this repository change" was itself an event saying it may have changed, so each answer scheduled the next question. Measured on the Linux runner: 78 debounce bursts in 30 seconds, from one real change. `Debounce`'s doc argued this was impossible because `GIT_OPTIONAL_LOCKS=0` stops `git status` writing the index back. That covers writes; `IN_OPEN` fires on reads. macOS FSEvents has no equivalent, which is why it was invisible on the machine it was written on. `is_content_change` drops `Access(Open | Read | Close(Read))` and keeps `Access(Close(Write))`, at both the host watch and the config hot-reload watch. Guarded by a host conformance case rather than a platform test, verified red on the Linux runner with the filter removed. Also here, and how the above was found: the `render_idle` tests counted frames across a window they advance a virtual clock over, while the pane runs its own git pipeline off a 300ms timer on that same clock that nothing advanced during settle — so the measurement set off the pane's first `git` run and raced its landing. `test_window::quiesce` settles both clocks, and the kernel's, before counting. That turned #523 from a 1-in-50 flake into a deterministic failure, which is what made the watch loop findable. Third: `ssh_config::an_alias_added_to_an_included_file_is_seen_without_touching_the_root` (from #528, already on main) failed on every Windows run of this branch. The cache keys on mtime and the test writes twice inside one ~15ms Windows clock tick, so the cache was right to say nothing changed. Test-only; the poll behind it runs at 4Hz and real edits arrive at human speed. Closes #523. |
||
|
|
e4bd49c39c |
fix(launch): restore the layout when a launch names a directory (#529)
"Open in tty7" from Explorer, and `tty7 <PATH>`, skipped the session restore whenever no window was already up — `restore_session` said nothing about it, the condition was simply "an explicit path was given". The folder arrived as a lone blank terminal in a brand-new workspace and the previous tabs were left behind: still running on the server, still `open: true` in views.json, and reachable only through the switcher. With a window already up the same menu entry had always just added a tab to it, so one entry had two meanings depending on whether the GUI happened to be running. Both shapes now restore first and open the folder as one more tab. The folder cannot be handed to the new window as its first terminal: `Adopt::IfEmpty` declines to adopt into a window that already has a tab, and the pull would then push that one tab back as the whole workspace — writing the layout it was restoring off the machine. So it travels with the hydration and is opened by whichever attempt settles it. A path still declines to follow the layout onto a remote workspace and starts a fresh local one there, which is the case the old blanket skip was really guarding: the directory it names is a path on this computer. Fixes #527 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
a2eab07f8d |
fix(ssh-config): watch every included file an alias was parsed from (#528)
The cache behind `alias_still_resolves` keyed on the mtime of `~/.ssh/config` alone, and `Include` leaves the including file's timestamp untouched — so an alias put back in the file it lived in went on reading as gone, parking its workspaces with no retry and no error. Every file the parse reads is watched now, plus the root even when unreadable. Two tests that failed on CI for reasons outside the code they cover go with it. `pane_history` waited on the seed file existing, but the snippet seeds through a redirection, which creates the file before `tail` fills it. `daemon::singleton` asked for the seat back the instant it dropped it, and a neighbour's fork keeps an inherited descriptor referencing the same flock until its exec. Closes #524. Closes #525. |
||
|
|
ba6e4432ab |
fix(files): follow a coding agent into its git worktree (#526)
The Files tree rooted itself at the foreground process's cwd, read out of the kernel (`proc_pidinfo` on macOS, `/proc/<pid>/cwd` on Linux). That is the right answer for a shell and the wrong one for a coding agent: moving into a git worktree does not `chdir`, so `claude` that entered `.claude/worktrees/feature` still reports the directory it was launched in, and the tree stayed rooted in the main checkout for the rest of the session. tty7 already knows better. An agent's hooks report their own cwd, which rides the OSC stream into `AgentSessionState::cwd`, and the git-status poll has folded that over the process cwd since remote workspaces landed. Three panels read the result — the pane's cwd row, the tab sidebar's path and the source control panel — each with its own hand-rolled `git_status_cwd().or_else(|| …cwd())`. The file tree was the one consumer that never got the memo, which made the disagreement visible inside a single panel: the cwd row said the worktree, the tree below it said the main repo. Give the pattern a name — `TerminalView::effective_cwd`, plus an `effective_host_cwd` for callers that hand the path to a `Host` — and route all four through it. Behaviour is unchanged for the three that already preferred the agent; the file tree now agrees with them. A pane with no agent, or one whose turn just ended, falls back to the process cwd exactly as before, so a stale worktree can never outlive the session that named it. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
c2950fc434 |
feat(ui): forget orphaned remote workspaces when a profile is deleted (#508)
Deleting an SSH profile used to leave every remote workspace entry that had connected through it behind, labelled with a bare internal id and retrying a route that could never work again. `RemoteRef` now carries a `RouteSnapshot` of the profile it was made from — name, user, host, port — written at creation and refreshed on every reopen, `serde(default)` so older session files load. The snapshot serves labels only: `PartialEq`/`Hash` ignore it, or a refresh would split one entry into two. Deleting a profile cascade-forgets the entries routing through it. Forgets, not deletes: `WorkspaceRemove` is never sent, so the sessions on the remote machine keep running and connecting again under a new profile brings them back from the machine's own workspace list. An entry holding a live or in-flight link is left alone, as is one whose window is still on screen — a window whose workspace the store has forgotten reads as local, and its next tab would open a local shell on what the user still sees as a remote box. Whatever survives parks instead: no retries, no error, and an inline action to drop it deliberately. A live or preempted link outranks a lost route. Labels fall back from the live profile to the snapshot to a placeholder, so no branch renders a bare UUID. Resolving the live name reads memory rather than reparsing `~/.ssh/config`, because that path runs on every frame of a window with a remote workspace open. Closes #485. |
||
|
|
7bd26390d7 |
fix(files): replace what a drop lands on only once the copy is whole (#490) (#512)
* fix(files): replace what a drop lands on only once the copy is whole A drop onto a name already taken destroyed what was there before it knew it could put anything in its place: `copy_into_dir` removed the destination and only then started the copy. A copy that fails partway — a full disk, a control connection that drops mid-tree on a remote workspace — therefore left the destination holding neither the old thing nor a whole new one, and the panel reported the failure over a hole where the file used to be. "Replace" gave up the one guarantee it had: that saying no to the copy still leaves you what you had. The copy now lands on a working name beside the destination, what is there is moved aside rather than removed, and a rename — one metadata operation, not a tree walk — puts the new copy in its place; the old one goes only once the new one is there. A failure anywhere puts it back, and in the one case where even that fails it is still on disk under `.tty7-replaced-<name>` instead of not at all. The other half of the same drop is a collision the destination cannot see. Two sources of one drop can carry the same name — `~/a/notes.md` and `~/b/notes.md` dragged in together — and neither is a conflict, because the destination has never heard of either. Both were planned onto the same path, so the second was written straight over the first while the panel reported both as copied: a file the drop said it had copied, gone, with nothing said. One name is one file. The first claim on it stands and the rest are refused with a reason. Three tests, two of which fail on the code they replace: a same-name pair reports one copy and one refusal rather than two copies of a single file, a replacement whose copy cannot finish leaves the original readable, and one that does finish leaves no working files behind. The drop is not in a release yet, so the changelog says this in the entry for the feature rather than in a Fixed line about a bug nobody met. Closes #490 * fix(files): stop the pass that carries the answer from asking again `conflicts` was filled on every pass, including the one that runs after the user has already answered "Replace". The panel prompts on any non-empty `conflicts` (`file_tree.rs:1281`), so the dialog re-opened over a replacement that had already happened — and since that branch is an `else if`, `report.errors` is never read while `conflicts` is set, so the same pass silently swallowed every error it produced: the new `FileDropNameTaken` refusal when a drop both collides with itself and lands on an existing name, and a replacement that failed — the case the staging exists to survive. The field already documented the invariant the code did not keep: "non-empty only when the caller asked without `overwrite`". Keep it, in the one place that fills it. The remaining silence is the narrow one: both renames are a control round trip each on a remote host, and a link that drops between them leaves what was there under the name it was moved aside to. That was a `log::warn!` and nothing else. The panel now names it, so the answer to "where did my file go" is on screen rather than in a log nobody opens. `free_name_beside` hands back the bare name for it — the sentence wants a name, not somebody else's whole remote path. The added test walks the real sequence: ask, answer, replace. It fails on the previous revision with `conflicts == ["note.txt"]` after the replace. --------- Co-authored-by: l0ng-ai <ysdpk123@gmail.com> |
||
|
|
ce4a19ae89 |
fix(remote): let the supervisor's view of a link be the only one (#517)
* feat(i18n): give the strip and the switcher the words for a link that is retrying The strip could count reconnect attempts but never say what any of them ran into, and the switcher had no word at all for a machine between links or for a workspace another client is holding. * fix(remote): let the supervisor's view of a link be the only one Four bugs met in this file's status pipeline, all of them a second opinion about a link that the supervisor already knew better. Take Back said it worked and did nothing (#488). Preempting a GUI client does not drop its control link — the server only hangs up on a `dedicated` one — so the pump's live branch marked the machine Attached and moved on, while the only site that ever sent `WorkspaceAttach` sat in the *reconnect* path, which a live link never reaches. The strip read Attached, `accepts_input` agreed, and every keystroke went into a socket the far end had handed to somebody else. So the live branch now attaches too. `reclaims_due` collects the workspaces the far end has not been told about — the ones a Take Back is reclaiming, and any this client has never spoken for over the link that is up now, because `connect_blocking` brings a link up and stops there and every switcher-initiated connect used to leave the daemon holding nothing. `attaching` keeps a reclaim that is on the wire from being sent again four times a second, `attach_sent` scopes "already told them" to one link, and `finish_reclaim` puts the takeover back — with the name that came with it, which is why `reclaiming` now carries it — when the far end refuses. While a reclaim is in flight the workspace reports Connecting: not Attached, because it is not, and not Preempted, because the button that would say so must not be clickable twice. A window's own failed connect no longer outranks the supervisor (#489). It was returned before `RemoteLinks` was consulted at all, and never compared against the window's own machine, so a failed connect to the GPU box replaced the strip of a window sitting happily on the build box. `resolve_status` takes both, and `clear_window_failures_for` retires what the windows still say once the supervisor gets through — precedence alone would not do, because the switcher reads `ConnectFlow` directly. And a reconnect says why (#498). `launch_attempt` set `Failed(e)` and the next tick overwrote it back to `Reconnecting`, 250ms later, so the reason existed for a quarter of a second. `MachineLink::last_error` outlives that. Retrying for ever stays: this is a laptop lid closed overnight, and giving up would be worse. * fix(switcher): draw a machine from what the supervisor knows, not from a table entry `link_state` read this window's `connect` and then asked whether a `HostLinks` entry existed. The pump drops that entry the moment a link dies, so a machine being retried right now rendered identically to one nobody has ever connected to — same grey dot, same "not connected", and, because the group body is only expanded when the link is not Offline, its workspaces vanished from the panel for the whole reconnect (#497). `Link::Reconnecting` is the state that was missing, and `link_from` builds it from `RemoteLinks::machine_status`, so the panel and the strip now read the same source. Disconnect and the machine menu treat it as engaged: a retry in flight is exactly what Disconnect is for. The route `launch_attempt` could not build wrote `LinkState::Failed(e)` where nothing the switcher read would ever find it. The group's error line now falls back to the supervisor's failure and to `last_error`, rather than having the supervisor write into `remote_host_errors` as well — one source, not two. A takeover leaves the link alone, so a machine whose workspace another client is holding drew as plain Connected with an "Open" badge on the row. It says so now, on the header and on the row it happened to. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
bf9c57dec7 |
fix(ssh): let a rejected stored credential ask again (#519)
* fix(ssh): let a rejected stored passphrase ask again (#486) Saving the wrong passphrase for an encrypted key locked that key out permanently. `passphrase_submit` wrote `SetKeyPassphrase` on the "remember" checkbox alone — before the daemon had tried the secret, since `apply_keychain_write` runs ahead of `respond_active` — and `try_identity_file` treated a stored passphrase as final: a decrypt failure with one went straight to "could not decrypt identity file", with no prompt and nothing in the UI that could let go of it. The daemon now says so. `AuthPromptKind::KeyPassphrase` grows a `rejected` flag, and a stored passphrase that does not open the file falls through to the interactive prompt carrying it, so the typed answer still gets its attempt. A passphrase the user typed this time keeps the hard failure — that is a wrong answer, not stale state. The sheet renders the warning line the password sheet already had, and a rejected prompt answered without "remember" now emits `DeleteKeyPassphrase`, mirroring the password idiom exactly. The flag is a `#[serde(default)]` field on a struct variant of an externally tagged enum, which is compatible in both directions: an older peer never sets it and serde ignores fields it does not know. So `PROTOCOL_VERSION` deliberately does not move — the remote-server handshake gates on it, and a bump would turn away older servers over a field they can safely ignore. `protocol.rs`'s compat test pins both directions. Also: deleting an SSH profile now drops the key-passphrase entries no other profile still references, which is what `delete_profile_confirmed`'s own comment already claimed to do but only ever did for the password. * fix(ssh): stop replaying a stale password at keyboard-interactive (#487) `try_keyboard_interactive` answered a password-shaped round from the keychain, marked the stored password spent whether or not it had been used, and returned on the first `Failure` — so the `MAX_ROUNDS` loop never got a second pass with the stored password withheld. The same dead secret went out on every reconnect and the user was never once asked to type a different one; `ki_submit` always emitted `KeychainWrite::None`, so nothing could clear it either. `collect_ki_answers` now reports where its answers came from, and only a round that actually sent the stored password spends it — which also fixes an OTP-then-password flow that was refusing the stored password for no reason, its first round having burned the allowance on a code. On a rejection whose last round came from the keychain, and where the server still offers the method, the request is started over with the stored password withheld, so the next round reaches the prompt. That retry is bounded twice over: the restart spends the stored password, so no second restart can qualify, and the round counter it shares with the info-request loop caps the method either way. The failure text now says which of the two was turned down. Scope, honestly: the only live scenario is auth mode Auto against a server offering keyboard-interactive but not password, with a stored password for that endpoint — a profile pinned to KeyboardInteractive gets `password: None` and always prompts, and Password never tries KI. Whether the symptom shows also depends on the server: OpenSSH ends a rejected kbdint request with USERAUTH_FAILURE (symptom holds), while a device that re-issues an InfoRequest in the same request already reached the prompt. `AuthPromptKind::KeyboardInteractive` grows a `#[serde(default)]` `stored_rejected`, same both-directions compatibility as `KeyPassphrase`'s `rejected` and the same reason `PROTOCOL_VERSION` stays put. The sheet shows the warning line and, on submit, forgets the rejected password. That needed an endpoint the KI prompt does not carry, which also fixed a bug next door: `raise_routed_auth` called `from_prompt(.., None, false)`, so every routed password write was keyed to port 22 regardless of the real port and the rejected self-heal could never fire there. `PendingAuth` now carries the endpoint and the auto-supplied flag, read straight off the route's `NativeSshSpec`. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
d4643f0532 |
fix(ssh): offer keys in the order the user asked for them (#520)
Every public key offered spends one of the server's `MaxAuthTries` — six by default — whether or not the server wants it, so the order decides who gets locked out when the budget runs dry. Offering the `~/.ssh` defaults before the agent, and offering them on top of a profile's own key rather than instead of it, spent the budget on the keys least likely to be accepted: three stale defaults in `~/.ssh` with the working key in a loaded agent could exhaust the attempts before the agent was reached, and a profile naming its own key came off worse than one naming none. Order the sources by how plainly the user asked for the key: 1. a key the profile names — "use this one" 2. the agent — "I loaded these" 3. the ~/.ssh defaults — nobody said anything, we are guessing Steps 1 and 3 are the same leg, because the defaults now stand in for the identity list only when the profile names no key of its own, the way `IdentityFile`'s default works in ssh_config — verified against the local OpenSSH: `ssh -G` lists five defaults with no IdentityFile and only the named key with one. WezTerm and Tabby both replace rather than append too (wezterm-ssh/src/config.rs:589, tabby-ssh/src/session/ssh.ts:194). The file-vs-agent order has no single convention to follow — WezTerm asks the agent first, Tabby reads files first, OpenSSH merges the two and trims the agent's extras with `IdentitiesOnly`, which tty7 does not parse. This order agrees with OpenSSH and Tabby when the user named a key and with WezTerm when they did not. Dedup by canonical path goes with it: the two lists are alternatives now, so there is nothing to dedup between them. `auth_steps` and `identity_offers` carry the two rules as pure functions, so both are unit tested instead of living inside the async round. The GUI's keychain preload follows the same rule, so both sides still key passphrases by the same strings. Closes #513. |
||
|
|
16ef93693f |
fix(settings): confirm before forgetting a password other profiles share (#510)
"Forget Password" was a bare menu item: one click deleted the keychain entry, with no confirmation and nothing said about who else it took down. The entry is keyed by `user@host:port`, so two profiles that reach the same endpoint — one direct, one through a jump host — share exactly one secret, and forgetting from either row signed both of them out. The notification even worded itself by endpoint while the action hung off a single profile's menu. It now asks first, the way deleting a profile does, and when the endpoint is shared the dialog names the blast radius instead of leaving it to turn up at the next connect on a host nobody touched. Deleting a profile stays conservative on purpose — the menu that could remove the secret is about to disappear — so the two paths keep their different policies. What they no longer keep is two copies of the "is this endpoint shared" question: `profiles_sharing_endpoint` is now the one place that answers it, and it has the test. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
01969ef6bb |
fix(settings): say what an ssh config import added, updated and could not keep (#515)
Importing from ~/.ssh/config was silent in three ways. A missing or unreadable file did nothing; a file of nothing but `Host *` and `Match` did nothing; and a successful import did nothing visible either, so the only way to learn what had happened was to go count the host list. Options tty7 has no field for — IdentityAgent, CertificateFile, AddKeysToAgent and the rest — were dropped without a word. They still are, because there is nowhere to put them, but the import now names them and the hosts that set them instead of pretending they were kept. Parsing keeps each keyword's original spelling alongside the lowercased form it matches on, and `option_is_supported` is the one list both the resolver and the report read, so the two cannot drift. Ignored options are grouped per Host block rather than per resolved alias: a keyword under a two-alias `Host` line is one omission, not two, and `Host *` noise stays out of the report entirely. `merge_imported` now returns added/updated/unchanged, comparing the six fields it writes before it writes them — so re-importing an unedited file reports six hosts unchanged rather than six updated. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
9c9a18f410 |
fix(settings): refuse a half-filled SSH profile instead of saving it (#511)
The SSH profile form saved whatever was in it. An empty host wrote a profile that renders as a blank row in the host list and hands `TcpStream::connect` an empty name — and Connect had no gate at all, so the first thing the user heard about it was a DNS error naming nothing. A jump host with a typo in it resolved to `None` and saved as a direct connection, with the field blank the next time the form opened. And `parse_host_port` was `parse().unwrap_or(0)`, so `proxy.example.com`, `proxy.example.com:` and `proxy.example.com:88O` all saved a proxy on port 0, which the socket layer got to explain (#492, #493, #494). The rules now live in `validate_ssh_draft`, a plain function over a plain-String snapshot of the form, which returns both the profile the form would save and what is wrong with it. Both, always: the Escape prompt asks whether the form differs from the config, and handing back only the errors would make a brand-new invalid profile compare equal to the nothing on disk — Escape would throw the typing away without asking. Only the host is required. A name is not, because the list already falls back to the address and every host imported from ~/.ssh/config arrives without one. A blank port still means 22, but a non-empty one has to be a port, so "0", "abc" and "70000" are refused rather than saved as written or quietly rewritten. A proxy address with no port takes the scheme's default (1080 / 8080) and `host_port_text` writes that back into the field, so the number it picked is visible; a colon with nothing usable after it is an error. `map_proxy` also stops treating port 0 as a proxy, because configs written before this are already on disk. Each complaint prints under the field it is about, and Save and Connect are disabled while any of them stands; a section holding one unfolds so the disabled button always has a visible reason. The "needs a host" line waits until the name/host/port/user group has something in it — every field notifies per keystroke, so otherwise a new host would be told off before anyone had typed a character. Consequence: on a pristine new form Save is now disabled where it used to be enabled. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
efe345174b |
fix(ssh): stop a new host-key algorithm from reading as a compromise (#516)
* fix(ssh): stop a new host-key algorithm from reading as a compromise A host that grows an ed25519 key beside the ssh-rsa one it has always had raised the full man-in-the-middle sheet — red border, fingerprint diff, a "type yes" field — because `check_in_str` folded "known by another algorithm" into `HostKeyStatus::Changed`. OpenSSH treats a key of an algorithm the host has no entry for as simply unknown, and saves the alarm for a key that contradicts one on file. `ChangedAlgorithm` splits the two apart, with `Changed` keeping precedence so a same-algorithm mismatch still screams however many other-algorithm lines sit beside it. The dialog was only half of it. Negotiation started from russh's default order, which leads with ed25519, so a host known only by ssh-rsa was *asked about on every single connection* — and an attacker could pick an algorithm the user had no entry for to trade the alarm for the mild confirmation. `build_preferred` now orders the host-key list the way OpenSSH's `order_hostkeyalgs()` does: what is already on file goes first, nothing is dropped, and a pinned `HostKeyAlgorithms` is left alone. It matches on key type, so all three RSA spellings travel together rather than pinning the host to SHA-1 signatures. The prompt reuses `AuthPromptKind::HostKeyUnknown` with an added optional field rather than gaining a variant: the enum is externally tagged and crosses both the daemon/GUI and the GUI/tty7-server boundaries, where a new variant is a hard decode failure on an older peer and a new field is not. Also fixes a defect the issue did not mention: overriding a genuinely changed key appended the new line without removing the old one, and since any same-algorithm match answers `Known`, the superseded — possibly attacker's — key stayed trusted forever, silently. The superseded line is now dropped first, and only lines naming this one host are touched, so a wildcard or `@revoked` entry is never collateral. * fix(ssh): make the Override button on a changed host key actually override `host_key_changed_decision` returns `accept: false` for anything but "yes", which is byte-for-byte what Abort sends — and the button had no disabled state and closed the sheet unconditionally. So clicking Override with an empty field rejected the key and dismissed the prompt, indistinguishable from having aborted, with nothing said. Enter on the input had the same trap. Override is now dead until the word is there, which is what the line above the field has been claiming all along, and Enter on a half-typed answer leaves the sheet up instead of quietly deciding. `changed_confirmed` is the single predicate behind both, so the button and the decision cannot disagree about what "yes" means. `host_key_changed_decision`'s `false` branch stays as defence in depth. Both input subscriptions also notify on `Change`, or the enabled flag would go stale between keystrokes, and a hint appears once the field holds something that is not "yes". Abort is untouched: still primary, still last. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
c91f0cf660 |
fix(forwards): say why a forward was refused, and never lose the rule it replaces (#514)
The managed-forward form in the Forwards panel used to bare-`return` from Add whenever the fields did not make a rule — an unparseable bind port, a target port of 0, an empty target host for a Local or Remote rule. The button did nothing, said nothing, and left the form exactly as it was. It now collects the fields through the same conditions the settings sheet applies (`ForwardRuleForm::collect`), keeps Add disabled while there is nothing to add, and shows the settings sheet's own "needs both" / "needs a listen port" line under the form once it has been touched. Saving an edit removed the old forward and then added the new one, so a rule that could not be started took a working forward with it. Remove-then-add is still the order — the ordinary edit keeps the bind port, and adding first would collide with the very rule being replaced — but the panel now looks at the entry the add appended, and on `ForwardStatus::Error` it takes that entry back out, puts the old rule back, and keeps the form open with the reason under it. `mf_editing` carries the whole `ManagedForward` rather than an id so there is something to put back, and it is re-pointed at the restored entry, which comes back under a new id. Adjacent, and the reason a failed request could not be told from a successful one: `ForwardRoute::add`/`remove` and their `RemoteTerminal` fallbacks turned every transport error into an empty `Vec`, which the caller then assigned straight into the panel's list — so one unreachable round trip blanked a panel full of live forwards. They return `Option` now, and the panel only takes a list the far side actually sent. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
f2fe829cb6 |
fix(sftp,forwards,files): tell a failure apart from an empty result (#518)
* fix(sftp): stop a failed poll from reading as an empty transfer list
A transfer poll that could not reach the daemon answered with an empty
`Vec`, which is indistinguishable from "every transfer is gone": the tray
disappeared and every upload the panel was waiting on counted as landed,
so a spurious "the upload finished" refresh fired. Over a link that is
down that is the permanent answer, not a blink.
`SftpRoute::transfer_list` and `RemoteTerminal::sftp_transfer_list` now
report the failure the way `sftp_list` already does. A failed poll keeps
the jobs the panel last saw, settles nothing, and says so in the transfer
tray through a new `jobs_error` — kept apart from `SftpPanelState::error`,
which blanks the directory listing a poll knows nothing about.
* fix(forwards): let a forward whose loop has exited say so
`ForwardEntry.status` was written once when the forward was set up and
never touched again, so a local or dynamic forward went on reporting
`Listening` after its accept loop had already exited. The pane outlives a
dead SSH transport on purpose, the daemon keeps it while a subscriber is
attached, and the panel re-polls every 2s — so the stale `Listening` is
not a blink but the permanent answer. `nc` to the port gets accepted once
and refused thereafter while the panel still shows it as live.
The status is now an `Arc<Mutex<ForwardStatus>>` shared with the task, and
both break arms record why they left: the listening socket closed, or the
SSH connection went away. `ForwardStatus::Error` carries it rather than a
new variant, because the enum crosses the protocol to `tty7-server` builds
that would not know one. `find_auto_local` reads the live status too, so a
loopback link is no longer reused after its forward has stopped serving.
A remote forward has no accept loop of its own — the far end opens the
channels — so it keeps whatever the `tcpip-forward` request answered.
* fix(files): tell a failed search from an empty one, and name the file a write failed on
Two ways the file tree answered a failure with something that reads as a
result.
A search was `unwrap_or_default()`ed inside the worker, so a host that
refused the walk left `hits` empty and the column printed "Nothing matches
{query}" — byte-identical to a genuine zero-hit search. The worker now
reports `(ok, hits)` the way `spawn_load` already reports a listing, and a
failed walk draws a `SearchFailed` note in the danger colour, the same
distinction `FileTreeState.unreadable` draws for a directory.
Creating and renaming pushed the bare `io::Error`, so the toast was
literally "Permission denied (os error 13)" — neither which file nor what
was being done to it. Both now go through `HostOps::notify_err` like
delete and drop-copy already do, naming the file; a rename names the name
it is leaving, which is the one still on screen.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
|
||
|
|
364e24af87 |
feat(ssh): probe ~/.ssh default identity keys (#507)
A connection with no explicit IdentityFile used to offer the server nothing at all when the agent was unavailable — the default on Windows, where the OpenSSH Authentication Agent service ships disabled — and then reported "no public key was accepted", for keys it had never sent. Offer the `~/.ssh` defaults (id_ed25519, id_ecdsa, id_rsa) after the explicit identities and before the agent, from one shared candidate list in `core::ssh_profile` so the GUI and the daemon key `key_passphrases` by the same strings. Candidates are deduped against the explicit list by canonical path, comparing the expanded paths the reader actually opens. A discovered key that is encrypted is used only when its passphrase is already cached, never prompted for; explicit keys keep prompting. Files that do not exist are skipped in silence, a `.pub` is never offered as a private key, and the failure text now separates "the server turned these down" from "nothing usable was found". The tests build their ed25519 fixture at run time from a fixed seed rather than embedding a PEM blob, so the tree carries no private key. Closes #484. |
||
|
|
72060d8257 |
fix(ui): stop washing out the workspace discs in the switcher (#483)
The monogram disc beside every workspace row was drawn at opacity 0.55 unless that row was the current workspace, and the initial inside it is already the foreground at 0.65 — so the letter landed at about 0.36 and went illegible on exactly the rows the panel exists to let you pick between. Only one row in the list is ever the current workspace; the other however-many all got the dimmed treatment. The liveness dot is painted on the wrapper rather than inside the disc, so it never dimmed with it: a washed-out grey blob with a full-strength green dot stuck to its corner, which is what made the column look dirty rather than quiet. Drop the dimming. Nothing is lost by it — the current workspace already carries a "this window" badge, a medium-weight name and the selected background, so the disc was saying a fourth time what three louder things had said. `current` was the only reason the helper took that argument, so it goes too. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
c4645aeea8 |
feat(terminal): draw a scrollback scrollbar down the right edge of a pane (#480)
A pane's scroll position lives in alacritty's `display_offset` — rows of scrollback, not pixels of laid-out content — so it has no `ScrollHandle` to hand a scrollbar. `TerminalScrollHandle` implements gpui-component's `ScrollbarHandle` over the grid instead, which lets the pane draw the same `Scrollbar` the sidebar and every list already use: same theme, same `Scrolling` show mode, same fade-out. The bar never touches the terminal. `set_offset` only records the row it wants; `sync_scrollbar` applies that on the next render — clearing the sub-line remainder and cancelling an in-flight smooth scroll on the way — and reports back where the grid actually ended up. Scrollback piling up at the live edge is deliberately not reported: the bar shows itself whenever the offset it reads changed, so a pane printing a build log would otherwise hold a thumb on screen for as long as the output ran. Every other change passes through, including the history shrinking, which is a cleared scrollback rather than growth. Closes #432 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
9735a490b5 |
Merge pull request #479 from l0ng-ai/perf/wsl-tab-open-cost
perf(wsl): stop re-proving the distro on every new tab |
||
|
|
f676fb96de |
perf(wsl): stop asking twice whether a distro is ready
Every pane on a WSL workspace ran `ensure_wsl_server` from the client before it even connected to the daemon — and then the daemon ran the very same probe inside `router::open_link` before opening the link. Two full rounds of five serial `wsl.exe` calls, to learn one fact. The client's copy bought nothing. It threw the answer away and kept only the error, which the route ack reports just as well; and the consent question for a first install still finds its way here, because the daemon runs its probe under `RouteSetup::blocking`, which installs the relay that turns that question into a frame on this connection. Measured on a distro that was already running and connected: 800ms to open a tab, down to 440ms. Issue #454 is the same code on a machine where one `wsl.exe` round trip takes 3.3s, where the duplicate was costing 15s a tab. |
||
|
|
af3928da1e |
fix(tree-sync): pay back a remote window's owed tree pull (#472)
* fix(tree-sync): pay back a remote window's owed tree pull A window opening onto a remote workspace is empty until `hydrate` pulls the machine's tree and rebuilds its tabs from it, and it has to be: an empty window diffs into "close every tab", so `sync_window` holds anything back until the pull lands. When the pull fails, `owe_rehydration` records the debt and returns, on the promise in its own doc comment that the next sync settles it — "which is what a reconnect does through `on_link_up`". `on_link_up` is called for `HostId::LOCAL` and nowhere else. On a remote host the debt was only ever settled by a reconnect completing, by an edit in the window, or by restarting the app. So a pull that failed while the link stayed up was never noticed again: no reconnect, and an empty window has nothing in it to edit. The window sat on the home page with every tab and every shell still on the machine, and only a restart brought them back. Two ways to fail a pull with a healthy link, both routine. A `MachineGet` can overrun its ten seconds on a slow link. And a `WorkspaceCreate` can lose its race with `start_prime`, which runs the same create from the other side of the same window opening — that one fires on every remote workspace opened, and is only invisible because the workspace it usually lands on is empty anyway. So: arm a backed-off retry when the debt is taken on, drive it through `sync_window` where the rules about whether a window may still adopt the machine's layout already live, and stop treating a lost create as a failure — read the tree again and hydrate from what is really there. `on_link_up` is also wired to a remote link coming up, which is what the comment always claimed: a link the switcher connects finishes no attempt, so nothing told its windows the machine could be reached. * fix(tree-sync): end the backoff with the run of failures, and stop shouting Review follow-ups on the owed-pull retry. The attempt count paces the retry, so it has to mean "failures in a row", but it was only cleared when a hydration landed. A debt abandoned rather than paid — a `Replace` dropped because the user filled the window in themselves — and a prime that landed both left it standing, so the next first failure waited the 30s cap on an outage that was already over. It is now cleared wherever the run ends. A window left open on a machine that is really gone retries forever by design, which meant a warn and an info every ~45s for as long as it stayed open. Once the backoff settles at its cap those lines stop being events and become a fact about the machine, so they step down to debug. The retry is exactly as persistent; only the volume drops. Also: report the create's own refusal when the reread finds the workspace still missing, and say at debug that the reread happened at all — the race recovery was silent, so the extra round trip was invisible when reading a log. And correct the comment on the window-gone guard: closing a window drops its whole `WsState` through `forget`, debt and all, so nothing is parked for the next opener. Tests: the count ends with the run at all three sites, the level steps down at the settle point, and the armed retry is driven through a real timer (advance_clock) into the window-gone guard — the first coverage of the retry actually firing rather than of the predicate it consults. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
12df66fb0f |
fix(ui): dim panes by blending terminal colors toward the window background (#464)
* fix(ui): dim panes by blending terminal colors toward the window background * fix merge * fix(ui): tighten the pane-dim blend |
||
|
|
0f5e63701e |
fix(ui): let the overlay scrollbars fade out again (#471)
* fix(search): wash a match in the accent, at a strength the theme can afford A search hit was washed from the terminal palette's selection colour at a fixed 1.45:1 against the background, so it read as a weaker selection on a grid that is already grey on grey — and 1.45:1 is under what a hairline is worth, spread over a whole cell. Two changes. The tint is now the theme's accent (`ActiveAccent`, already floored at 3:1 by `legible_accent`), which is the one colour the terminal surface has nothing else in. And the strength is derived per theme instead of fixed: the wash is opaque with the glyph drawn on top, so what it may spend is the theme's own text-contrast budget. A palette with 21:1 between text and background can afford a wash you cannot miss; one with 6.6:1 cannot, and a single constant has to be safe for the second. The current match drops its caret-coloured outline. That existed because a fill 2.1:1 off the background could not say "this one" on its own; now that it sits at the top of the theme's budget, the outline is the same colour saying the same thing twice. * fix(ui): let the overlay scrollbars fade out again macOS reports should_auto_hide_scrollbars() = false for anyone with a mouse plugged in, and apply_theme turned that into ScrollbarShow::Always for every list in the app. That preference is about legacy scrollbars, which take a gutter out of the layout; ours are overlay bars painted on top of the content, so Always parked an opaque bar over the switcher's tab column for as long as the panel stayed open, with nothing to fade it. Pin scrollbar_show to Scrolling instead, so every list fades its bar out after it stops scrolling. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
99a388331c |
fix(search): wash a match in the accent, at a strength the theme can afford (#470)
A search hit was washed from the terminal palette's selection colour at a fixed 1.45:1 against the background, so it read as a weaker selection on a grid that is already grey on grey — and 1.45:1 is under what a hairline is worth, spread over a whole cell. Two changes. The tint is now the theme's accent (`ActiveAccent`, already floored at 3:1 by `legible_accent`), which is the one colour the terminal surface has nothing else in. And the strength is derived per theme instead of fixed: the wash is opaque with the glyph drawn on top, so what it may spend is the theme's own text-contrast budget. A palette with 21:1 between text and background can afford a wash you cannot miss; one with 6.6:1 cannot, and a single constant has to be safe for the second. The current match drops its caret-coloured outline. That existed because a fill 2.1:1 off the background could not say "this one" on its own; now that it sits at the top of the theme's budget, the outline is the same colour saying the same thing twice. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
fafcaa0685 |
feat(splits): draw the pane grip as Ghostty draws its own (#463)
The bar a pane was picked up by grew and recoloured under the pointer, and showed the moment the pointer was anywhere in the pane at all. It was loud in the wrong places: a mark on top of the terminal wherever the mouse happened to rest, and a target that moved while being reached for. Cut to the shape Ghostty gives its grab handle instead: * three dots, 80x12 of reach around them, and nothing between the two states but ink — 0.3 in the band, 0.8 on the grip itself. * the dots are asked for by the pane's top fifth (floored at 24px), not by the whole pane, so the terminal is left alone everywhere else. * the target is there for as long as the pane can be moved, and only the dots come and go, so a pointer going straight for the top of a pane can press the grip on the frame it arrives. * a 150ms fade in, so the dots read as arriving rather than blinking. The fading a pane is under is now worn by the terminal it holds rather than by the pane, which keeps the grip legible on the very panes `dim_inactive_panes` fades — the ones being reached for. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
a55340ed7f |
fix(daemon): sweep a dead daemon's leavings on the writer's tick, not at startup
Review follow-ups on this branch. `history::sweep` still ran at startup, three lines under a new comment explaining why sweeping there is wrong. The reasoning transfers exactly, and worse than by analogy: a restore carries the dead pane's commands to its successor via `history::carry`, so sweeping before the window can ask deletes the file the request is about. Same shape as the scrollback bug, one file over. Both sweeps now run on the writer's tick off one shared id set, and the writer is named for what it does. `pane_attachable` lost its only caller when the restore path moved to `pane_free_for`, leaving a function kept alive by the test asserting on it. The attach site does not need to predict the listing: it tries the attach, and a pane that is gone falls through to the fresh spawn on its own. Gone, with its tests folded into `pane_free_for`'s. `restored_screen` now drops the snapshot in both directions. Keeping the file when it decoded to nothing left it to be re-read and re-rejected by every later restore, and swept never, for a pane the tree still names. Also: the module doc still said scrollback was off unless asked for, which is what this branch reverses; and #449 landed the whole feature with no CHANGELOG entry, so nothing told anyone that pane output now lives on disk. |
||
|
|
852d3178c8 | style: rustfmt | ||
|
|
477d82524f |
feat(daemon): keep every pane's screen, without asking
`persist_scrollback` is gone, and with it the switch, its three translations and the branches that read it. Keeping a capped tail of each pane's output is now what the daemon does, not something it can be asked to do. This reverses the call made when the feature landed. The argument for off-by-default was that the ring holds whatever the pane printed — echoed tokens, `env` output, an agent's transcript — and that writing that down should be the user's decision to make. What the argument missed is when the decision gets made: the moment anyone learns they wanted this is the moment a daemon has already died, and by then the setting could only be turned on for next time. A feature whose entire purpose is to survive an event nobody schedules cannot be opt-in. The cost is real and does not go away: pane output now lives at `<config>/scrollback/*.bin` on every machine, 0600 on unix and behind the config directory's ACL on Windows, capped at 256 KiB per pane and dropped as soon as no window can still ask for it. Old configs naming the key still parse — nothing in `Config` refuses unknown fields — so the key simply stops meaning anything. |
||
|
|
412bfcfc90 |
fix(session): let a dead pane keep its id so its screen can be asked for
`pane_attachable` answered one question and was used for two. Deciding whether to attach needs to know the pane is alive; deciding which dead pane a fresh one replaces needs only its id — and that is the case the stored screen exists for. Using the first answer for the second was self-defeating. After the daemon restarts, every pane the window held is missing from the new daemon's listing, so the id was ruled out, so `restore_pane` was `None`, so the window spawned a pane that had never heard of a predecessor. No attach was tried, no restore was requested, and the screen the daemon still had on disk was swept a tick later without anyone reading it. The setting was on, the snapshot was written, the daemon was ready to hand it over, and nothing ever asked. Ownership still rules an id out, because another workspace's pane is neither ours to attach to nor ours to show. Liveness no longer does: the attach is still tried first and still gives way to a fresh spawn when the pane really is gone, which is the arrangement the tree path already argues for at length — `live` is a hint about what to show, never the judge of what to destroy. |
||
|
|
3093babddd |
fix(pane): say when a restore is not asked for
Dropping the request here produced a blank pane, which is also what a pane with nothing stored looks like and what a daemon that refused would produce. Three causes and one appearance, with nothing anywhere to tell them apart — the filter was silent, so reading the source was the only way to find out which had happened. |
||
|
|
c138be687a |
fix(daemon): keep a pane's shell and its screen across a restart
Two things a pane lost when the background service stopped and started, both of them things the tree was the only possible place to keep. **The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh spec and its agent, but never what it was running. A window rebuilding a dead pane from the tree therefore had nothing to pass and spawned on whatever the default shell is now — so a restart turned a bash pane into a PowerShell one, quietly and in place. The daemon resolves the override against the config at spawn time and is the only party that knows the answer, so it keeps it and reports it; the seed carries it too, for the panes a window spawned itself. A handoff carries it in the blob, because nothing on the far side of an `execve` can work out the command line of a child it never spawned. **The screen.** The startup sweep ran before the endpoint was listening, which is the one moment nothing can answer the question it asks: the registry is empty and the windows that know which screens are still wanted cannot say so yet. A tree that failed to parse made it worse — `read_machine` quarantines it and returns an empty `Machine`, so one bad file took every pane's stored screen with it. The sweep now happens only on the periodic pass, a tick later, with the registry filled in and the tree caught up; nothing is serving a request in between. Turning the setting *off* still clears the directory at once, because there the promptness is the whole promise. Two smaller ones alongside it: `restorable_pane_ids` now counts the tree's pane list and not only the panes some tab currently stands on — the two disagree while a window is between layouts, and being wrong costs a file swept a tick late in one direction and somebody's terminal in the other. And `restored_screen` drops the snapshot file *after* deciding it was not empty, so a snapshot holding nothing is no longer consumed by the request it could not answer. The restore path had no end-to-end test, which is how this shipped: the unit tests cover the file, not whether a window that reattaches is shown anything. The new one runs a real daemon, puts a marker on a real pane, stops the daemon, starts another, and reads the wire. |
||
|
|
2dc6a88af6 |
merge: main into the Source Control branch
Conflicts were the two streams touching the same seams, resolved by taking the newer decision on each side: - main's interface font scale (rems tokens) wins in right_panel.rs; the SCM panel keeps its local px steps until it moves onto that scale, and the now-unused PANEL_TEXT constants are gone. - main's l10n_keys! macro (idents only) means the key list carries no doc comments any more; our SCM keys fold into it, and PanelUntracked stays deleted — its only caller was the panel this branch replaced. - main's Command::localized palette style carries our Git group; ORDER keeps main's visibility and our width. - main's ansi_seed/clear_ink refactor in presets.rs carries the lane colours: lanes() now clears through the same helper semantics uses. - file_tree keeps both: main's drag-and-drop targets and this branch's git decorations per row. - diff_overlay keeps both: main's sidebar-count write-back on snapshot install and this branch's epoch read and untracked preview. - main's window.prompt SSH-close confirmation supersedes the bespoke modal our branch still carried; main's tile-glyph revert stands. - main's two new guards are satisfied: the fourteen SCM actions carry authored names on the Keybindings page (their palette wording, plus a new CmdGitToggleGraph), ja translates ScmDetached, and CmdGroupGit joins the kept-in-English list — Git is a name. 2571 tests, 0 failures. |
||
|
|
feb027da1f |
feat(scm): show an untracked file's content when its row is opened
Focusing an untracked file in the diff overlay used to fall through to the names-only "Untracked files (N)" card — git has no patch for a file it does not know, and `--no-index` needs a null device whose spelling is platform business. The overlay now reads the file's own bytes (lazily, only the focused file, 4 MiB cap) and synthesizes the card a parsed added-file patch would produce: every line an addition, new-side numbers, true counts past the single-file budget, git's own NUL-in-the-first-8000-bytes binary rule. A fresh snapshot clears the preview so an edit shows up on the same cadence a tracked file's does; a failed read says so instead of showing an empty file. Found in manual acceptance of the panel. |
||
|
|
1df43b72b5 |
feat(files): copy dropped files into the folder they were dropped on (#458)
* feat(files): copy dropped files into the folder they were dropped on The Files panel has only ever been a drag *source* — a row dragged into a terminal inserts its path. Nothing on the tree ever registered a drop, so a file dragged in from the desktop did nothing at all, not even a highlight. Closes #453. The drop is the whole gesture: files land where the cursor was, not somewhere a dialog asks about afterwards. A folder row takes them itself, a file row stands in for the folder holding it — "next to this one" — and the space the rows do not cover belongs to the top of the tree. The placeholder inside an empty folder takes a drop too; it is the only thing drawn there, and letting it fall through to the root would put files somewhere the cursor never was. A row under the cursor wins over the column, which is what gpui's innermost-first dispatch already does. The copy itself goes through the `Host` the tree is listing, so a remote workspace reads here and writes there. Locally it is `fs::copy`, which is what keeps the executable bit that `write_file` would drop; remotely the bytes ride one control frame, and a file too big for that is refused with the advice to use SFTP rather than half-sent. Names already taken are asked about before anything is written, and the answer governs the whole drop — a half-done copy would have to be undone to honour a "no". Replacing a folder replaces it rather than merging into it. A drag let go where it started is a miss, not an error, so it says nothing. * fix(sftp): list the directory again once an upload lands An upload is written to `<name>.tty7-upload-<hex>` and renamed into place at the very end. The browser listed the directory the moment the transfer was handed to the daemon, so it caught that temporary name — and nothing ever listed again, so a finished upload sat on screen as a file with a hash glued to its name until the directory was navigated by hand. The premature listing is gone, and the panel now remembers the job ids it started: once one stops running — done, failed, cancelled, or dropped off the job list entirely — the directory is listed once more. Two uploads in flight settle independently, so the second one finishing does not depend on the first. * docs(changelog): note the SFTP upload listing fix * ci(host-boundary): allow the source side of a file drop, and stop scanning two files as empty The Files panel now copies dropped files in, and what the desktop hands over is by construction a path on the desktop's own machine: reading it is a local read even when the tree being dropped on is remote. The destination side goes through `Host`, and the one `std::fs::copy` that touches a destination sits inside a branch already gated on `host.id().is_local()`. While adding that entry: `attr` starts unset, which awk reads as 0, so a file whose first line is `mod something` matched `attr == NR - 1` and cut its body at line 0. `head -n -1` then errored and the file was scanned as empty — `src/terminal/mod.rs` and `src/ui/tray/mod.rs` both open that way, and the guard had been blind to both. Neither contains a violation, so seeing them is free. |
||
|
|
58d7ef5838 |
fix(scm): close out the review's minor findings across the data and UI layers
The second pass over the branch review: every remaining finding verified against the code, the real ones fixed. Data layer: - A truncated log parse is never called complete: RecordSplitter drops an overlong record whole and reports the count (delivered cut short, a commit body cut mid-way reads as the real message), parse_log carries a truncated flag past MAX_LOG_BYTES, and load_page only says "end of history" when the parse read everything git returned. - Every scope pins symbolic revs to shas before walking, so a commit landing between two pages can no longer shift where page two starts under Head and Refs scopes; unresolvable names read as "no history" rather than as a load failure. --parents was doing nothing and is gone; edge sort is stable so a merge's Outs keep first-parent order. - The lane model's central invariant now names the join case — a merge whose second parent already has a lane reserved sends its Out onto that lane, one line below the cut, not two — with a golden test for the commonest merge topology of all, which no golden covered. - DiffSource revs get the same could-be-an-option guard log already had; C-quoted paths decode the full escape set (a tab decoded to a literal t broke the :(literal) re-probe); rename from/to lines override the ambiguous diff --git header; combined-diff line numbers follow the sides rather than the colour, so a " +" line no longer drifts every number below it. - A rename's old path stays out of the per-file decoration map, where it outranked a file re-created at that path; ignored records decorate as Ignored, not Modified; checkout <branch> gains the trailing -- that keeps a stale name from falling back to a worktree-clobbering path checkout; unstage before the first commit takes -f (worktree- safe with --cached); batches split by bytes as well as count for Windows' 32K command line; a deadline expiry reports Timeout, not "git could not be run"; error details keep both streams. - probe_status distinguishes "not a repository" from "could not ask": a dropped link keeps the cached status (stale beats blank) and rests 10s instead of erasing the panel, while a definitive not-a-repo also drops the cwd→root mappings so the panel stops drawing Loading for a repository that is gone. Probe and watch work are wrapped against panics that would wedge their in-flight bookkeeping forever, watch landings check the wipe counter, superseded probes relaunch through the debounce, and a refused network slot says so instead of eating the click. UI: - Reset --hard confirms with its own words (commits fall off the branch), not the discard dialog's; a merge commit whose prefilled message the user cleared is committable again; the disabled commit button distinguishes "nothing to commit" from "write a message". - Selection highlight matches on the diff source too, so a file staged and edited again no longer lights both of its rows for one overlay. - The graph materializes only the rows in the viewport window (5000 flex children per frame was most of a frame), row clicks carry the page Arc and an index instead of a deep Commit clone per row per frame, filter results are cached per (page, query), and a selected merge ring's hole matches the selection band under it. - A failed commit_files read says the list could not be read instead of "0 files changed"; the STAGED chip and the graph's relative times go through the i18n table; the keys-awaiting-a-caller list is pruned to the seven that still are; the orphaned PanelUntracked key is gone; the zh commit placeholder reads naturally. 2398 tests, 0 failures. Known flake: daemon::singleton's second-claim test, untouched by this branch, fails ~1 in 3 full parallel runs and passes alone. |
||
|
|
5f1ee966ec |
fix(windows): give the pane grip and drags in flight a cursor (#455)
Win32 ships neither an open- nor a closed-hand cursor, and gpui's Windows backend answers both with the plain arrow. The pane drag grip asked for `cursor_grab()` and so read as ordinary background there, and the pointing hand the sidebar's group header had worked around it with was dropped again the moment a drag began, since the active drag cursor is `ClosedHand`. Lift that workaround into `reorder::cursor_grab` so the grip and the group header share one answer, and pick the held cursor per platform too. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |