Commit Graph
31 Commits
Author SHA1 Message Date
l0ng-ai b5c20932cb fix(diff-overlay): keep the row key pointing at the snapshot that landed
`sync_diff_rows` wrote `rows_key` only when it rebuilt the rows. A probe that
found nothing new lands a fresh `Arc` over an equal snapshot, so `describes`
answered yes the long way — by walking every line of the patch — and then left
the key holding the *old* `Arc`. Every frame after that paid the same walk:
once per wheel event, over the ~1 MB of `String` at the 20k-line budget, which
is the cost the key exists to avoid. Retarget it on a match, so the pointer
comparison settles the next frame.

The preview was doubly exposed: `install_diff_snapshot` clears it on every
landing, so the re-read produces a fresh `Arc<FileDiff>` too. Its comparison
also ran eagerly, ahead of the scalars that could already have answered no;
move it into the `&&` chain.

`stack_corners` lost its last non-test caller when the file cards went, and
`cargo build` had started reporting it. Drop it and the test that held it up.
2026-09-07 20:59:46 +08:00
l0ng-ai ecf3ff3577 perf(diff-overlay): draw the patch as a virtualised row list
The overlay built its whole patch as a nested element tree on every frame:
a card per file, a header per hunk, six elements per line. gpui notifies the
view on each scroll wheel event, so a few hundred lines of diff rebuilt tens
of thousands of elements tens of times a second, and the window stalled.

Flatten the tree into one row per line in a new `diff_list` module and draw it
with `gpui::list`, which builds only the rows on screen. The rows are rebuilt
only when what they are built from changes, so scrolling no longer re-splits
hunks or re-clones every line, and a change to one file splices just the rows
it touched rather than resetting the list and losing the scroll position.

A card cannot survive that flattening — its rows are separate items now — so
the frame it drew is gone, and with it the grey header bars and hunk bands
that made the overlay the one view in the app still speaking gpui-component's
default container language. The rows take the source control panel's own
measurements instead: 26px, 10px inset, 5px radius, colour only under the
pointer. The title bar's view switch loses its border for the same reason.
2026-09-07 19:53:12 +08:00
l0ng-ai 958d8b7442 feat(window): dock the code panel and the diff overlay beside the terminal (#625) (#685)
* feat(window): dock the code panel and the diff overlay beside the terminal (#625)

Opening a file covered the workspace. The terminal underneath kept
running and was neither visible nor typeable, so reading a file while an
agent talked was a toggle loop: open it, close it to read the reply, open
it again. The Files tree already docks; the two surfaces you go to *from*
it did not.

They dock now, as a flex sibling of the terminal column rather than a
narrower overlay — that distinction is the feature. `set_grid_size` is
driven by the terminal element's laid-out bounds, so a column takes width
away from the grid and the PTY reflows into what is left; a card painted
over half the workspace would have left the grid full width with half of
it hidden.

`overlay_top` stops ordering a pair and starts choosing between them: a
column has one child, and two `flex_1` siblings would split it and fight.
Fill mode keeps the old vector, the old opaque paint and the old platform
hoist untouched, so nothing about today's overlay changes for anyone who
picks it.

- Half the terminal column by default; drag the divider, double-click it
  to cycle a third / half / two thirds, or use the palette commands. Two
  thirds deliberately runs past the half-window cap the side panels obey
  — only the terminal's floor binds it.
- `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it
  the way they already reserve each other, and the column is derived from
  the *live* sidebar and panel widths rather than their floors, so a
  panel someone dragged wider is width the terminal keeps.
- A window too narrow to seat both fills for that frame. The fallback is
  derived at render time and never stored, so widening re-docks on the
  next frame with nothing to undo.
- Fill or dock is per tab, on the header's context menu. Reading a long
  file over the whole window in one tab while an agent keeps half of
  another is the normal case, and one global switch made each of those
  flip the other. A tab that has not been told reads `document_layout`
  from the config, which is what a fresh tab starts as — and which the
  menu therefore does not write, since every untold tab is reading it.
- Everywhere but macOS the title bar spans the workspace, which left a
  bar's height of nothing above the column. The header is drawn into it,
  and behaves like the title bar it now sits in. With the detail panel
  closed the column reaches the window's right edge, so the header stops
  short of the trailing chrome through a width the tab strip's own
  reservation shares.
- The docked headers drop the traffic-light inset they never had to
  clear, and the diff header's branch name becomes the thing that yields
  so the view toggle and the close tile survive a column's width.

New in `config.json`: `document_ratio`, and `document_layout` for what a
fresh tab starts as. Four new actions, bindable and unbound by default.

* fix(window): hold the docked column to widths the strip and the file agree on

Three defects in the document column, each with a guard test that fails
without its fix.

The tab strip did not know a column had taken width off it. On macOS the
strip lives inside the terminal column and sizes itself to the window less
the detail panel, so a docked document left it 340 points wider than the
column it sits in and the chips ran on under the column — the same overrun
the panel's own reservation was added for. Everywhere else the strip spans
the workspace and the column's hoisted header is drawn over its trailing
end with no fill of its own, so a chip left under it showed through the
file name and stayed clickable through it. The column's width now comes off
`strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's
already goes.

The divider wrote widths the file would not keep. `Config::sanitize` holds
`document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column
pushed against either edge of a wide window was saved outside that band and
reopened somewhere else — on a 2560-point body, 232 points from where it
was dropped. The band is a pair of shared constants now and the drag clamps
to it, the way the font size and its stepper were made to agree in #550.

The palette named the config's layout rather than the tab's. Fill is per
tab, so a tab told to fill was still offered "Document: Fill Window" — a
row that named the state it was already in and did the opposite. It reads
the active tab through `ChromeState` now.

Also: `document_layout`'s doc comment still described the global switch an
earlier draft had, three lines after the field became a per-tab default.
2026-08-19 18:03:51 +08:00
l0ng-ai 1424da0891 fix: nine UX fixes across the diff overlay, layout, settings and pane spawn (#623)
Found by driving a dev instance and measuring what an idle window costs.

Two of them were frame loops that never stopped. A `Head` diff overlay
calls itself stale when the cached git status disagrees with the snapshot
on screen, and every landed probe wakes that check by touching the cache
— but the read published its counts and left the branch behind, so a
branch switched outside tty7 made the disagreement permanent: two `git`
processes a lap, forever, with `refreshing…` pinned to the header and an
idle window at 7% of a core. And the home page asked for a frame sixty
times a second to change one glyph's opacity twice, which made a window
with nothing open in it eight times more expensive than one running a
shell. Both now settle: the diff read publishes the branch it found, and
the home cursor flips a bool on a timer the way the terminal's own does.

The rest:

- The tab sidebar and the right panel each capped themselves at half the
  window and knew nothing about the other, so together they could take
  all of it — 260 points of terminal on a 720-point window. Both now cap
  at whichever binds harder: half the window, or what is left after the
  terminal's floor and the other panel's floor. The same cap bounds the
  drag, so a panel dragged to its limit stays where it was dropped.
- Only a HEAD diff may correct the sidebar's counts. Those numbers mean
  `git diff --numstat HEAD`; an unstaged or staged patch answers a
  smaller question, so opening an untracked file from the Source Control
  panel took the staged lines off the total on the click.
- An untracked row in the diff overlay had no click target, and once
  focused could not be left — the breadcrumb looks the path up in
  `files`, where an untracked file has no entry. Both ends fixed.
- A new pane keeps the name its directory was reached by. `cwd()` alone
  loses it: the shell falls back to `getcwd()`, so `/tmp/x` became
  `/private/tmp/x` in every tab opened from the first. `PWD` carries it,
  and POSIX has the shell discard a `PWD` that names the wrong
  directory, so this can correct the name and cannot invent one.
- The settings search now sees into the Keybindings page, which is
  generated from the binding table rather than the static index — so
  searching for a feature finds its shortcut, and the page filters to
  the matches. Closes #444.
- The settings reading column is centred rather than pinned to the nav:
  on a window as wide as the display it was made for, 640 points of
  settings sat beside 1600 points of nothing.
- `New Workspace…` takes the ellipsis its three sibling actions already
  carry — it opens a form asking for a name and a host.

Every fix has a test. The re-probe loop is pinned end-to-end with
`render_probe::draws() == 0` against a real repository, confirmed to
fail on the old behaviour before it was kept.
2026-08-14 08:42:29 +08:00
l0ng-ai 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.
2026-08-10 13:25:30 +08:00
l0ng-ai 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.
2026-08-10 13:06:15 +08:00
l0ng-ai 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.
2026-08-10 12:31:58 +08:00
l0ng-ai 62b922f2c2 Merge origin/main into integration/polish
main dropped the client-side command-mark store (#404) while this branch
had just started reading it: the close confirmation names the command it
is about to end, and the mark was the only place that text existed on the
client. Keep both. The OSC 133 tokenizer main left in place already sees
every mark, so the command line now rides alongside `zle_reading` and
`shell_vi_mode` as one shared string — set on `C`, cleared on `B` and on a
`C` that carries no line — instead of a store with a list, a lock and a
cap. `busy()` reads that.

The rest:

- settings.rs takes main's opaque overlay surface and background layers,
  keeping this branch's no-match note and scrolled body. The inner
  `.bg()` goes, per main's reason: the root already paints it, and a
  second fill hides the theme image.
- i18n keeps this branch's `every_key_is_translated_in_every_locale`,
  which walks `L10nKey::ALL` in all three locales, over main's
  hand-listed zh coverage test it replaced. It immediately caught three
  of main's new backdrop keys reading English in ja — Mica, Mica Alt and
  Acrylic, which is what Japanese Windows calls them, so they join the
  allowlist with that reason.
- app.rs keeps both sides' tests and drops both sides' now-dead imports:
  `window_background` (main deleted the function) and `humanize_action`
  (this branch's keybinding note uses `keymap::action_entry` instead).

Verified: `sleep 300` then ⌘W asks about "sleep 300"; ⌘W after it ends
closes without asking.
2026-08-09 16:38:15 +07:00
l0ng-ai 0106430ecd merge: main into the Source Control branch
The one conflict is an import list in `diff_overlay.rs`: this branch added
`SharedString` for the unified view's row labels, main added `Background`
and `Hsla` for the window backdrop work. Both sides are still used, so the
resolution is the union.

Worth recording why this merge happened when it did. `main` moving is not
normally urgent — branch protection dropped its strict check, so a branch
behind main still merges — but a *conflicting* branch is different: GitHub
cannot compute `refs/pull/N/merge`, and every workflow that triggers on
`pull_request` silently stops running. Three pushes in a row registered no
CI at all on #424 while other PRs kept going green, which reads as a GitHub
incident and is really just an unresolved conflict.
2026-08-09 16:20:19 +08:00
ARNOandl0ng-ai 61efe27f2d feat(windows): add native backdrop material presets (Mica / Acrylic /… (#412)
* feat(windows): add native backdrop material presets (Mica / Acrylic / Blur)

Adds a Background material dropdown (Auto / Blur / Mica / Mica Alt /
Acrylic / Off) that maps onto the native Windows backdrop APIs already
provided by the gpui fork — Mica and Mica Alt via
DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE), Acrylic via the new
DWMSBT_TRANSIENTWINDOW material, and Blur via the classic
ACCENT_ENABLE_ACRYLICBLURBEHIND path — with no fork changes required.
* config: introduce WindowBackdrop in tty7-core with lenient kebab-case
  deserialization, defaulting to Auto for existing configs
* theme: resolve the backdrop through a build-number fallback chain
  (Mica/Mica Alt need Windows 11 22H2, Acrylic needs 22H2 natively and
  1809 via classic acrylic, Blur needs 1809; older builds fall back to
  plain translucency) and default the background alpha to
  SYSTEM_MATERIAL_OPACITY (0.82) while a material is active
* settings: replace the blur toggle with a localized backdrop dropdown
  that only lists the presets the current Windows build actually
  supports, and keep the settings panel fully opaque so workspace
  translucency never shows through it
* theme: make the file sidebar and right detail panel follow the window
  opacity so the backdrop material shows through the whole workspace,
  keeping row-level accents opaque for readability
* i18n: add backdrop keys for en, zh-CN and ja-JP, covered by the
  translation completeness test

* feat(theme): let the sidebar and right panel follow the window opacity

* update GPUI

* fix(windows): gate the sidebar translucency to translucent windows and sync the opacity slider

fix(windows): gate the sidebar translucency compensation to active materials

* fix(windows): derive the material opacity default from the resolved appearance

* fix(theme): keep WindowBackdrop semantics consistent on non-Windows

f

* fix(theme): stop Windows-only materials from pinning the blur on other platforms

* docs(changelog): document the Windows backdrop material settings

* refactor(theme): share the default window-opacity derivation

* fix(ui): keep gradient presets behind the settings panel and scope its fallbacks

* fix(ui): keep the settings theme picker legible and the backdrop label honest

f

* fix(theme): let every backdrop variant defer to the local blur toggle on non-Windows

* fix(settings): restore the backdrop dropdown selection on locale refresh

* fix(ui): keep the opened-file editor surface opaque under window translucency

* fix(settings): rebuild backdrop options after selection

* fix(settings): ignore synced windows backdrop overrides on other platforms

* fix(settings): preserve synced windows backdrop on non-windows reset

* fix(diff): keep the full-window overlay background opaque

* fix(windows): keep Auto opaque and stop the backdrop from misreporting itself

Ten findings from a review of the backdrop-material work, all in the
Windows-only paths.

The root one: `material_active` treated `Auto` as a material whenever the
legacy blur toggle happened to be on. `Auto` is the default in every config
written before this setting existed, and plenty of them carry
`window_blur: true` from the switch that no longer renders on Windows, so an
untouched install would drop from opaque to 0.82 alpha - with its file
sidebar and right panel at 0.15 - on first launch after the update, with no
visible control to undo it. Only an explicit pick in the dropdown now buys
the translucent defaults. The switch comes back on Windows while the
backdrop is `Auto`, since that is exactly when the legacy flag still decides
something.

The rest:

- Mica and Mica Alt fell back to `Blurred` with no lower bound, asking for a
  blur that does not exist below 1809 - and build 0, which is what a failed
  `RtlGetVersion` reports. They now degrade to plain translucency like
  `Blur` and `Acrylic` already did.
- Acrylic is no longer offered below 22H2, where it resolves to the very
  same classic WCA blur as `Blur`. A test now asserts that no two offered
  presets render identically on any build.
- `reload_from_config` re-applied the theme and the opacity slider but not
  the backdrop dropdown, so an external config change switched the window's
  material while the control kept naming the old one.
- The settings, opened-file and diff overlays were made opaque so the OS
  backdrop cannot show through their text; that also hid the theme
  background image, which used to show through them. They paint their own
  copy of it now, and the fill they share moved into
  `theme::overlay_background`.
- The SFTP transfers tray painted `workspace_surface_color` inside the right
  panel, which already paints it, stacking the same translucent surface
  twice into a darker band with a hard seam.
- `apply_theme` re-issued `set_background_appearance` on every `Config`
  mutation in every window. With a DWM material that now costs a
  `SetWindowPos(SWP_FRAMECHANGED)` frame recalc, so dragging the opacity
  slider recalculated the frame once per mouse sample; it is skipped when
  the appearance is unchanged.

* fix(ui): dim the overlay background image, and stop telling Windows it is macOS

Two defects found while driving the previous commit's changes in the app.

The overlays repaint the theme background image over their own opaque fill,
so it survives them being made opaque - but nothing dimmed it. Before those
overlays were opaque the image reached the eye through their translucent
fill; painting it at full strength put the settings text straight on top of
the wallpaper and made the panel unreadable at any image opacity above about
half. They now paint the image and then the workspace's own fill over it,
which is exactly the strength the image had through these overlays before,
and which needs no new constant to say so. Shared as
`app::overlay_surface_layers`, empty when the theme has no image so a
themeless window paints no second pass of anything.

The Windows-only blur row reused `SettingsBlurDesc`, whose text ends in
"(macOS)". It gets its own key in all three locales, describing the job the
flag actually still has on Windows: feeding the `Auto` material.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-09 15:14:54 +08:00
l0ng-ai de2e622be5 fix(diff): a read that failed must not overwrite the sidebar's counts
The overlay says "could not read the diff" in words, and then published
the failed read's zero totals over the +N -N the row already had right.
2026-08-09 12:02:03 +07:00
l0ng-ai 5497252aec feat(scm): read a whole commit in the panel's second-level view
The graph gives up text for shape: a 260px row has room for about 26
characters beside its lanes, and this repository's commit subjects run
to a median of 64. This is where the rest comes back — the full subject,
the body, every ref, the parents, and the files the commit touched.

The file list is two commands, not one. Git accepts `--numstat` and
`--name-status` together and then quietly drops the numstat half
(measured on 2.50.1), so they run separately and join on the path. It is
`log -1 --first-parent` rather than `diff-tree -m --first-parent`, which
does not narrow a merge: on the same git it emits one diff per parent
and concatenates them, so a two-parent merge came back with every path
listed twice. `log` is also how `DiffSource::Commit` walks the patch,
which is what makes this list and the overlay's cards agree file for
file.

`DiffSource::Commit` now carries an optional label, so the overlay's
header can say what the commit was about instead of eight hex digits.
The label deliberately takes no part in the source's identity:
`PartialEq`, `Hash` and the overlay's string probe key are all derived
from one `tag()` function, because the same commit opened with a subject
in hand and without one has to stay one patch, one in-flight probe and
one overlay. The key used to be built from `Debug`, which would have
split the probe cache the moment a label arrived.

Also lifts `local_branches` out of the panel's inline `for-each-ref`,
and keeps the `%(upstream)` that `parse_refs` had been asking for and
throwing away.
2026-08-09 10:20:34 +08:00
l0ng-ai 16d7c1b5a2 refactor(scm): retire the panel's old flat-diff state
The panel now reads WorkingTreeStatus, so RightPanelState's diff, diff_cwd
and diff_pending had no writer left — and with them went PANEL_DIFF_SOURCE,
the seed shortcut that borrowed the panel's snapshot, and the write-back that
kept it fed. The overlay always names its own source now.

Two things were still reading that state and quietly getting nothing:

- the source control tile's badge, which now counts entries from the same
  status the panel draws, so the number and the group headers cannot disagree
- the branch row's busy spinner, which was inferring "the push finished" from
  the repository's epoch moving. Anything else that moves the epoch — a .git
  event, a file save — would have dropped the spinner mid-push. It asks the
  slot counter run_git_op claims from instead, which is the operation itself.

scm_network_busy reads through try_global rather than default_global: it runs
from render, where taking the global mutably queues a global-observer effect
on every frame.
2026-08-09 02:10:53 +08:00
l0ng-ai 45879d1c22 feat(diff): add a unified view and name the source in the header
The overlay could only ever show one thing one way: `git diff HEAD`, side
by side. Both halves of that are now choices.

A segmented control in the header switches between side-by-side and
unified, stored in `Config::diff_view` so the choice survives the next
open. Unified is measured against the split cell rather than designed
next to it: the same 19px row, the same type, and the same 0.12 wash
behind an addition and a removal. It differs only where the shape forces
it — 34px per line-number gutter instead of 42, and a column of its own
for `+`/`−`, without which the context lines' code would start two
characters left of everything else.

The header now says which patch it is showing. A branch name for the
worktree and for HEAD as before; the same with a STAGED chip for the
index, which is otherwise indistinguishable; the commit glyph and a
short object id for a commit or a range. The subject and author of a
commit are not there yet — `DiffSource::Commit` carries only the rev,
and buying them costs another round trip that the commit detail view
will be making anyway.

Two pieces of coupling go with it. `PANEL_DIFF_SOURCE` no longer decides
whether the panel's snapshot may seed an overlay, or whether an overlay
has gone stale: the first is settled by the snapshot's own source, the
second by the overlay's. A commit and a range never go stale at all, and
the two sources the cached `--numstat HEAD` counts cannot describe now
compare `ScmData` epochs instead — read when the probe starts, so a
write landing under it is not mistaken for one the result reflects.

And the file cards drop their private A/M/D/R table for the shared
`status_glyph`/`status_color`, so a file wears the same letter here as
in the panel and the tree.
2026-08-09 01:19:50 +08:00
l0ng-ai 58e8ba16d1 feat(git): ask for three kinds of diff, and lift the row model out of the renderer
`probe` could only ever run `git diff HEAD`. A `DiffRequest` now names the
source (worktree, staged, HEAD, one commit, a range), the pathspecs, the
context width and the budget, and `DiffSource::args` is the single place any
diff argv is built — which is also the single place to test it.

Three things were verified against git 2.50.1 rather than assumed:

- `-c core.quotePath=false` is a real bug fix, not tidiness. With quoting on,
  `diff --git "a/\344\270\255\346\226\207\345\220\215.txt" …`; off, `diff --git
  a/中文名.txt b/中文名.txt`. `parse_quoted_pair` does not decode octal, so
  every non-ASCII path in the overlay was simply wrong. `ls-files` quotes the
  same way, so the untracked listing gets the flag too.

- `Commit` runs `log -p -1 --format= --first-parent`, not `diff-tree`.
  `diff-tree` does not honour `--first-parent` as a narrowing of a merge: over
  a merge of two branches that each added a file, `diff-tree -p -m
  --first-parent` emits both files (one patch per parent, concatenated) and
  dropping `-m` emits nothing at all. `log -p -1 --first-parent` gives the one
  first-parent patch for a merge, an ordinary commit and the initial commit
  alike, so there is no special case and no `--root`.

- A type change is not one patch. git emits a deletion and a creation for the
  same path, back to back, with nothing in either header saying why; the pair
  is folded back into a single `TypeChanged` entry. `old mode`/`new mode` only
  appears for permission changes, which stay `Modified`.

`FileStatus` also gains `Copied` and `Unmerged`. A conflicted path arrives as a
combined diff (`diff --cc`, `@@@`, one marker column per parent), so the body
parser reads its marker width from the hunk header instead of assuming one.

The budget is a parameter now (`DiffBudget::PANEL`, `::SINGLE_FILE`) rather
than three module constants read inside the parser. Defaults are unchanged.

On the UI side, the overlay's reuse test was `(cwd, host)` — clicking a staged
file while a worktree overlay was open took the "just move the focus" branch
and went on showing the unstaged patch under the staged file's name. The source
is part of an overlay's identity now, in that filter, in the panel-seed
shortcut and in the in-flight de-duplication key. `maybe_refresh_diff_overlay`
only compares HEAD snapshots against the cached `--numstat HEAD` counts, which
are the only counts they are comparable to.

`split_hunk` and tab expansion move to `ui::diff_rows` alongside a new
`unified_rows`, so the two renderings of a hunk are built and tested in one
place, without a window. The overlay's file list finally hangs on tty7's own
scrollbar instead of a bare `overflow_y_scroll`.
2026-08-09 00:40:21 +08:00
l0ng-ai 544badfa33 fix(diff): give the diff overlay the scrollbar every other scroll area has
A working tree can run to thousands of lines here, and the overlay was
the one long document in the app with nothing to say how far it went or
where in it you were. Nine scroll areas already wrap in the shared
helper; this one had not even been given a ScrollHandle.
2026-08-08 21:54:01 +07:00
l0ng-ai 342b65ae5e fix(git): stop the sidebar count disagreeing with the diff it opens
The +N −N on a tab row is refreshed on an edge — the directory changing, a
command finishing, an agent turn ending, the window coming back — and never
polled. That is the right shape for it, but it means a working tree edited
from anywhere else keeps the old number until one of those happens. Click
the number and the overlay reads the tree there and then: the row said
+27 −8 while the overlay it had just opened said +3 −1.

A diff read is a fresher answer to the same question, so it now hands its
totals back to the status cache. The Changes panel feeds the same path, so
watching a diff shrink there keeps the row honest too. Only the numbers
move — the branch, the root and the probe's own schedule are untouched, and
a diff for a repo nothing has probed is dropped rather than inventing a row
with no branch on it.

The totals are exact even when the diff bodies were truncated by the
repo-wide budget, which `repo_wide_budget_keeps_totals_exact` holds.
2026-08-08 17:26:57 +07:00
8a342f2ca9 feat(ui): GUI localization for en and zh-Hans (#303)
* feat(ui): add GUI localization for en and zh-Hans

* feat(ui): localize search placeholders and relative time

* feat(ui): localize palette, switcher, and sftp strings

* feat(ui): localize home shortcut labels

* feat(ui): localize tray, ssh prompt, and editor strings

* feat(ui): add plural/select i18n helpers and localize sftp/settings labels

* feat(ui): localize settings search, forwards panel, and file tree

* feat(ui): localize code editor and right panel

* feat(ui): localize stop/delete workspace confirmations with plural support

* feat(ui): localize diff overlay with plural-aware summary

* feat(ui): localize pending pane, worktree prompt, and home time strings

* feat(ui): localize app menus, tray, tab strip/sidebar, and remote status strings

* feat(ui): localize switcher, file_tree, machine_mirror fallback strings

* feat(ui): localize ssh prompts, theme presets, host error wrapper, and finish remote strings

* feat(ui): localize command palette strings

* feat(ui): localize app.rs notifications, prompts, placeholders, and parse errors

* feat(ui): localize remaining theme, switcher, settings, and sftp strings

* style: cargo fmt

* feat(ui): add language selector to settings

* fix(ui): refresh locales across windows

* refactor(ui): make GUI language selection explicit

* fix(ui): localize Explorer settings after merge

* fix(ui): keep persisted theme names out of the GUI locale

A theme's name is data, not chrome: it is written into the theme YAML and
matched back with `trim_end_matches(" (custom)")`. Translating it meant a
Chinese GUI forked "Nord" into "Nord(自定义)", the next fork stacked a second
suffix on it, and the name stayed Chinese after switching back to English. The
derived-name fallback had the same problem. Both are English again.

Also in this pass:

- Give each test thread its own locale override. The locale is process-wide and
  tests run in parallel, so the two tests that switched to zh-CN could flip the
  language out from under another thread's English assertions.
- Rebuild the menu bar when gui_language changes in config.json, the way the
  in-app picker already does — otherwise the menus kept the old language.
- Document the values the setting actually accepts. The docs still described
  `auto` and `zh-Hans`, which sanitize() resets to `en`.
- Put the English words back into the Chinese search keywords for the language
  setting; the other 58 keyword sets keep them.
- Drop the unused is_zh_hans helper.

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-03 23:48:29 +08:00
l0ng-aiandl0ng-ai 8c1946d763 chore: strip every comment from the Rust sources (#268)
Removed all Rust comments -- line, block, and doc -- from the 139 tracked
.rs files with `uncomment` 3.5.1. It parses each file with tree-sitter
instead of matching text, so comment-like content inside string literals
is left alone: the JavaScript plugin source embedded in agent_hooks.rs
raw strings keeps its own `//` lines.

Left alone: Cargo.toml comments and the shell scripts under scripts/.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-30 21:36:15 +08:00
f6fa34a016 feat(ui): make the sidebar diff preview optional and bound its cost on large working trees (#247)
* feat(diff): make the sidebar diff preview optional and bound its cost

Clicking a sidebar row's `+N −N` opens the working-tree diff overlay. On a big
tree that could stall the window, and not everyone wants an in-app diff viewer
in the first place.

Two halves, matching the report.

The setting: `sidebar_diff_preview` (Settings → Window & Tabs, on by default,
persisted in `config.json`). Off, the branch and the counts stay exactly where
they are and read exactly the same; they lose only the pointer cursor and the
`toggle_diff_overlay` handler, so the press falls through to ordinary tab
activation. Both come off one value — `diff_click_cwd` — so they cannot get out
of step.

The performance work. All five of the reporter's hypotheses held up against
v26.7.6, and each fix is measured on a 300-file / 90 000-line / 4.5 MB diff
(release, macOS arm64):

1. The full diff was buffered before parsing — `git_status::git` uses
   `Command::output()`. Now streamed line by line through the new
   `git_status::git_lines` into an incremental `DiffParser`: peak transient
   buffer 4 552 060 bytes → 50 bytes, at ~1.7× the parse CPU (3.97 ms →
   6.76 ms) on the background thread, where it never touches a frame.

2. The snapshot was deep-cloned per holder inside `this.update`, i.e. on the
   UI thread. Now shared behind `Arc`: 2.41 ms → 11 ns per holder.

3. The element tree is not virtualized — confirmed, not cured. Rendering is not
   being redesigned here; instead the element count is bounded (see 4) and
   `MAX_RENDERED_FILES` caps the cards built at all, with a "… and N more" line
   for the tail.

4. Auto-collapse was per file, and counted only +/− while the rendered body
   also has context lines. Added `AUTO_COLLAPSE_TOTAL_LINES` over *retained*
   lines: sixty forty-line files, none individually large, went from 2400
   side-by-side rows to zero, under a summary saying the diff is too large to
   render efficiently and pointing at expanding individual files or `git diff`.

5. The Changes panel probed independently and kept its own snapshot. Both now
   go through `spawn_shared_diff_probe`, which dedupes by cwd and installs one
   `Arc` into every watcher; opening the overlay while the panel already shows
   that repo now paints from the panel's snapshot instead of re-probing.

Plus a repo-wide retention budget (`MAX_TOTAL_LINES`, `MAX_FILES_WITH_HUNKS`):
90 000 lines / 6.2 MiB of line text → 20 000 / 1.2 MiB. The `+N −N` totals
deliberately escape every cap — they are compared against `--numstat` to detect
staleness, so a capped total would disagree forever and re-probe in a loop.

Small diffs are untouched: a forty-file, twelve-lines-each tree is not
oversized and still opens expanded, asserted directly.

Not verified: anything requiring the GUI. No frame timings, no visual check of
the oversized banner or the settings row, and `AUTO_COLLAPSE_TOTAL_LINES` is a
judgement call anchored on row count rather than a measured frame budget.

Refs #239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(diff): bound the untracked list and stop a moving default flipping toggles

Five findings from the review of the previous commit, all confirmed against the
source before acting.

The untracked list escaped every bound the previous commit added. `git ls-files
--others` reports the whole tree of anything not yet ignored, so a fresh clone
before `node_modules` / `target` / `.venv` reach `.gitignore` answers with tens
of thousands of paths — read through the buffering helper, retained without a
cap, ignored by `oversized()`, and rendered one non-virtualized row each. That
reaches the overlay without going through the diff at all, which is why the diff
budget never saw it. It is now streamed through `git_lines`, capped at
`MAX_UNTRACKED`, counted toward the oversized threshold, and rendered at most
`MAX_RENDERED_FILES` rows with a "… and N more" tail. The reported count stays
the true total via `untracked_total` — same split the diff side already makes
between what is retained and what is counted, because a count that shrank with
the budget would read as files having disappeared.

The overlay's expand state was an inversion set — "files flipped away from their
default" — which was correct while the default was per-file and stable. The
repo-wide `collapse_all` moves the default for every file at once, so any
refresh crossing the oversized threshold inverted every explicit choice
simultaneously: the two files the user had opened snapped shut and the rest
sprang open. It now stores absolute intent (`HashMap<String, bool>`), answered
before the default is even computed, so nothing about the snapshot can reach it.
Covered by a test that asserts an explicitly opened and an explicitly closed
file both survive a transition in either direction while an untouched file
follows the default.

The Changes panel dropped freshly landed snapshots. `install_diff_snapshot` only
wrote `right_panel.diff` when the panel was the one waiting, so a probe the
overlay started was discarded even when the panel sat on that exact repo — the
overlay rendered the new snapshot while the panel kept the old one, same window,
same repo. The wait (`diff_pending`) and the data (`diff_cwd`) are now claimed
separately: with probes deduped per cwd there is at most one in flight, so there
is no out-of-order overwrite to guard against.

The oversized banner reported `retained_lines()` as "diff lines", which after
the budget fires is what was kept, not what changed — it read "20000 diff lines"
directly under a header showing the exact +90000/-0. It now states
loaded-of-total and names which cap ate the difference, and lists each axis that
tripped the threshold so a big untracked list never reads as a claim that the
diff is big.

And the Changes panel deep-copied every untracked path String on every frame, on
the UI thread, for two `len()`/`is_empty()` reads — the same cost class the
`Arc` switch removed from the probe path.

995 tests pass, fmt clean, no new clippy warnings. The app was not built,
launched, or driven; visual acceptance is the owner's.

Refs #239.

* no-mistakes(review): cap Changes panel rows and fix oversized banner truncation notice

* no-mistakes(review): drive banner per-file truncation off parser flags

* no-mistakes(document): correct changelog cost count and overlay-trigger docs

* fix(diff): reconcile the diff-overlay work with the host-aware git refactor

The rebase onto main lands this change on top of the remote-workspace work
(#235, #242), which moved the git helpers into `tty7-core` and made every read
take the pane's `Host`. Adapting rather than papering over:

- `sidebar_diff_preview` moves to the core `Config`, where the struct now lives.
- The diff and untracked reads go through `git_status::git(host, cwd, ..)`.
- The shared probe, its in-flight set, and `install_diff_snapshot` key on
  (`HostId`, `PathBuf`) — the same path on two machines is two work trees — as
  does the Changes panel's `diff_pending`.
- `diff_click_cwd` became generic over what identifies a repo, so the setting
  gate did not need to learn about hosts.
- Main's newer card rounding reads `truncated`, which is an `Option` here now.

The streaming diff read is deliberately absent at this commit: `Host::git` is
buffered, so it is restored on top of a streaming host API in the next one.

Refs #239.

* feat(host): stream git reads whose size scales with the work tree

`Host::git` returns a fully-buffered `Output`, and the remote implementation
round-trips one over the wire. That is the right shape for the reads tty7 does
constantly — `rev-parse`, `symbolic-ref`, `--numstat` — all of which answer in
bytes. It is the wrong shape for `git diff HEAD`, whose output scales with the
work tree rather than with anything the UI can show: this repository's own
`git log -p -n 400` is 8.3 MB, and the diff overlay keeps a small fraction of it.

So `Host` grows a second entry point rather than changing the first. `git_lines`
delivers the same invocation a line at a time, and its **default implementation
buffers** — every host gets it for free, nothing that works today changes shape,
and it stays inside the "every git read funnels through the host" invariant
instead of becoming a way around it. Overriding it is an optimisation, never a
behaviour change: a test asserts the streamed and buffered reads yield identical
lines.

The local host reads straight off the pipe. The remote host adds
`ControlRequest::GitStream`, answered with `ControlEvent::GitChunk` pushes and a
terminating `GitEnd` carrying the exit status — the shape `WatchOpen` already
proved out.

Two protocol details worth the reader's attention:

The **client** picks the stream id, which is why the reply carries none. Ids
only need to be unique within a connection and a connection has one client, so
choosing it client-side lets the receiver be registered *before* the request
goes out. A server-assigned id arrives in the reply, leaving a window where a
chunk that overtook it reaches a client with no entry for that id and is dropped
under the unknown-id rule — silently losing the front of the diff. `WatchOpen`
needs a whole deferred-start mechanism to close that window; this does not.

The feature is **advertised and checked**, not assumed. A server predating
`GitStream` cannot decode the variant, and an undecodable frame ends the
connection — so sending it blind would not degrade, it would disconnect. Servers
advertise `git-stream`; a client that does not see it uses the buffered `Git`,
which is the path every remote pane used before this existed. Covered by a test
against a peer advertising only `control` and `host-rpc`.

Chunks carry newline-terminated line data batched to ~64 KiB, not verbatim
slices of stdout: the server reads through `git_lines` itself, so line content
survives exactly while `\r\n` and a missing final terminator are normalised
away. The only consumer is line-oriented. Framing per batch rather than per line
is what keeps a 90 000-line diff from becoming 90 000 frames.

Re-measured on the rebased code, against real git output, release build
(the previous figure was taken before the host refactor and no longer holds):

  buffered: 8 269 409 bytes resident, read 817 ms + parse 12 ms
  streamed: peak transient chunk 64 KiB, read+parse 557 ms

Lower peak memory *and* faster end to end — parsing now overlaps with git
producing output instead of waiting for all of it. The earlier synthetic
measurement showed streaming costing ~1.7x CPU; that was an artifact of reading
a warm page-cached file, where there was nothing to overlap with.

The app was not built, launched, or driven; visual acceptance is the owner's.

Refs #239.

* fix(host): make unsubscribing a watch take effect at the drop, not after it

CI's Windows job failed `watch_drop_unsubscribes`: an event for a file created
*after* the subscription was dropped still reached a consumer holding a clone of
the receiver.

Tearing the watcher down is not instantaneous. The OS backend runs its own
thread, and on Windows a `ReadDirectoryChangesW` completion can fire during
teardown, reach the event closure while `raw_tx` is still alive, and be
forwarded by a coalescer that has not yet noticed the disconnect. So "dropped"
meant "stops delivering shortly", which is not what the subscription promises —
and for a remote host it is the difference between releasing a server-side watch
and leaking one.

The handle now closes the delivery channel in its own `Drop`, before any of that
unwinds. Batches already queued stay readable — `close` stops sends, not
receives — which is the one thing a consumer racing its own drop may legitimately
still see, and exactly what the conformance test allows for.

Not this branch's bug: the change here is to git reads, not watches. But main is
flaky in the same family — it failed the sibling `watch_coalesces_within_window`
eleven hours ago and was hardened for that one — so this fixes the cause rather
than loosening the test.

Refs #239.

* refactor(host): make the streaming git read part of the protocol

Remote workspaces have never shipped a release, so there is no deployed server
to negotiate with. The `git-stream` feature flag, the `has_feature` check and
the buffered fallback behind it were all guarding against a peer that cannot
exist — dead code that would have to be maintained, and read by the next person
as evidence that older servers are out there.

`ControlRequest::GitStream` is simply part of the control protocol now. Buffered
`Host::git` stays exactly as it was, for the many reads that answer in bytes and
have no reason to stream.

The remote test that proved the fallback becomes one that proves the stream:
the peer serves `GitStream`, splits a line across two chunks, and the client
reassembles it — the case the reassembly exists for.

Refs #239.

* no-mistakes(review): fix remote git-stream deadlock, chunk encoding and stray docs

* no-mistakes(review): stop git-stream batch growing after a send failure

* no-mistakes(document): note git-stream protocol delta in remote-workspace contract doc

* fix(host): bound a git stream's wait, its lines, and its concurrency

Three ways the streaming git read could still hold or hang more than it
should, all found reviewing #239's implementation.

A stream is answered by pushes, so neither of the failure paths the rest
of the client relies on covers it: the request deadline was satisfied by
the immediate `Unit` reply, and keepalive watches the link, which stays
up while a server-side git wedges on a network filesystem. The reader
parked forever, on one of a small pool of blocking threads, and the diff
probe it belonged to never released its per-repo claim — so that
repository's overlay and Changes panel were stuck on "Loading…" for the
life of the process. `git_lines` now waits `GIT_STREAM_IDLE_TIMEOUT`
between chunks. Between, not across: a slow-but-alive read must be
allowed to take as long as it takes, which is why a total deadline would
be the wrong instrument. Draining moved to `drain_git_stream` so all
three exits are reachable from a test without waiting out two minutes.

"Incremental" bounded the number of allocations but not the size of any
one of them: a line is only complete at its newline, so a work tree with
a minified bundle rebuilt the whole-output peak inside `LineSplitter`,
on both ends of a remote link and in the server's outgoing batch. Lines
are now capped at `MAX_LINE`, and what is cut says so in the line itself
rather than silently shortening a rendered diff. That also bounds the
server batch, which makes `GIT_STREAM_CHUNK_MAX` a frame backstop rather
than the only thing standing between a bundle and a 32 MiB payload.

Finally, `GitStream` is the one request that spawns a thread outside the
bounded worker pool, so nothing counted them.  `MAX_CONCURRENT_GIT_STREAMS`
per connection now does, with the slot returned by a guard so a refusal,
a failed spawn and a panicking read all give it back — a leaked slot
would be a permanent refusal, not a transient one.

* fix(diff): stop the overlay re-walking the tree per frame, and re-probe a folded-in refresh

Two things the shared-probe work left on the render path.

The overlay asks six whole-snapshot questions while building its element
tree — oversized, totals, retained lines, budget fired, per-file cap
fired, untracked count — and each accessor walked `files` on its own,
`oversized` walking the hunks too. `files` is deliberately uncapped (only
hunks are), so that was six walks over a list whose length is the size of
the working tree, on the UI thread, on exactly the tree this module
exists to keep responsive. `DiffSnapshot::stats` answers all six in one
pass and the per-question accessors are gone, so nothing can drift from
it. Computed rather than stored, because the snapshot is built by hand
with `..Default::default()` throughout the tests and a cached count would
read as zero for every one of them.

Deduping probes per repository is what makes one `git diff` answer every
watcher, but a probe describes the tree as it was when it *started*. A
refresh triggered after that — a command finished, an agent turn ended —
folded into the running probe and was answered with a snapshot already
known to be stale, with nothing left to trigger another look: the
overlay's own re-check is gated on `loading`, which the landing clears,
and the `GitStatusCache` change that would have re-armed it has been
spent. A folded-in request is now remembered and re-issued when that
probe lands. It converges rather than loops, because a quiet tree never
sets the flag.

* fix(diff): bound the stream queue, and stop two thresholds answering the wrong question

Review follow-ups on the sidebar-diff branch. Five findings, four of them
about a bound that was claimed but not held.

The remote streaming read bounded both ends and not the middle. The reader
thread serves the whole connection, so it cannot wait on a slow consumer —
parking it there stalls every other reply and the keepalive with it — and an
unbounded queue was the price. That reassembles the whole diff in a channel,
which is the peak the buffered read was replaced to avoid, one container
further along. The queue is now bounded instead of back-pressured: each chunk
is charged to the stream's arrears, the drainer credits them back, and a
stream 32 MiB behind is cut loose with an error rather than served. Real
back-pressure would need credit-based flow control in the dialect; this is
not that, and says so.

`oversized` counted untracked paths on its file axis, and collapsing every
file body removes no untracked rows — that section has no bodies to fold. A
tree with an un-ignored node_modules and three edited files hid the three
cheap things, kept the expensive one, and told the reader their working tree
was too large to render. The untracked list is bounded where it is built:
MAX_UNTRACKED on retention, MAX_RENDERED_FILES on rows.

AUTO_COLLAPSE_TOTAL_LINES counted the context lines git prints around every
hunk — four to six retained per line actually changed — against a threshold
set as if it were reading `+N -N`. It fired on trees whose header said 400.
8000, compared against the 20000 the parser stops retaining at, since
collapsing everything is the heavier of the two interventions and should not
arrive first by much.

A probe that could not run produced an empty file list, which renders as
"Working tree clean" — a claim about the repository, made because a read
timed out. Newly reachable, too: a stream can be refused or go silent where a
buffered read could only arrive or error. DiffSnapshot::read_failed keeps the
two apart.

Also: StreamStop's doc comment had been glued onto StreamSlot, leaving the
enum undocumented and the guard described twice; the overlay header asked
totals() beside stats() rather than through it; and the watch-teardown fix
riding along on this branch was in neither the PR body nor the CHANGELOG.

Tests: the queue budget both ways (a stream that outruns it is cut loose, a
larger one that is drained is not), the untracked axis, an ordinary
context-heavy afternoon sized to fail against the old threshold and pass
against the new, and empty-because-broken against empty-because-clean. Each
was checked to fail against the behaviour it replaces. 1603 pass, 0 fail;
fmt clean; no new clippy warnings in the touched files.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 13:26:55 +08:00
l0ng-aiandl0ng-ai 9c00648875 fix(ui): keep the window-drag arm alive across a repaint, and make every header draggable (#252)
* fix(ui): make every header draggable, and keep the arm alive across a repaint (#221)

Two changes to the same code, which is why they land together.

Five rows that stand in for the title bar — the tab rail's top zone, the
settings page's top strip, the detail panel's top zone, and the code and
diff overlays' headers — armed their drag with an `Rc<Cell<bool>>`
allocated inside the render function. A redraw between the press and the
first drag event handed the next frame's listeners a fresh, zeroed cell
while the press had written to the old one, so the whole hold was dead
until you released and tried again.

The press itself schedules that redraw: these rows carry `on_double_click`,
and gpui calls `window.refresh()` on mouse-down for any element with a
click listener. So a drag only survived if the first move beat the next
vsync — 16ms at 60Hz, 8ms on ProMotion. A mouse press physically nudges the
pointer and often won that race; a trackpad press is a finger pushing down
without translating, and almost never did. That is the trackpad-vs-mouse
split the issue reports. The terminal's cursor blink (a 530ms `cx.notify()`
loop) disarms it on its own even with no press at all.

`window_move_gesture` now holds the flag in `window.use_keyed_state`, which
survives frames — where gpui-component's own `TitleBar` has always kept it,
and why the ordinary caption strip was never affected. Keyed rather than
`use_state` because one builder serves several call sites and `use_state`'s
`CodeLocation` id would collide when two of these rows are on screen at
once (the rail's top zone plus an overlay header is a real combination).

A longer-lived flag has to be cleared explicitly, so releasing outside the
row disarms too; with a per-frame cell the frame boundary did that for free.

Nothing else about these rows changes — same hit boxes, same geometry, same
`WindowControlArea::Drag`, same double-click.

Grabbing the window by a header is a property of the whole app, not a
per-surface feature, so a user never has to learn which rows are draggable.
Written down beside `window_move_gesture`, along with the two things it
takes beyond arming the gesture: non-controls inside a header take no hit
box (the rule #202 set for the "duo" mark, so the drag falls through them),
and a header whose contents *do* take hit boxes by design needs a floor on
its flexible spacer.

- `panel_title` — the detail panel's section header, shared by Info,
  Outline, Changes, Files and the remote Files browser — is draggable now.
  Its one un-`occlude()`d control (SFTP's refresh tile) gains the wrapper
  every control on a drag row needs, or Windows' HTCAPTION eats its clicks.
- The horizontal tab strip keeps a bare 80px slice of caption. Its spacer
  was a `flex_1` with no minimum, so it collapsed to exactly 0px once the
  chips saturated the row (~7-8 tabs on a 1440px window), leaving only three
  6px gaps and a hairline above and below the chips to grab — the "the
  region that works seems very small" half of the report. The chip row's
  fixed-chrome reserve is corrected to match: a stale flat 100px, sized when
  the corner held a 30px "+" and a 30px "⋯", becomes the ~137px the corner
  actually occupies plus the handle. Chips reach their minimum width and
  truncate a tab or two sooner, and the window is always grabbable.
- The rail's top-zone spacer gains the same floor.

`ui::app::window_drag_tests` drives the real `title_bar_drag` row through
gpui's test platform, where `start_window_move` is `unimplemented!()` and a
panic is therefore a reliable "the window would have moved" detector. It
pins the invariant (press → repaint → move still drags), that a press alone
does not, that a release disarms, and that two rows on screen keep separate
arms. A control test keeps the old per-frame-cell pattern alongside and
asserts it still loses the drag to the identical event sequence — without
it, the invariant test could pass for the wrong reason.

* no-mistakes(review): occlude resize handles; correct chip-reserve arithmetic

* no-mistakes(document): reorder changelog sections; record non-draggable header exclusions

* no-mistakes(document): make panel grab-handle docs version-neutral and platform-accurate

* no-mistakes(document): make workspace_head panel-width doc version-neutral

* docs(changelog): re-file Unreleased entries after the rebase onto main

The rebase onto 64403cf applied every hunk without a conflict and still
produced a wrong file, which is the failure mode worth naming: this
branch's "reorder the Unreleased sections" commit moved its own entries
to Added -> Changed -> Fixed, and replaying that on a main whose
Unreleased had grown three new entries wedged this branch's ### Changed
and ### Fixed headings into the middle of main's ### Added list.

The result had two of *other people's* entries — "Fork an agent session"
and "Copy Session ID", both Added, both from #211 — orphaned under this
branch's ### Fixed, and a duplicate ### Changed / ### Fixed pair further
down. Git had nothing to complain about; the text merged cleanly and the
meaning did not.

Restored to main's structure with this branch's two entries filed under
the headings they belong to. No entry text changed on either side; all
seven Unreleased entries are present, verified against the union of both
parents.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 14:57:21 +08:00
l0ng-aiandl0ng-ai 1f09a62568 fix(ui): give corner-filling children their own radius (#236) (#244)
* fix(ui): stop filled children squaring off rounded corners

The cursor-shape toggles (Block / Bar / Underline) reported in #236 look
rough because the selected segment's fill covers the whole corner of the
track it caps, and its outer edge is a hard, unantialiased vertical cut.
The track's own border arc is drawn correctly and antialiased — it just
floats *inside* that square, so the corner reads as a stair-step.

The controls were relying on `overflow_hidden` to shape their end
segments' fills to the track's rounding. It cannot do that.
`gpui::ContentMask` is a bare axis-aligned `Bounds`; `Style::overflow_mask`
builds it from the element's bounds shrunk by the border widths and drops
`corner_radii` entirely, and every shader applies it as a hard
`clip_distances < 0` discard. So the mask only ever cuts a square, and it
never antialiases the cut. A container's own corners come from somewhere
else — the quad shader's SDF, `saturate(0.5 - distance)` — which is why a
plain rounded card renders smooth while anything with a filled child in
its corner does not. That divergence is the whole bug, and the reporter's
screenshot shows both halves of it: the corner with the selected fill is
square, the corner without one is a clean arc.

The fill has to carry the radius itself, so it goes down the SDF path too.
It sits one border-width inside the track, so the concentric radius is
`outer - border`. `ui::rounding` states that rule once, with the
constants and the corner-assignment helpers, and unit-tests the
invariants (inset is strictly tighter than the outer radius, clamps at
zero, only the end segments cap the track).

Applied to every place a child paints a fill into a rounded corner:

* the segmented controls (the reported one, plus the others `segmented`
  serves),
* the −/value/+ steppers' hover fills — those glyph boxes also had to be
  pinned to the track's content height, because a padded auto-height box
  measures 31px against a 22px content box and its rounded corner would
  land 4½px outside the visible strip,
* the theme picker's flush-mounted previews,
* the diff overlay's card headers and the row that closes a card.

Not reproducible locally: this is a rendering-geometry defect, not a
platform one, but it is most visible at a device pixel ratio of 1, where
the clip's hard edge is a whole physical pixel. Verified by reading the
gpui mask/shader source and the reporter's screenshot pixel by pixel, and
by the geometry tests; the on-screen result is left for visual
acceptance.

Refs #236

* no-mistakes(review): round diff card header when body is empty

* no-mistakes(document): point Unreleased changelog link at v26.7.6

* no-mistakes(document): untrack AGENTS.md per gitignore dev-tool convention

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-07-29 00:34:40 +08:00
l0ng-ai 208454e202 feat(remote): remote workspaces — a window that is one machine
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.

- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
  the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
  static against musl and pushed onto the remote box. One dependency, on
  purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
  makes goes through the machine it belongs to. `LocalHost` answers on this
  box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
  of them run on the background executor with the result landed on the UI
  thread; de-duplication, staleness and error reporting live here rather than
  at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
  workspace list → a window bound to one workspace on it. Workspace switcher
  groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
  cargo-zigbuild, a host-boundary grep, and version stamping factored out of
  the nightly workflow. Both new jobs are non-required so branch protection
  does not wedge open PRs.

Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
2026-07-28 10:59:46 +08:00
thomasandClaude Opus 5 a67cf2b2ad feat(chrome): make the title bar's line whole off macOS
macOS fills the window's leading corner with the traffic lights and
`TITLE_BAR_LEAD` reserves them 80px. Everywhere else that corner held
nothing: the caption row's only contents are the rail's "+" and collapse
at the rail's right edge and the corner chrome at the window's, so the
left third of the row read as unfinished rather than restrained — while
Windows treats the top-left as the app's identity slot.

Three parts, all of them about that row:

- `window_mark()` draws the "duo" mark (the app icon's own art) at the
  head of the rail on `CONTENT_INSET`, the line the search box and every
  row label below it start on, and follows the rail's controls into the
  title strip when the sidebar collapses. It is drawn, never clicked: no
  hover capsule, and deliberately no `occlude()`, so the drag region
  underneath still takes the press and the strip stays grabbable.

- The rail's stand-in row now reserves the same hairline the real
  `TitleBar` draws inside its own height. Without it the bar centred
  content on 19.5 and the rail on 20, and the mark hopped half a pixel
  as collapsing the rail handed it from one to the other.

- With the detail panel open off macOS the bar is hoisted above
  `[terminal | panel]` so the window controls can reach the corner, which
  left the code and diff overlays — anchored to the terminal column —
  starting 40px down, with headers drawn to *be* the title bar landing a
  row low. They now hang on the row that owns the bar, inset by the
  panel's width. Covering the caption row that way needs the headers to
  carry its gestures, which neither ever did with the panel open or
  closed: `title_bar_drag()` gives both (and the rail's row, which grew
  the same wiring by hand) drag-to-move and double-click-to-zoom, and
  their controls are `occlude()`d so HTCAPTION stops eating the clicks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
2026-07-26 23:08:18 +08:00
l0ng-ai e41afaf857 feat(windows): one window per workspace
tty7 had exactly one window, so `main` opened it inline and every app-wide
duty — tray, menus, the quit hook — lived in `Tty7App`'s constructor. This
splits those apart: a *workspace* is the persistent identity (tabs, splits,
cwds, name) and a *window* is a transient view onto exactly one of them.

- `ui::windows` — the app-level window registry and the single place that
  opens a window. Exactly one window per workspace is enforced there: the
  daemon gives each pane a single subscriber, so a second window on the same
  panes would silently steal the first's output. `open` focuses the existing
  window instead. New windows cascade so one never lands on top of another.
- `WorkspaceStore` owns session.json, so windows never race each other as
  writers. Closing a window *detaches* — panes keep running in the daemon
  and the entry stays for the picker; `StopWorkspace` kills the sessions and
  keeps the layout; `DeleteWorkspace` also forgets it.
- Window menu lists every workspace with a monogram badge and a liveness
  dot, ⌘1–9 for the first nine. Same list in the palette; closed ones also
  appear in a home-page picker with a coarse relative age.
- Sidebar collapse and right-panel visibility move onto `Tty7App`, so
  toggling one window's chrome leaves the others alone; the config value
  becomes what a new window starts with. Panel *width* stays shared — a
  width is a preference, not a view state.
- Tray, menus, and the quit hook now walk the registry rather than
  belonging to a single window.

Protocol goes to v2: `RemoteKind::Wsl` is a new enum variant, which is not
the additive change it looks like — the enums carry no `#[serde(other)]`, so
a v1 peer fails the whole decode and drops the pane's connection. The
handshake now catches that skew and offers a restart.
2026-07-25 14:10:44 +08:00
l0ng-ai fd1062f564 fix(right-panel,editor): restore the git dependency and address review findings
The branch had `gpui-component` pointed at a sibling checkout by absolute
path, which is why every CI job failed at manifest load. Point it back at
the fork's `tty7` branch (now carrying the custom-button label-color fix
the chrome tiles depend on) with the `tree-sitter-languages` feature, and
re-lock.

Review fixes on top:

- **Changes tab churned.** `right_panel_invalidate` dropped the cached
  diff on every `GitStatusCache` notification — including unrelated
  repos' — so the list blanked to "Loading…" and spawned a fresh
  `git diff` several times a second while a pane produced output.
  Replaced by `right_panel_refresh_changes`, which compares branch and
  totals first and re-probes in place, mirroring the diff overlay.
- **Changes tab could wedge on "Loading…".** A probe dropped because the
  cwd changed mid-flight left `diff_cwd` set and `diff` empty, and the
  render path only spawns when the cwd *changes* — so nothing re-probed.
  Spawn when nothing is cached and nothing is in flight.
- **Find references blocked the UI thread.** `cx.spawn_in` runs on the
  main thread; the up-to-200 `read_to_string`s for the row previews now
  run on the background executor, as the comment already claimed.
- **LSP frames could be lost or reordered at startup.** `send` checked
  `ready` outside the `queued` lock, so a frame could park behind a
  handshake that had just finished and never go out. `ready` now flips
  under that lock in `mark_ready_and_flush`.
- `MarkScanner`'s ESC-in-payload branch bypassed the payload cap, so a
  stream of bare ESCs inside an unterminated OSC grew the buffer without
  bound.
- The file tree's search frontier used `Vec::remove(0)`; a wide tree made
  that quadratic. `VecDeque`.
- `procs()` documented a pane check it didn't make; it takes the pane id
  and makes it.
- Four doc comments had been orphaned onto newly inserted functions
  (`pty`, `smooth_scroll`, `foreground_agent`, `file_expanded`).
2026-07-24 17:07:07 +08:00
l0ng-ai 403cfd47a1 feat(right-panel): docked detail panel with Info, Changes and Files tabs
Add a right-hand detail column showing what the active pane is, not what
it prints: session facts plus its process tree and listening ports
(daemon-side procinfo, pull-based via QueryProcs), the working-tree diff,
and the file tree. Tab row lives in the title bar, body in right_panel.

Also record OSC 133 command marks client-side so the panel's Outline can
list a pane's commands and scroll back to one, keyed on row text since
absolute scrollback indices drift once history fills.
2026-07-24 15:07:35 +08:00
l0ng-ai 2dd817d8cd fix(theme): review follow-ups — diff overlay background, hot-reload window effects, docs
- Diff overlay paints the gradient/opacity-aware window background instead
  of the stale representative solid.
- Config hot-reload now re-runs apply_theme with the window (blur flip,
  traffic-light re-pin) and re-syncs the Appearance opacity slider, so
  hand-edits to config.json / theme files take effect fully.
- Document the theme background/window settings in features docs (EN + zh-CN).
2026-07-16 16:30:59 +08:00
l0ng-ai 4cdd413f5d feat(diff-overlay): per-tab lifecycle and side-by-side view
The overlay was a single app-global slot rendered over whatever tab was
active: switching tabs left a stale diff occluding the new tab's body,
with focus on the terminal underneath so Esc couldn't even close it.

Move the overlay state onto Tab so it follows product intuition: switching
away hides it, switching back restores it (re-probing if the status cache
disagrees with the shown snapshot), and closing the tab drops it.
focus_active now hands focus to the returning tab's overlay so Esc keeps
working; probe landings update every tab showing the same cwd.

Also replace the unified diff body with a GitHub-PR-style side-by-side
view: removed lines fill the left column, added lines the right, aligned
positionally within each change block; context lines land on both sides
and leftovers pair with a muted empty cell.
2026-07-16 11:27:03 +08:00
l0ng-ai f26045fbb5 style(diff-overlay): rustfmt the diff overlay module 2026-07-15 22:05:20 +08:00
l0ng-ai 68cc4680d4 feat(git): click a sidebar git line to open a working-tree diff overlay
The sidebar row's third line (branch + counts) is now a click target: it
opens a read-only, GitHub-style unified diff of that pane's repo as an
overlay covering the terminal body. The sidebar stays visible, so other
tabs' git lines remain one click away — peek at another session's
changes without switching tabs.

- terminal/git_diff: parse `git diff HEAD` into files/hunks/lines
  (renames, binary, quoted paths, per-file line cap), plus the
  untracked list `diff` can't see; probed off-thread, read-only via
  the shared GIT_OPTIONAL_LOCKS=0 helper.
- ui/diff_overlay: per-file cards with collapsible hunk bodies (big
  files collapse by default), line-number gutters, green/red tints,
  Esc / close / re-click toggles; auto-refreshes off the existing
  GitStatusCache triggers when shown counts go stale.
2026-07-15 22:00:19 +08:00