merge: main into the ligature run walk, after #783 landed

`seg_budget` grew a `measured` argument and `paint_glyphs` now shapes the
segment before it sets the budget, because how far a solo glyph may reach
turns on whether its ink was measured at all.

This branch had already replaced the `fit` flag with `run` and an early
`continue`, so two pieces of #783 are dead on arrival here: a run never
reaches that code, which makes the `fit.then(..)` wrapper around the
measurement and the `else { cell_width * cells }` arm of the budget
unreachable. Measure unconditionally instead, and hand
`ink_covers_segment` to the five-argument `seg_budget`.

The rest is adjacency: `ink_covers_segment` sits beside `run_drift` and
friends after `ink_extent`, and both sets of tests stand.
This commit is contained in:
l0ng-ai
2026-09-07 22:39:58 +08:00
24 changed files with 2429 additions and 739 deletions
+39
View File
@@ -5,6 +5,43 @@ All notable changes to tty7 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [26.9.1] - 2026-09-07
### Fixed
- **A remote server that will not start now says why** (#774). A remote
workspace could sit in a loop nobody could get out of: every reconnect failed
with "started but nothing was answering on the control socket after 15s", the
strip showed a copy bar frozen at 100%, and no button was offered. A daemon
whose control listener would not open logged one line and kept running — and a
running daemon holds the single-server lock, so every later start stood down
at once and every probe failed, forever. Whether something else is serving is
now settled by connecting rather than by reading the errno, and a daemon that
cannot listen exits. The reason is kept too: the remote daemon's output and
exit status land beside the binary, stamped with the launch's own nonce so a
restart never reads the outgoing daemon's status as the incoming one's, and a
start that has already failed no longer waits out the full timeout. On the
window side, an automatic reconnect retires its install progress instead of
drawing an install still in flight, and a long error no longer stretches the
status card past the window and takes the retry button off screen with it.
- **A stale pane socket no longer stops every later daemon** (#779). A daemon
that died without unlinking its Unix socket left a file `bind` refuses, so the
client launched a daemon, it exited on the bind, and the client launched
another — forever. The removal is now decided by the single-server seat rather
than the pidfile: holding the seat means nobody else can be serving that config
dir, so anything still at the endpoint belongs to a process that is gone.
Where there is no seat, a socket that answers is refused rather than removed.
### Changed
- **The control dialect is v9.** v8 added the project verbs; this build takes
them back out and speaks v7's messages again, message for message — but the
number does not go back with them. v8 is deployed, and a number that moves
backwards stops being an identity: a 7 on the wire would mean either "before
projects" or "after them" depending on which build put it there, and the
handshake has nothing but the number to tell the two apart.
## [26.9.0] - 2026-09-04
### Added
@@ -4401,6 +4438,8 @@ Initial release.
- zsh shell integration (OSC 7 cwd + OSC 133 prompt marks) via a throwaway `ZDOTDIR`.
- Native macOS light/dark themes that follow the system appearance.
[26.9.1]: https://github.com/l0ng-ai/tty7/compare/v26.9.0...v26.9.1
[26.9.0]: https://github.com/l0ng-ai/tty7/compare/v26.8.3...v26.9.0
[26.8.3]: https://github.com/l0ng-ai/tty7/compare/v26.8.2...v26.8.3
[26.8.2]: https://github.com/l0ng-ai/tty7/compare/v26.8.1...v26.8.2
[26.8.1]: https://github.com/l0ng-ai/tty7/compare/v26.8.0...v26.8.1
Generated
+107 -57
View File
@@ -298,7 +298,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -309,7 +309,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -376,6 +376,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "array-init"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc"
[[package]]
name = "arraydeque"
version = "0.5.1"
@@ -681,6 +687,20 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "async_zip"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb7f5f40e1eb30949a266fc900d37fd3267c7baf50c3705ac09c5d8ced5def63"
dependencies = [
"async-compression",
"binrw",
"crc32fast",
"futures-lite 2.6.1",
"pin-project",
"thiserror 2.0.20",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -891,7 +911,7 @@ dependencies = [
"bitflags 2.13.1",
"cexpr",
"clang-sys",
"itertools 0.13.0",
"itertools 0.11.0",
"log",
"prettyplease",
"proc-macro2",
@@ -902,6 +922,30 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "binrw"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ad120d555272286c1017d25165ab8bd74806f13fc85b258484ec7e4ce75458f"
dependencies = [
"array-init",
"binrw_derive",
"bytemuck",
]
[[package]]
name = "binrw_derive"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92e0e9baae4dc82c7bad7715ca40c0a5c71539057bf2ea04a5c29c980410b"
dependencies = [
"either",
"owo-colors",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -1483,7 +1527,7 @@ dependencies = [
[[package]]
name = "collections"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"gpui_util",
"indexmap",
@@ -2047,7 +2091,7 @@ dependencies = [
[[package]]
name = "derive_refineable"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"proc-macro2",
"quote",
@@ -2104,7 +2148,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2142,7 +2186,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading 0.8.9",
"libloading 0.7.4",
]
[[package]]
@@ -2394,7 +2438,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -3199,7 +3243,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.20",
"windows 0.62.2",
"windows 0.58.0",
]
[[package]]
@@ -3225,7 +3269,7 @@ dependencies = [
[[package]]
name = "gpui"
version = "0.2.2"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"accesskit",
"anyhow",
@@ -3416,7 +3460,7 @@ dependencies = [
[[package]]
name = "gpui_linux"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"accesskit",
"accesskit_unix",
@@ -3467,7 +3511,7 @@ dependencies = [
[[package]]
name = "gpui_macos"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"accesskit",
"accesskit_macos",
@@ -3514,7 +3558,7 @@ dependencies = [
[[package]]
name = "gpui_macros"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -3525,7 +3569,7 @@ dependencies = [
[[package]]
name = "gpui_platform"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"console_error_panic_hook",
"gpui",
@@ -3538,7 +3582,7 @@ dependencies = [
[[package]]
name = "gpui_shared_string"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"schemars",
"serde",
@@ -3548,7 +3592,7 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"log",
@@ -3557,7 +3601,7 @@ dependencies = [
[[package]]
name = "gpui_web"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"console_error_panic_hook",
@@ -3581,7 +3625,7 @@ dependencies = [
[[package]]
name = "gpui_wgpu"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"bytemuck",
@@ -3610,7 +3654,7 @@ dependencies = [
[[package]]
name = "gpui_windows"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"accesskit",
"accesskit_windows",
@@ -3939,7 +3983,7 @@ dependencies = [
[[package]]
name = "http_client"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"async-compression",
@@ -3964,7 +4008,7 @@ dependencies = [
[[package]]
name = "http_client_tls"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"rustls",
"rustls-platform-verifier",
@@ -4057,7 +4101,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.58.0",
]
[[package]]
@@ -4581,9 +4625,9 @@ dependencies = [
[[package]]
name = "keyring"
version = "4.1.6"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a"
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
@@ -4901,9 +4945,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
dependencies = [
"serde_core",
"value-bag",
@@ -5092,7 +5136,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
[[package]]
name = "media"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"bindgen",
@@ -5202,7 +5246,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5258,7 +5302,7 @@ dependencies = [
"once_cell",
"png",
"thiserror 2.0.20",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -5481,7 +5525,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6006,6 +6050,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "owo-colors"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8"
[[package]]
name = "p256"
version = "0.14.0-rc.15"
@@ -6204,7 +6254,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "perf"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"collections",
"serde",
@@ -6900,7 +6950,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7207,7 +7257,7 @@ dependencies = [
[[package]]
name = "refineable"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"derive_refineable",
]
@@ -7250,7 +7300,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "reqwest_client"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"bytes",
@@ -7611,7 +7661,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7784,7 +7834,7 @@ dependencies = [
[[package]]
name = "scheduler"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"async-task",
"backtrace",
@@ -8389,7 +8439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8515,7 +8565,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -8627,7 +8677,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "sum_tree"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"heapless",
"log",
@@ -8917,7 +8967,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9384,7 +9434,7 @@ dependencies = [
"once_cell",
"png",
"thiserror 2.0.20",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -9775,11 +9825,11 @@ dependencies = [
[[package]]
name = "tty7"
version = "26.9.0"
version = "26.9.1"
dependencies = [
"alacritty_terminal",
"anyhow",
"async_zip",
"async_zip 0.0.19",
"core-foundation 0.10.0",
"gpui",
"gpui-component",
@@ -9820,7 +9870,7 @@ dependencies = [
[[package]]
name = "tty7-cli"
version = "26.9.0"
version = "26.9.1"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -9835,7 +9885,7 @@ dependencies = [
[[package]]
name = "tty7-core"
version = "26.9.0"
version = "26.9.1"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -9869,7 +9919,7 @@ dependencies = [
[[package]]
name = "tty7-server"
version = "26.9.0"
version = "26.9.1"
dependencies = [
"libc",
"tempfile",
@@ -9896,7 +9946,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -10108,11 +10158,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "util"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"async-fs",
"async_zip",
"async_zip 0.0.18",
"collections",
"command-fds",
"dirs",
@@ -10147,7 +10197,7 @@ dependencies = [
[[package]]
name = "util_macros"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"perf",
"quote",
@@ -10156,9 +10206,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.24.1"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -10740,7 +10790,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -11491,7 +11541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -12048,7 +12098,7 @@ dependencies = [
[[package]]
name = "zlog"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"anyhow",
"chrono",
@@ -12093,7 +12143,7 @@ dependencies = [
[[package]]
name = "ztracing"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
dependencies = [
"tracing",
"tracing-subscriber",
@@ -12104,7 +12154,7 @@ dependencies = [
[[package]]
name = "ztracing_macro"
version = "0.1.0"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#d99a40a5f79a173465feec00363d480f75881a81"
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#ece710e365575bc08656c4a8d20488769b2de9a6"
[[package]]
name = "zune-core"
+11 -3
View File
@@ -172,7 +172,7 @@ windows-sys = { version = "0.61", features = [
# The standalone updater extracts only Windows release ZIPs. Reuse the async
# reader already present in Cargo.lock and enable only the Deflate codec emitted
# by PowerShell's Compress-Archive.
async_zip = { version = "0.0.18", default-features = false, features = ["deflate"], optional = true }
async_zip = { version = "0.0.19", default-features = false, features = ["deflate"], optional = true }
# COM for toast branding (`core::aumid`): IShellLinkW + IPropertyStore stamp
# System.AppUserModel.ID onto the Start Menu shortcut, which Windows requires
@@ -271,7 +271,7 @@ edition = "2024"
# they must never drift apart. This is the line a release bump edits (and the
# one nightly's `awk '/^version = /'` stamps: it is the first such line in the
# file, since the package entries above are all `version.workspace = true`).
version = "26.9.0"
version = "26.9.1"
[workspace.dependencies]
# Our fork's `tty7` branch carries the local customizations tty7 relies on:
@@ -367,7 +367,7 @@ lto = "thin"
codegen-units = 1
# ---- gpui fork ------------------------------------------------------------
# Our `tty7` branch (cut from the pinned upstream rev, three commits on top) carries:
# Our `tty7` branch (cut from the pinned upstream rev) carries:
#
# 1. `prefers_ime_for_printable_keys` takes the keystroke, so an input handler can
# answer per key instead of per view. tty7 needs it for Option-as-Meta — macOS
@@ -386,6 +386,14 @@ codegen-units = 1
# 3. resvg/usvg bumped 0.45 → 0.47 so gpui's SVG stack unifies with the resvg
# tty7 pins directly above (follow-up to the #227 dependabot bump).
#
# 4. `ListState::with_size_hint`. A list counts an item it has not laid out yet
# as zero tall, so a long document reports itself as about one viewport and
# a scrollbar reading that height drags one viewport and stops — the diff
# overlay scrolls a whole working tree through one of these. The hint counts
# the unmeasured items at an estimate instead, which `measure_all` would
# settle exactly at the price of laying out the whole document on the first
# frame (see `ui::diff_overlay::DIFF_LINE_H`).
#
# Patching by source rather than editing the `gpui`/`gpui_platform` pins above is
# deliberate: `gpui-component` declares its own `gpui` from the upstream URL, and
# a plain pin swap would put two incompatible copies of gpui in the tree. `[patch]`
+3
View File
@@ -163,8 +163,11 @@ getrandom = "0.3"
# tree on hangup (ConPTY's `kill` only reaches the shell itself). `Registry`
# additionally lets `daemon::windows_env` re-read the two environment hives at
# pane-spawn time so a long-lived daemon stops handing out its startup `PATH`.
# `IpHelper` is `GetExtendedTcpTable`, which is how `daemon::procinfo` answers
# the Info tab's Ports section here — the platform has no `lsof` to shell out to.
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_Security",
"Win32_System_Console",
"Win32_System_Diagnostics_ToolHelp",
+403 -1
View File
@@ -296,7 +296,225 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
ports
}
#[cfg(not(unix))]
/// Windows has no `lsof`, and the Ports section was simply never drawn there —
/// the daemon answered `QueryProcs` with an empty list no matter what the pane
/// was running, so a `npm run dev` in a Windows pane showed processes and no
/// port to click.
///
/// `GetExtendedTcpTable` is the same answer without a subprocess: the kernel's
/// own table of listening sockets, each already tagged with the pid that owns
/// it. The table is machine-wide, so the filter against the pane's tree below
/// is the whole difference between this panel and `netstat -ano`.
///
/// **Cost.** Two calls per poll — one per address family — into a buffer sized
/// for far more listeners than a real machine has; a family only pays for a
/// second call when its table outgrew that. The Info tab re-polls every two
/// seconds while it is open, so this is a fixed handful of microseconds, with
/// no process spawn and nothing allocated per pid.
#[cfg(windows)]
fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> {
use std::net::{Ipv4Addr, Ipv6Addr};
use windows_sys::Win32::NetworkManagement::IpHelper::{
MIB_TCP6ROW_OWNER_PID, MIB_TCP6TABLE_OWNER_PID, MIB_TCPROW_OWNER_PID,
MIB_TCPTABLE_OWNER_PID,
};
if procs.is_empty() {
return Vec::new();
}
let by_pid: HashMap<u32, &str> = procs.iter().map(|p| (p.pid, p.name.as_str())).collect();
let mut ports: Vec<PortEntry> = Vec::new();
let v4 = tcp_table(AF_INET);
// SAFETY: `tcp_table` hands back either an empty buffer or one the kernel
// filled with a `MIB_TCPTABLE_OWNER_PID`; the `Vec<u32>` gives it the 4-byte
// alignment every field of that struct wants, and `rows` is clamped to what
// the buffer can actually hold before anything is read out of it.
unsafe {
if let Some((rows, count)) = table_rows::<MIB_TCPTABLE_OWNER_PID, MIB_TCPROW_OWNER_PID>(&v4)
{
for i in 0..count {
let row = &*rows.add(i);
// Filter before spelling the address: the table is the whole
// machine's, and formatting a string for every stranger's
// socket is the one avoidable allocation on this path.
let Some(name) = by_pid.get(&row.dwOwningPid) else {
continue;
};
let addr = Ipv4Addr::from(row.dwLocalAddr.to_ne_bytes());
record_listener(
&mut ports,
name,
local_port(row.dwLocalPort),
row.dwOwningPid,
spell_v4(addr),
);
}
}
}
let v6 = tcp_table(AF_INET6);
// SAFETY: as above, for the IPv6 shape of the same table.
unsafe {
if let Some((rows, count)) =
table_rows::<MIB_TCP6TABLE_OWNER_PID, MIB_TCP6ROW_OWNER_PID>(&v6)
{
for i in 0..count {
let row = &*rows.add(i);
let Some(name) = by_pid.get(&row.dwOwningPid) else {
continue;
};
let addr = Ipv6Addr::from(row.ucLocalAddr);
record_listener(
&mut ports,
name,
local_port(row.dwLocalPort),
row.dwOwningPid,
spell_v6(addr),
);
}
}
}
ports.sort_by_key(|e| (e.port, e.pid));
ports
}
/// The two Winsock address families, named here rather than by switching on
/// `windows-sys`'s `Win32_Networking_WinSock`: the whole socket module is a
/// long compile for two integers the ABI froze decades ago.
#[cfg(windows)]
const AF_INET: u32 = 2;
#[cfg(windows)]
const AF_INET6: u32 = 23;
/// One `GetExtendedTcpTable` snapshot of the listening sockets in `family`, as
/// the raw buffer the kernel filled, or an empty buffer if it would not answer.
///
/// Failure is soft, the way an absent `lsof` is soft on unix: the panel shows no
/// ports rather than an error. A machine with IPv6 disabled takes that path for
/// `AF_INET6` alone and still gets its IPv4 ports.
#[cfg(windows)]
fn tcp_table(family: u32) -> Vec<u32> {
use windows_sys::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, NO_ERROR};
use windows_sys::Win32::NetworkManagement::IpHelper::{
GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
};
// 8 KiB: room for ~340 IPv4 or ~146 IPv6 listeners, where a busy desktop has
// a few dozen. Sizing it up front is what keeps the common poll to one call
// per family instead of the usual size-then-fetch pair.
let mut buf = vec![0u32; 2048];
// Two rounds, not a loop until it fits: the table can keep growing between
// calls, and this runs on a 2 s timer where giving up costs one poll.
for _ in 0..2 {
let mut size = (buf.len() * std::mem::size_of::<u32>()) as u32;
// SAFETY: `buf` is at least `size` bytes, 4-aligned, and writable; the
// kernel writes no more than `size` and reports what it needed instead.
let rc = unsafe {
GetExtendedTcpTable(
buf.as_mut_ptr().cast(),
&mut size,
// No kernel-side sort: the rows are ordered by (port, pid) below
// anyway, and this one is over the address, not the port.
0,
family,
TCP_TABLE_OWNER_PID_LISTENER,
0,
)
};
match rc {
NO_ERROR => return buf,
ERROR_INSUFFICIENT_BUFFER => {
buf = vec![0u32; (size as usize).div_ceil(std::mem::size_of::<u32>()) + 64]
}
_ => break,
}
}
Vec::new()
}
/// Where the rows of a `MIB_*TABLE_OWNER_PID` start in `buf`, and how many of
/// them the buffer can be trusted for.
///
/// The count is `min`'d against the buffer's own capacity rather than taken from
/// `dwNumEntries` alone: that field is the kernel's, but the read that follows
/// it is ours, and a table shorter than its header claims must not walk off the
/// end of the allocation.
///
/// # Safety
///
/// `buf` must be empty or hold a `Table` the kernel filled.
#[cfg(windows)]
unsafe fn table_rows<Table, Row>(buf: &[u32]) -> Option<(*const Row, usize)> {
let bytes = std::mem::size_of_val(buf);
if bytes < std::mem::size_of::<Table>() {
return None;
}
let table = buf.as_ptr().cast::<Table>();
// Every `MIB_*TABLE_OWNER_PID` is `{ dwNumEntries: u32, table: [Row; 1] }`,
// so the count is the first word and the rows begin where the padding ends.
let count = buf[0] as usize;
let offset = std::mem::size_of::<Table>() - std::mem::size_of::<Row>();
let capacity = (bytes - offset) / std::mem::size_of::<Row>();
let rows = unsafe { table.cast::<u8>().add(offset) }.cast::<Row>();
Some((rows, count.min(capacity)))
}
/// `dwLocalPort` carries the port in *network* byte order in its low 16 bits.
/// Reading it as a plain number is the classic way to end up showing 41247 for
/// a server on 8099.
#[cfg(windows)]
fn local_port(raw: u32) -> u16 {
u16::from_be(raw as u16)
}
/// The wildcard binds are spelled `*`, exactly as `lsof -n` spells them on the
/// other platforms, so the same server reads the same in the panel wherever it
/// runs — and so `PortEntry::authority` resolves it to `localhost`.
#[cfg(windows)]
fn spell_v4(addr: std::net::Ipv4Addr) -> String {
match addr.is_unspecified() {
true => "*".to_string(),
false => addr.to_string(),
}
}
/// See `spell_v4`. A specific IPv6 address keeps `lsof`'s brackets, which is
/// what makes `[::1]:5173` a pastable authority.
#[cfg(windows)]
fn spell_v6(addr: std::net::Ipv6Addr) -> String {
match addr.is_unspecified() {
true => "*".to_string(),
false => format!("[{addr}]"),
}
}
/// Adds one listening socket to the list, merging it with a row already there
/// for the same port and pid.
///
/// The merge rule is the unix path's, for the same reason: a process bound to
/// both `192.168.1.5` and `*` is on localhost, and the row the panel turns into
/// a clickable URL should say so rather than whichever address the kernel
/// happened to list first.
#[cfg(windows)]
fn record_listener(ports: &mut Vec<PortEntry>, name: &str, port: u16, pid: u32, addr: String) {
if let Some(seen) = ports.iter_mut().find(|e| e.port == port && e.pid == pid) {
if !PortEntry::reaches_loopback(&seen.addr) && PortEntry::reaches_loopback(&addr) {
seen.addr = addr;
}
return;
}
ports.push(PortEntry {
port,
pid,
addr,
name: name.to_string(),
});
}
#[cfg(not(any(unix, windows)))]
fn listening_ports(_procs: &[ProcEntry]) -> Vec<PortEntry> {
Vec::new()
}
@@ -408,3 +626,187 @@ mod tests {
assert_eq!(entry("192.168.1.20").authority(), "192.168.1.20:8080");
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use std::io::Write as _;
use std::net::TcpListener;
use std::time::{Duration, Instant};
use super::*;
/// The port lives in the low half of a DWORD in *network* order. Getting
/// this wrong does not fail loudly — it yields a plausible port number for
/// a socket nobody is listening on.
#[test]
fn a_local_port_is_read_out_of_network_order() {
// 8099 == 0x1FA3, so the wire spells it 0xA31F.
assert_eq!(local_port(0x0000_A31F), 8099);
assert_eq!(local_port(0xBB01), 443);
assert_eq!(local_port(0x5000), 80);
}
/// The panel renders one server the same way on every platform, so a
/// Windows wildcard bind has to arrive spelled the way `lsof -n` spells it.
#[test]
fn addresses_are_spelled_the_way_lsof_spells_them() {
assert_eq!(spell_v4("0.0.0.0".parse().unwrap()), "*");
assert_eq!(spell_v4("127.0.0.1".parse().unwrap()), "127.0.0.1");
assert_eq!(spell_v4("192.168.1.20".parse().unwrap()), "192.168.1.20");
assert_eq!(spell_v6("::".parse().unwrap()), "*");
assert_eq!(spell_v6("::1".parse().unwrap()), "[::1]");
}
/// A dual-stack server shows up twice in the kernel's tables, once per
/// family, and is one row in the panel — the reachable one.
#[test]
fn a_dual_stack_listener_collapses_to_its_reachable_address() {
let mut ports = Vec::new();
record_listener(&mut ports, "node.exe", 3000, 42, "192.168.1.5".into());
record_listener(&mut ports, "node.exe", 3000, 42, "*".into());
assert_eq!(ports.len(), 1, "one port, not one per address family");
assert_eq!(ports[0].addr, "*", "the loopback-reachable bind wins");
// ...and never the other way round: a wildcard already recorded is not
// downgraded to an interface nobody can reach on localhost.
let mut ports = Vec::new();
record_listener(&mut ports, "node.exe", 3000, 42, "[::]".into());
record_listener(&mut ports, "node.exe", 3000, 42, "192.168.1.5".into());
assert_eq!(ports[0].addr, "[::]");
// Two processes on the same port number are two rows.
record_listener(&mut ports, "python.exe", 3000, 43, "127.0.0.1".into());
assert_eq!(ports.len(), 2);
}
/// The whole feature, against the live kernel table: a real
/// `cmd.exe -> powershell.exe` chain holding a real socket.
///
/// Both halves matter. The port has to show up with the right number and
/// the right owner — that is the half that was missing entirely, since
/// `listening_ports` was `#[cfg(unix)]` and Windows got an empty list. And
/// a socket held *outside* the tree must not show up, because
/// `GetExtendedTcpTable` answers for the whole machine: without the pid
/// filter this panel would list every port on the box and still pass the
/// first assertion.
#[test]
fn listening_ports_finds_the_pane_tree_s_socket_and_only_its_tree_s() {
// The out-of-tree listener. It belongs to the test process, which is
// the parent of the chain and so is never inside the tree rooted at it.
let outsider = TcpListener::bind("127.0.0.1:0").expect("bind an out-of-tree listener");
let outside_port = outsider.local_addr().expect("read its port").port();
let stamp = format!("tty7-ports-{}", std::process::id());
let dir = std::env::temp_dir();
let script = dir.join(format!("{stamp}.ps1"));
let port_file = dir.join(format!("{stamp}.port"));
let _ = std::fs::remove_file(&port_file);
// The port comes back through a file rather than a pipe: PowerShell
// buffers redirected stdout, and a test that waits on a flush that
// never comes is a test that hangs.
let mut f = std::fs::File::create(&script).expect("write the listener script");
write!(
f,
"$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)\r\n\
$l.Start()\r\n\
[System.IO.File]::WriteAllText('{}', [string]$l.LocalEndpoint.Port)\r\n\
while ($true) {{ Start-Sleep -Seconds 1 }}\r\n",
port_file.display().to_string().replace('\'', "''")
)
.expect("write the listener script");
drop(f);
// `cmd.exe` in front of PowerShell is what makes this a *tree* and not
// one child: the listener sits at depth 1, reached only by walking.
let mut child = std::process::Command::new("cmd.exe")
.args([
"/c",
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(&script)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn the cmd -> powershell chain");
let root = child.id();
let cleanup = |child: &mut std::process::Child| {
let doomed = crate::daemon::winproc::descendants(
&crate::daemon::winproc::snapshot(),
child.id(),
);
let _ = child.kill();
let _ = child.wait();
crate::daemon::winproc::terminate_and_wait_all(
&doomed,
Instant::now() + Duration::from_secs(5),
);
};
// PowerShell's startup is measured in seconds on a cold machine, and
// `WriteAllText` can be observed mid-write, so parse until it parses.
let deadline = Instant::now() + Duration::from_secs(60);
let inside_port = loop {
if let Some(port) = std::fs::read_to_string(&port_file).ok().and_then(|text| {
text.trim()
.trim_start_matches('\u{feff}')
.parse::<u16>()
.ok()
}) {
break port;
}
if Instant::now() >= deadline {
cleanup(&mut child);
let _ = std::fs::remove_file(&script);
panic!("the in-tree listener never reported its port");
}
std::thread::sleep(Duration::from_millis(100));
};
let got = snapshot(root, None);
cleanup(&mut child);
let _ = std::fs::remove_file(&script);
let _ = std::fs::remove_file(&port_file);
drop(outsider);
assert!(
got.procs.iter().any(|p| p.depth > 0),
"the chain must be walked past its root: {:?}",
got.procs
);
let found = got
.ports
.iter()
.find(|e| e.port == inside_port)
.unwrap_or_else(|| {
panic!("port {inside_port} is missing from {:?}", got.ports);
});
assert_eq!(
found.addr, "127.0.0.1",
"a loopback bind keeps its address, as it does under lsof"
);
assert!(
got.procs.iter().any(|p| p.pid == found.pid),
"the port's owner must be one of the pane's own processes"
);
assert!(
found.name.eq_ignore_ascii_case("powershell.exe"),
"the row names the process holding the socket, got {:?}",
found.name
);
assert!(
!got.ports.iter().any(|e| e.port == outside_port),
"port {outside_port} is held outside the tree and must not be listed: {:?}",
got.ports
);
assert!(
got.ports.windows(2).all(|w| w[0].port <= w[1].port),
"rows arrive ordered by port, as they do on unix"
);
}
}
+81 -4
View File
@@ -5,6 +5,9 @@ use serde::{Deserialize, Serialize};
pub const MAX_FRAME: usize = 64 * 1024 * 1024;
/// A frame is a little-endian `u32` length, a one-byte kind, then the payload.
const HEADER: usize = 5;
pub const PROTOCOL_VERSION: u32 = 6;
pub const FEATURE_PANE_OWNER: &str = "pane-owner";
@@ -952,9 +955,33 @@ pub fn write_frame<W: Write>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<
"frame payload exceeds MAX_FRAME",
));
}
w.write_all(&(len as u32).to_le_bytes())?;
w.write_all(&[kind])?;
w.write_all(payload)?;
// One write, not three. The pane socket is a loopback `TcpStream` with
// `TCP_NODELAY` set, so three `write_all`s put the length, the kind and the
// payload on the wire as three separate segments, and the reader on the far
// side wakes from `read()` three times for one frame. Under a PTY flood the
// daemon frames every ConPTY read, so that is two extra syscalls on each
// side per frame, tens of thousands a second (issue #713).
let mut header = [0u8; HEADER];
header[..4].copy_from_slice(&(len as u32).to_le_bytes());
header[4] = kind;
let mut bufs = [io::IoSlice::new(&header), io::IoSlice::new(payload)];
let mut rest: &mut [io::IoSlice<'_>] = &mut bufs;
while !rest.is_empty() {
match w.write_vectored(rest) {
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"the frame could not be written in full",
));
}
// A writer that does not implement `write_vectored` natively falls
// back to writing the first non-empty slice, so this loop still
// terminates — it just costs the two writes it used to cost.
Ok(n) => io::IoSlice::advance_slices(&mut rest, n),
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(())
}
@@ -984,7 +1011,6 @@ pub fn is_error_kind(kind: u8) -> bool {
}
pub fn take_frame(buf: &mut Vec<u8>) -> io::Result<Option<(u8, Vec<u8>)>> {
const HEADER: usize = 5;
if buf.len() < HEADER {
return Ok(None);
}
@@ -2046,6 +2072,57 @@ mod tests {
assert!(buf.is_empty());
}
/// The pane socket has `TCP_NODELAY` set, so a write is a segment and a
/// segment is a wakeup on the far side. A frame must therefore cost one
/// write, not one for the length, one for the kind and one for the payload
/// (issue #713) — at flood rates that difference is tens of thousands of
/// syscalls a second on each end.
#[test]
fn a_frame_is_one_write_on_a_vectored_writer() {
#[derive(Default)]
struct Counting {
writes: usize,
bytes: Vec<u8>,
}
impl Write for Counting {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes += 1;
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn write_vectored(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<usize> {
self.writes += 1;
let mut n = 0;
for b in bufs {
self.bytes.extend_from_slice(b);
n += b.len();
}
Ok(n)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let mut w = Counting::default();
write_frame(&mut w, kind::OUTPUT, b"a chunk of pty output").expect("write the frame");
assert_eq!(w.writes, 1, "one frame must cost one write");
// An empty payload is a frame too — the header still has to land, and
// the empty second slice must not spin the loop.
let mut empty = Counting::default();
write_frame(&mut empty, kind::DETACH, &[]).expect("write the empty frame");
assert_eq!(empty.writes, 1);
// Whatever the write count, the bytes on the wire are unchanged: a
// `read_frame` over them gives back exactly what went in.
let mut cursor = io::Cursor::new(w.bytes);
assert_eq!(
read_frame(&mut cursor).expect("read it back"),
(kind::OUTPUT, b"a chunk of pty output".to_vec())
);
}
#[test]
fn from_frame_rejects_unknown_kind() {
assert!(ClientMsg::from_frame(99, vec![]).is_err());
+12 -1
View File
@@ -14,7 +14,7 @@ description: "The bundled default, fallback chains, ligatures, and why CJK needs
| **Interface font family** | system UI font | The face for everything outside the grid |
| **Line height** | 1.4 | A multiple of the font size |
| **Bold font** / **Italic font** | — | Distinct faces, when you want them |
| **Font ligatures** | off | Contextual alternates stay off unless you ask |
| **Font ligatures** | off | `calt`, `liga` and `clig` all stay off unless you ask |
## Hack is bundled
@@ -60,6 +60,17 @@ A tag must be four alphanumeric characters; `true`/`false` map to `1`/`0`.
Anything malformed is skipped with a log line rather than failing the whole
config.
Writing *any* tag hands the whole feature set to you: the terminal only names
`calt: 0`, `liga: 0` and `clig: 0` itself while `font_features` is unset. So
`{"font_features": {"zero": 1}}` turns slashed zeroes on **and** ligatures back
on. Name them explicitly if you want both:
```json
{
"font_features": { "zero": 1, "calt": 0, "liga": 0, "clig": 0 }
}
```
## CJK and the two-column grid
<Info>
+1 -1
View File
@@ -112,7 +112,7 @@ Keybindings**:
| Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` |
| Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` |
| SSH | `ToggleSftp` · `ShowSshForwards` · `OpenSshProfiles` |
| Application | `About` · `CheckForUpdates` · `OpenDocumentation` · `OpenDiscord` · `ReportIssue` · `ShowAll` · `ZoomWindow` |
| Application | `CloseWindow` · `About` · `CheckForUpdates` · `OpenDocumentation` · `OpenDiscord` · `ReportIssue` · `ShowAll` · `ZoomWindow` |
## Rebinding syntax
+1
View File
@@ -18,6 +18,7 @@ actions!(
SelectWorkspace7,
SelectWorkspace8,
SelectWorkspace9,
CloseWindow,
CloseActiveTab,
RenameTab,
NewWorktreeTab,
+199 -27
View File
@@ -149,11 +149,39 @@ fn build_font(base: &Font, bold: bool, italic: bool) -> Font {
FontStyle::Normal
};
if f.features.tag_value_list().is_empty() {
f.features = gpui::FontFeatures::disable_ligatures();
f.features = ligatures_off();
}
f
}
/// The feature set that actually turns ligatures off in a terminal grid.
///
/// gpui's own `FontFeatures::disable_ligatures()` emits `calt: 0` and nothing
/// else. `calt` is only the contextual-alternate feature a programming face
/// drives `=>` and `!=` from; the `fi`/`ffl`/`ffi` ligatures live in `liga` and
/// `clig`, which that call never mentions — so they were left at the shaper's
/// default, and CoreText, DirectWrite and rustybuzz all default them on. A
/// terminal cannot have them: a ligature is one glyph where the grid budgeted a
/// cell per character, so the row drifts.
///
/// Naming all three is therefore the whole fix, and it is not platform
/// specific. Windows is the sharpest case only because gpui's DirectWrite
/// backend always attaches an `IDWriteTypography` to the run: an empty feature
/// list leaves that object empty, which suppresses DirectWrite's own defaults,
/// while any non-empty list has `liga: 1`/`clig: 1` appended to it
/// (`gpui_windows/src/direct_write.rs`, `apply_font_features`). Asking for
/// `calt: 0` there bought ligatures that asking for nothing would not have.
///
/// `rlig` is deliberately left alone: it carries the required ligatures a
/// script cannot be written without.
fn ligatures_off() -> gpui::FontFeatures {
gpui::FontFeatures(std::sync::Arc::new(vec![
("calt".to_string(), 0),
("liga".to_string(), 0),
("clig".to_string(), 0),
]))
}
fn snapshot_cell(
cell: &Cell,
point: AlacPoint,
@@ -975,12 +1003,26 @@ fn native_cell_residue(style: &GlyphStyle) -> Option<char> {
/// and letting it spill. This follows WezTerm, whose answer shows the glyph
/// whole most often: a quarter cell of slack always, and a whole extra cell
/// when the neighbouring cell is blank and has nothing to lose.
fn seg_budget(solo: bool, cells: usize, room: bool, cell_width: Pixels) -> Pixels {
fn seg_budget(solo: bool, measured: bool, cells: usize, room: bool, cell_width: Pixels) -> Pixels {
if solo {
// Single-cell glyphs have always been allowed to lean into the next
// cell. Narrowing that here would shrink a pile of symbols that look
// fine today, so it stays a separate decision.
cell_width * 2.
// Single-cell glyphs are allowed to lean into the next cell — that is
// what keeps the pile of symbols that look fine today from shrinking —
// but only while that cell is empty. A Nerd Font icon pulled from a
// fallback face inks well past a narrow primary's cell (1.6 cells is
// typical), and where the next cell has a glyph of its own the lean is
// not a lean, it is an overlap: the neighbour is painted afterwards
// and lands on top of the overshoot. Hand those their own cell and let
// `fit_scale` bring them down into it, the way kitty and ghostty do.
//
// Only where the ink was measured, though. This number is the clip as
// well as the threshold to shrink at, so narrowing it for a segment
// nothing could measure would cut the glyph instead of scaling it —
// see `ink_covers_segment`.
if room || !measured {
cell_width * 2.
} else {
cell_width
}
} else if room {
cell_width * (cells as f32 + 1.)
} else {
@@ -1134,6 +1176,21 @@ fn shape_piece(
.shape_line(piece, font_size, run_buf, force_width)
}
/// Whether [`ink_extent`]'s answer speaks for the whole segment.
///
/// It measures the segment's first character in the run's first face. That is
/// all of a one-character segment and only part of anything longer: a
/// cluster's combining marks can ink well to the right of the base they hang
/// off — Devanagari `\u{915}` + `\u{93E}` — and are not in the number. `None`
/// is a face that answered no bounds at all, which is no measurement either.
///
/// Neither can be scaled to fit, so neither may be held to one cell: the
/// budget doubles as the clip, and a segment that cannot shrink into a
/// narrowed one is simply cut off at it.
fn ink_covers_segment(ink: Option<Pixels>, text: &str) -> bool {
ink.is_some() && text.chars().nth(1).is_none()
}
/// Whether the cell after a segment is free for its glyph to lean into.
///
/// A blank still owns its cell if it paints anything there: a background, a
@@ -1314,18 +1371,21 @@ fn paint_glyphs(
continue;
}
let budget = seg_budget(
solo,
cells,
has_room_after(row_cells, start, cells),
geom.cell_width,
);
let mut shaped =
window
.text_system()
.shape_line(text.clone(), font_size, run_buf, force_width);
if let Some(ink) = ink_extent(cx, &shaped, &text, font_size) {
// Measured before the budget is set, because how far a solo glyph
// may reach turns on whether its ink is known at all.
let ink = ink_extent(cx, &shaped, &text, font_size);
let budget = seg_budget(
solo,
ink_covers_segment(ink, &text),
cells,
has_room_after(row_cells, start, cells),
geom.cell_width,
);
if let Some(ink) = ink {
let scale = fit_scale(ink, budget);
if scale < 1. {
shaped = window.text_system().shape_line(
@@ -2495,7 +2555,7 @@ mod tests {
let ink = px(18.75);
let scale = |advance_em: f32, room: bool| {
let cell = px(15. * advance_em);
fit_scale(ink, seg_budget(false, 2, room, cell))
fit_scale(ink, seg_budget(false, true, 2, room, cell))
};
// Menlo and friends: a quarter cell of slack is enough on its own.
@@ -2995,30 +3055,142 @@ mod tests {
#[test]
fn seg_budget_frees_solo_symbols_and_lends_a_cell_only_when_one_is_free() {
let cell = px(10.);
assert_eq!(seg_budget(true, 1, false, cell), px(20.), "solo keeps two");
assert_eq!(
seg_budget(false, 2, false, cell),
seg_budget(true, true, 1, true, cell),
px(20.),
"a solo glyph leans into a free cell"
);
assert_eq!(
seg_budget(true, true, 1, false, cell),
px(10.),
"but keeps to its own once the next cell is taken"
);
assert_eq!(
seg_budget(false, true, 2, false, cell),
px(22.5),
"a quarter cell"
);
assert_eq!(seg_budget(false, 2, true, cell), px(30.), "a whole cell");
assert_eq!(seg_budget(false, 1, false, cell), px(12.5));
assert_eq!(
seg_budget(false, true, 2, true, cell),
px(30.),
"a whole cell"
);
assert_eq!(seg_budget(false, true, 1, false, cell), px(12.5));
}
#[test]
fn build_font_disables_ligatures_unless_features_are_configured() {
let font = build_font(&gpui::font("Test"), false, false);
assert_eq!(font.features.is_calt_enabled(), Some(false));
fn a_fallback_icon_is_fitted_to_its_cell_only_when_the_next_one_is_taken() {
// Measured on Windows: Maple Mono NF CN supplying U+F059 to a
// 15px Cascadia Mono grid inks 13.85px across an 8.79px cell.
let cell = px(8.789);
let ink = px(13.845);
let mut configured = gpui::font("Test");
configured.features = serde_json::from_str(r#"{"calt":true,"liga":1}"#).unwrap();
let font = build_font(&configured, false, false);
assert_eq!(font.features.is_calt_enabled(), Some(true));
let leaning = fit_scale(ink, seg_budget(true, true, 1, true, cell));
assert_eq!(leaning, 1., "a blank neighbour still lends its cell");
let crowded = fit_scale(ink, seg_budget(true, true, 1, false, cell));
assert!(crowded < 1., "an occupied neighbour does not");
assert!(
ink * crowded <= cell,
"and the icon has to end inside its own cell"
);
}
#[test]
fn a_solo_segment_is_held_to_its_cell_only_where_its_ink_was_measured() {
let cell = px(10.);
let budget = |ink, text| seg_budget(true, ink_covers_segment(ink, text), 1, false, cell);
// One character the face answered bounds for: the whole of it was
// measured, so `fit_scale` can bring it into one cell and the clip
// may be drawn there.
assert!(ink_covers_segment(Some(px(15.)), "\u{f059}"));
assert_eq!(budget(Some(px(15.)), "\u{f059}"), px(10.));
// A base with a combining mark hanging off it reaches paint_glyphs as
// a one-cell `Cluster`, which counts as solo. `ink_extent` read the
// base alone: Devanagari ka plus the aa matra inks to the right of the
// base, and none of that overhang is in the 9px. Nothing would shrink
// it, so a one-cell clip would cut the matra clean off.
assert!(!ink_covers_segment(Some(px(9.)), "\u{915}\u{93E}"));
assert_eq!(budget(Some(px(9.)), "\u{915}\u{93E}"), px(20.));
// A face that answers no bounds at all is the same story from the
// other side: `fit_scale` is never reached, so halving the clip only
// clips.
assert!(!ink_covers_segment(None, "\u{f059}"));
assert_eq!(budget(None, "\u{f059}"), px(20.));
// A free neighbour lends its cell either way \u2014 the measurement only
// decides whether the lean can be withdrawn.
for ink in [Some(px(15.)), None] {
for text in ["\u{f059}", "\u{915}\u{93E}"] {
assert_eq!(
seg_budget(true, ink_covers_segment(ink, text), 1, true, cell),
px(20.)
);
}
}
}
/// The emitted feature set is the whole contract with the shaper, so pin it
/// tag for tag rather than asking after one tag at a time.
///
/// `calt: 0` alone is not "ligatures off" on any platform: it never asks
/// for `liga`/`clig`, which every shaper defaults on. Shaping "office
/// waffle fluffier" in Calibri through the real DirectWrite shaper gives 15
/// glyphs for 22 characters with `calt: 0`, and 22 once all three are named
/// zero.
#[test]
fn build_font_emits_the_pinned_feature_set_for_each_ligature_setting() {
fn tags(font: &Font) -> Vec<(&str, u32)> {
font.features
.tag_value_list()
.iter()
.any(|(tag, value)| tag == "liga" && *value == 1)
.map(|(tag, value)| (tag.as_str(), *value))
.collect()
}
// Default config, and the settings toggle switched off: both leave
// `font_features` unset, so the terminal names its own.
let font = build_font(&gpui::font("Test"), false, false);
assert_eq!(
tags(&font),
vec![("calt", 0), ("liga", 0), ("clig", 0)],
"an unconfigured face must name every ligature feature off",
);
assert_eq!(font.features.is_calt_enabled(), Some(false));
// Every face the grid paints with gets the same set, not just the plain one.
for (bold, italic) in [(true, false), (false, true), (true, true)] {
assert_eq!(
tags(&build_font(&gpui::font("Test"), bold, italic)),
vec![("calt", 0), ("liga", 0), ("clig", 0)],
"bold={bold} italic={italic}",
);
}
// Explicitly on: what Settings → Appearance → Font ligatures writes.
let mut configured = gpui::font("Test");
configured.features = crate::core::config::gpui_font_features(
&serde_json::from_str(r#"{"calt":true,"liga":1}"#).unwrap(),
);
let font = build_font(&configured, false, false);
assert_eq!(
tags(&font),
vec![("calt", 1), ("liga", 1)],
"an explicit request for ligatures must survive untouched",
);
assert_eq!(font.features.is_calt_enabled(), Some(true));
// Explicitly off in `config.json`: also passed through unchanged.
let mut configured = gpui::font("Test");
configured.features = crate::core::config::gpui_font_features(
&serde_json::from_str(r#"{"calt":0,"liga":0,"clig":0}"#).unwrap(),
);
assert_eq!(
tags(&build_font(&configured, false, false)),
vec![("calt", 0), ("liga", 0), ("clig", 0)],
);
}
+115 -22
View File
@@ -1395,29 +1395,8 @@ impl Tty7App {
let weak_app = cx.weak_entity();
window.on_window_should_close(cx, move |_window, cx| {
let last_window = crate::ui::windows::WindowRegistry::count(cx) <= 1;
if let Some(app) = weak_app.upgrade() {
app.update(cx, |app, cx| app.detach_workspace(cx));
}
if last_window {
// With a tray icon, closing the last window retires to the
// tray: the daemon stays reachable (show / quit-and-stop)
// instead of being orphaned behind a dead icon. Without one
// the app quits — the only way it stays visible at all.
//
// The icon has to actually be up, not merely asked for: the
// backend can fail for the whole run (a Linux session with no
// StatusNotifier host), and retiring into an icon that never
// appeared leaves a process with no window and no tray — no
// way back in, and the daemon still held.
let retire_to_tray =
cx.global::<Config>().show_tray_icon && crate::ui::tray::icon_is_up();
if !retire_to_tray {
cx.spawn(async move |cx| {
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
app.update(cx, |app, cx| app.prepare_window_close(cx));
}
true
});
@@ -1468,6 +1447,36 @@ impl Tty7App {
crate::ui::windows::refresh_menu(cx);
}
fn prepare_window_close(&self, cx: &mut App) {
let last_window = crate::ui::windows::WindowRegistry::count(cx) <= 1;
self.detach_workspace(cx);
if last_window {
// With a tray icon, closing the last window retires to the
// tray: the daemon stays reachable (show / quit-and-stop)
// instead of being orphaned behind a dead icon. Without one
// the app quits — the only way it stays visible at all.
//
// The icon has to actually be up, not merely asked for: the
// backend can fail for the whole run (a Linux session with no
// StatusNotifier host), and retiring into an icon that never
// appeared leaves a process with no window and no tray — no
// way back in, and the daemon still held.
let retire_to_tray =
cx.global::<Config>().show_tray_icon && crate::ui::tray::icon_is_up();
if !retire_to_tray {
cx.spawn(async move |cx| {
let _ = cx.update(|cx| cx.quit());
})
.detach();
}
}
}
fn close_window(&self, window: &mut Window, cx: &mut App) {
self.prepare_window_close(cx);
window.remove_window();
}
pub(crate) fn teardown_workspace_forwards(&self, cx: &gpui::App) {
let Some(route) = self
.tabs
@@ -4895,6 +4904,7 @@ impl Tty7App {
OpenWorkspacePicker => self.open_switcher(window, cx),
StopWorkspace => self.stop_workspace(self.workspace, window, cx),
DeleteWorkspace => self.delete_workspace(self.workspace, window, cx),
CloseWindow => self.close_window(window, cx),
SplitRight => self.split(Axis::Horizontal, window, cx),
SplitDown => self.split(Axis::Vertical, window, cx),
ClosePane => self.close_pane(window, cx),
@@ -7391,6 +7401,9 @@ impl Render for Tty7App {
.on_action(cx.listener(|this, _: &NewWorkspace, window, cx| {
this.open_workspace_form(window, cx);
}))
.on_action(
cx.listener(|this, _: &CloseWindow, window, cx| this.close_window(window, cx)),
)
.on_action(cx.listener(|this, _: &CloseActiveTab, window, cx| {
if !this.editor_close_active_if_focused(window, cx) {
this.close_pane(window, cx)
@@ -10244,3 +10257,83 @@ mod managed_forward_gpui_tests {
});
}
}
#[cfg(test)]
mod close_window_action_tests {
use crate::core::actions::CloseWindow;
use crate::core::config::Config;
use crate::core::session::Session;
use crate::ui::app::Tty7App;
use crate::ui::windows::WindowRegistry;
use gpui::{AppContext as _, TestAppContext, VisualTestContext};
/// `CloseWindow` has to close the window, and close it the way the red
/// button does.
///
/// The action is otherwise all table entries — the `actions!` row, the
/// keymap slot, the palette command, the Keybindings label — and every one
/// of those can be in place while the action reaches nothing at all. So
/// this drives the real dispatch path and then asks two separate
/// questions: the window is gone from gpui, *and* it left the
/// `WindowRegistry` on the way out. The second is what makes it the same
/// close as the native one — `detach_workspace` is where the session is
/// saved and the workspace is retired, and a `remove_window` that skipped
/// it would still pass the first assertion while quietly dropping a
/// window's tabs on the floor.
#[gpui::test]
fn dispatching_close_window_takes_the_window_down_with_its_registration(
cx: &mut TestAppContext,
) {
crate::core::config::pin_test_config_dir();
cx.executor().allow_parking();
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(Config::default());
crate::ui::keymap::init(cx);
WindowRegistry::init(cx);
});
let window = cx.add_window(|window, cx| {
let app =
cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx));
gpui_component::Root::new(app, window, cx)
});
let app = window
.update(cx, |root, _, _| {
root.view()
.clone()
.downcast::<Tty7App>()
.ok()
.expect("window root wraps a Tty7App")
})
.unwrap();
// Registered the way an opened window registers itself; the registry
// is where the close has to show up, so an unregistered window would
// make the assertion below pass for the wrong reason.
let handle = window.into();
let weak = app.downgrade();
app.update(cx, |app, cx| {
WindowRegistry::register(cx, app.workspace, handle, weak);
});
let mut vcx = VisualTestContext::from_window(handle, cx);
vcx.background_executor.run_until_parked();
assert_eq!(
vcx.update(|_, cx| WindowRegistry::count(cx)),
1,
"the harness starts with exactly the one window"
);
vcx.dispatch_action(CloseWindow);
drop(vcx);
assert!(
cx.update(|cx| cx.windows().is_empty()),
"CloseWindow has to reach `remove_window`; the window is still open"
);
assert!(
cx.update(|cx| WindowRegistry::open_windows(cx).is_empty()),
"the close has to run the same cleanup the red button runs, \
which is what takes the window out of the registry"
);
}
}
+484
View File
@@ -0,0 +1,484 @@
//! Flattening a diff snapshot into the one-dimensional list of rows the
//! overlay scrolls.
//!
//! The overlay used to build its whole patch as a nested element tree —
//! a card per file, a hunk header and one element per line inside it, every
//! one of them constructed on every frame. A `20_000` line budget is around
//! `120_000` elements to lay out, and gpui notifies the view on each scroll
//! wheel event, so a diff of any size rebuilt the entire tree tens of times a
//! second.
//!
//! [`gpui::list`] only builds the rows it can see, but it is one-dimensional:
//! it takes an index, not a tree. So the tree is flattened here, once per
//! change to what is on screen.
use std::collections::HashMap;
use crate::core::config::DiffViewMode;
use crate::terminal::git_diff::{
AUTO_COLLAPSE_LINES, DiffSnapshot, FileDiff, FileStatus, MAX_RENDERED_FILES, Truncation,
};
use crate::ui::diff_rows::{SplitRow, UnifiedRow, split_hunk, unified_rows};
/// Everything the file header row draws, lifted out of its [`FileDiff`].
///
/// A copy rather than a borrow: the row list outlives the frame that built it,
/// and these are a handful of small fields against a file's whole patch.
#[derive(PartialEq, Eq)]
pub(crate) struct FileHead {
/// Position in `snap.files`, for the element id and nothing else.
pub(crate) index: usize,
pub(crate) path: String,
/// `old → new` for a rename, the path itself otherwise.
pub(crate) shown_path: String,
pub(crate) status: FileStatus,
pub(crate) added: u32,
pub(crate) removed: u32,
pub(crate) binary: bool,
pub(crate) expandable: bool,
pub(crate) expanded: bool,
}
#[derive(PartialEq, Eq)]
pub(crate) enum DiffRow {
/// The space that used to be the `gap_3` of a flex column.
Gap,
/// The banner above an auto-collapsed tree. Its text is derived from the
/// snapshot at render time, so nothing is carried here.
Oversized,
FileHeader(FileHead),
HunkHeader {
text: String,
/// The first hunk of a file follows its header and needs no rule
/// above it; every later one is separating itself from the lines of
/// the hunk before.
leads: bool,
},
Split(SplitRow),
Unified(UnifiedRow),
Truncated(Truncation),
MoreFiles {
rest: usize,
},
UntrackedHeader {
total: usize,
},
Untracked {
index: usize,
path: String,
},
MoreUntracked {
rest: usize,
},
}
/// The rows of the whole overlay, in the order they scroll past.
pub(crate) fn build_rows(
snap: &DiffSnapshot,
expanded: &HashMap<String, bool>,
focused: Option<usize>,
mode: DiffViewMode,
oversized: bool,
) -> Vec<DiffRow> {
let mut out: Vec<DiffRow> = Vec::new();
// Stands in for the gap between the groups this list used to be a flex
// column of: gpui's list stacks its items with nothing between them.
let gap = |out: &mut Vec<DiffRow>| {
if !out.is_empty() {
out.push(DiffRow::Gap);
}
};
if oversized {
out.push(DiffRow::Oversized);
}
let shown = snap.files.len().min(MAX_RENDERED_FILES);
for (idx, file) in snap.files.iter().enumerate() {
if focused.is_some_and(|f| f != idx) {
continue;
}
if focused.is_none() && idx >= shown {
break;
}
let is_expanded = if focused == Some(idx) {
expanded.get(&file.path).copied().unwrap_or(true)
} else {
file_expanded(file, expanded, oversized)
};
gap(&mut out);
out.extend(file_rows(idx, file, is_expanded, mode));
}
if focused.is_none() && snap.files.len() > shown {
gap(&mut out);
out.push(DiffRow::MoreFiles {
rest: snap.files.len() - shown,
});
}
if focused.is_none() && !snap.untracked.is_empty() {
gap(&mut out);
out.extend(untracked_rows(snap));
}
out
}
/// The single synthesized file an untracked file's preview shows.
///
/// `usize::MAX` keeps its element ids clear of the real list's.
pub(crate) fn preview_rows(file: &FileDiff, mode: DiffViewMode) -> Vec<DiffRow> {
file_rows(usize::MAX, file, true, mode)
}
fn file_rows(index: usize, file: &FileDiff, expanded: bool, mode: DiffViewMode) -> Vec<DiffRow> {
let expandable =
!file.binary && (!file.hunks.is_empty() || file.truncated == Some(Truncation::Budget));
let shown_path = match &file.old_path {
Some(old) => format!("{old}{}", file.path),
None => file.path.clone(),
};
let mut rows = vec![DiffRow::FileHeader(FileHead {
index,
path: file.path.clone(),
shown_path,
status: file.status,
added: file.added,
removed: file.removed,
binary: file.binary,
expandable,
expanded,
})];
if expanded && (!file.hunks.is_empty() || file.truncated.is_some()) {
for (h, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::HunkHeader {
text: hunk.header.clone(),
leads: h == 0,
});
match mode {
DiffViewMode::Split => {
rows.extend(split_hunk(&hunk.lines).into_iter().map(DiffRow::Split))
}
DiffViewMode::Unified => {
rows.extend(unified_rows(&hunk.lines).into_iter().map(DiffRow::Unified))
}
}
}
if let Some(reason) = file.truncated {
rows.push(DiffRow::Truncated(reason));
}
}
rows
}
fn untracked_rows(snap: &DiffSnapshot) -> Vec<DiffRow> {
let total = snap.untracked_count();
let shown = &snap.untracked[..snap.untracked.len().min(MAX_RENDERED_FILES)];
let mut rows = vec![DiffRow::UntrackedHeader { total }];
for (index, path) in shown.iter().enumerate() {
rows.push(DiffRow::Untracked {
index,
path: path.clone(),
});
}
if total > shown.len() {
rows.push(DiffRow::MoreUntracked {
rest: total - shown.len(),
});
}
rows
}
/// Whether a file's lines show on their own, absent an explicit choice.
pub(crate) fn file_expanded(
file: &FileDiff,
expanded: &HashMap<String, bool>,
collapse_all: bool,
) -> bool {
if let Some(&want) = expanded.get(&file.path) {
return want;
}
!collapse_all && file.added + file.removed <= AUTO_COLLAPSE_LINES
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::git_diff::{DiffLine, Hunk, LineKind};
fn line(kind: LineKind, old: Option<u32>, new: Option<u32>) -> DiffLine {
DiffLine {
kind,
old_no: old,
new_no: new,
text: "x".to_string(),
}
}
/// One removal answered by one addition, wrapped in a context line either
/// side — four lines unified, three rows split.
fn hunk() -> Hunk {
Hunk {
header: "@@ -1,3 +1,3 @@".to_string(),
lines: vec![
line(LineKind::Context, Some(1), Some(1)),
line(LineKind::Removed, Some(2), None),
line(LineKind::Added, None, Some(2)),
line(LineKind::Context, Some(3), Some(3)),
],
}
}
fn file(path: &str, hunks: usize) -> FileDiff {
FileDiff {
path: path.to_string(),
old_path: None,
status: FileStatus::Modified,
added: 1,
removed: 1,
binary: false,
truncated: None,
hunks: std::iter::repeat_n(hunk(), hunks).collect(),
}
}
fn snapshot(files: Vec<FileDiff>) -> DiffSnapshot {
DiffSnapshot {
files,
..Default::default()
}
}
fn shape(rows: &[DiffRow]) -> Vec<&'static str> {
rows.iter()
.map(|row| match row {
DiffRow::Gap => "gap",
DiffRow::Oversized => "oversized",
DiffRow::FileHeader(_) => "file",
DiffRow::HunkHeader { .. } => "hunk",
DiffRow::Split(_) => "split",
DiffRow::Unified(_) => "unified",
DiffRow::Truncated(_) => "truncated",
DiffRow::MoreFiles { .. } => "more-files",
DiffRow::UntrackedHeader { .. } => "untracked-header",
DiffRow::Untracked { .. } => "untracked",
DiffRow::MoreUntracked { .. } => "more-untracked",
})
.collect()
}
fn open(paths: [&str; 1]) -> HashMap<String, bool> {
paths.into_iter().map(|p| (p.to_string(), true)).collect()
}
#[test]
fn an_open_file_flattens_to_a_header_a_hunk_header_and_a_row_per_line() {
let snap = snapshot(vec![file("a.rs", 1)]);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
assert_eq!(
shape(&rows),
["file", "hunk", "unified", "unified", "unified", "unified"]
);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Split, false);
assert_eq!(
shape(&rows),
["file", "hunk", "split", "split", "split"],
"the split view pairs the removal with the addition that replaced it"
);
}
#[test]
fn only_the_first_hunk_of_a_file_follows_its_header() {
let rows = build_rows(
&snapshot(vec![file("a.rs", 2)]),
&HashMap::new(),
None,
DiffViewMode::Unified,
false,
);
let leads: Vec<bool> = rows
.iter()
.filter_map(|r| match r {
DiffRow::HunkHeader { leads, .. } => Some(*leads),
_ => None,
})
.collect();
assert_eq!(
leads,
[true, false],
"the second hunk draws the rule that separates it from the first"
);
}
#[test]
fn a_collapsed_file_is_a_single_row() {
let snap = snapshot(vec![file("a.rs", 1)]);
let shut: HashMap<String, bool> = [("a.rs".to_string(), false)].into_iter().collect();
let rows = build_rows(&snap, &shut, None, DiffViewMode::Unified, false);
assert_eq!(shape(&rows), ["file"]);
}
#[test]
fn a_truncation_note_lands_under_the_lines_it_is_about() {
let mut f = file("a.rs", 1);
f.truncated = Some(Truncation::Budget);
let rows = build_rows(
&snapshot(vec![f]),
&HashMap::new(),
None,
DiffViewMode::Unified,
false,
);
assert_eq!(*shape(&rows).last().unwrap(), "truncated");
}
#[test]
fn files_are_separated_by_a_gap_and_the_list_never_opens_with_one() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let shut: HashMap<String, bool> =
snap.files.iter().map(|f| (f.path.clone(), false)).collect();
let rows = build_rows(&snap, &shut, None, DiffViewMode::Unified, false);
assert_eq!(shape(&rows), ["file", "gap", "file"]);
}
#[test]
fn the_oversized_banner_leads_and_collapses_what_was_not_asked_for() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let rows = build_rows(&snap, &open(["b.rs"]), None, DiffViewMode::Unified, true);
assert_eq!(
shape(&rows),
[
"oversized",
"gap",
"file",
"gap",
"file",
"hunk",
"unified",
"unified",
"unified",
"unified"
],
"the file the reader opened stays open; the other one does not"
);
}
#[test]
fn a_focused_file_is_the_only_one_flattened() {
let snap = snapshot(vec![file("a.rs", 1), file("b.rs", 1)]);
let rows = build_rows(
&snap,
&HashMap::new(),
Some(1),
DiffViewMode::Unified,
false,
);
assert_eq!(
shape(&rows),
["file", "hunk", "unified", "unified", "unified", "unified"]
);
let DiffRow::FileHeader(head) = &rows[0] else {
panic!("the focused file leads");
};
assert_eq!(head.path, "b.rs");
assert_eq!(head.index, 1, "the element id still points into `files`");
}
#[test]
fn the_file_list_is_capped_and_the_tail_gets_one_line() {
let snap = snapshot(
(0..MAX_RENDERED_FILES + 25)
.map(|i| {
let mut f = file(&format!("f{i}.rs"), 1);
f.hunks.clear();
f
})
.collect(),
);
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
let files = rows
.iter()
.filter(|r| matches!(r, DiffRow::FileHeader(_)))
.count();
assert_eq!(files, MAX_RENDERED_FILES);
assert!(matches!(rows.last(), Some(DiffRow::MoreFiles { rest: 25 })));
}
#[test]
fn the_untracked_list_is_capped_but_its_count_stays_true() {
let mut snap = snapshot(Vec::new());
snap.untracked = (0..MAX_RENDERED_FILES + 5)
.map(|i| format!("p{i}"))
.collect();
snap.untracked_total = 9_000;
let rows = build_rows(&snap, &HashMap::new(), None, DiffViewMode::Unified, false);
assert!(matches!(
rows.first(),
Some(DiffRow::UntrackedHeader { total: 9_000 })
));
let shown = rows
.iter()
.filter(|r| matches!(r, DiffRow::Untracked { .. }))
.count();
assert_eq!(shown, MAX_RENDERED_FILES);
assert!(matches!(
rows.last(),
Some(DiffRow::MoreUntracked { rest }) if *rest == 9_000 - MAX_RENDERED_FILES
));
}
#[test]
fn a_preview_is_one_open_file() {
let rows = preview_rows(&file("new.md", 1), DiffViewMode::Unified);
assert_eq!(
shape(&rows),
["file", "hunk", "unified", "unified", "unified", "unified"]
);
let DiffRow::FileHeader(head) = &rows[0] else {
panic!("a file opens with its header");
};
assert_eq!(
head.index,
usize::MAX,
"its element ids stay clear of the real list's"
);
assert!(head.expanded);
}
#[test]
fn a_rename_shows_both_names_and_a_binary_file_cannot_be_opened() {
let mut renamed = file("new.rs", 1);
renamed.old_path = Some("old.rs".to_string());
let rows = preview_rows(&renamed, DiffViewMode::Unified);
let DiffRow::FileHeader(head) = &rows[0] else {
unreachable!()
};
assert_eq!(head.shown_path, "old.rs → new.rs");
assert_eq!(head.path, "new.rs", "the key is still the file itself");
let mut binary = file("logo.png", 0);
binary.binary = true;
let rows = preview_rows(&binary, DiffViewMode::Unified);
let DiffRow::FileHeader(head) = &rows[0] else {
unreachable!()
};
assert!(!head.expandable);
}
/// The threshold that decides whether a file opens on its own, kept where
/// the flattening that reads it lives.
#[test]
fn a_file_over_the_line_count_opens_only_when_asked() {
let mut big = file("big.rs", 1);
big.added = AUTO_COLLAPSE_LINES + 1;
let none = HashMap::new();
assert!(!file_expanded(&big, &none, false));
assert!(file_expanded(&big, &open(["big.rs"]), true));
assert!(file_expanded(&file("small.rs", 1), &none, false));
}
}
+900 -580
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -23,12 +23,14 @@ pub(crate) enum Side {
New,
}
#[derive(PartialEq, Eq)]
pub(crate) struct SplitCell {
pub(crate) no: Option<u32>,
pub(crate) text: String,
pub(crate) changed: bool,
}
#[derive(PartialEq, Eq)]
pub(crate) struct SplitRow {
pub(crate) left: Option<SplitCell>,
pub(crate) right: Option<SplitCell>,
@@ -85,6 +87,7 @@ pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec<SplitRow> {
rows
}
#[derive(PartialEq, Eq)]
pub(crate) struct UnifiedRow {
pub(crate) old: Option<u32>,
pub(crate) new: Option<u32>,
+3 -1
View File
@@ -1430,6 +1430,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab",
L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread",
L10nKey::CmdClosePaneTab => "Close Pane / Tab",
L10nKey::CmdCloseWindow => "Close Window",
L10nKey::CmdCloseWindowSubtitle => "shells keep running",
L10nKey::CmdCloseOtherTabs => "Close Other Tabs",
L10nKey::CmdCloseTabsToTheRight => "Close Tabs to the Right",
L10nKey::CmdReopenClosedTab => "Reopen Closed Tab",
@@ -1506,7 +1508,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdRestartServer => "Restart Server…",
L10nKey::CmdRestartServerSubtitle => "ends every running shell; layout is kept",
L10nKey::CmdQuitTty7 => "Quit tty7",
L10nKey::CmdQuitTty7Subtitle => "shells keep running",
L10nKey::CmdQuitTty7Subtitle => "stops the server; every running shell ends",
L10nKey::CmdQuickConnect => "Connect to \"{target}\"",
L10nKey::CmdQuickConnectSaveProfile => "Save \"{target}\" as profile…",
L10nKey::CmdRecent => "Recent",
+3 -1
View File
@@ -1487,6 +1487,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク",
L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク",
L10nKey::CmdClosePaneTab => "ペイン / タブを閉じる",
L10nKey::CmdCloseWindow => "ウィンドウを閉じる",
L10nKey::CmdCloseWindowSubtitle => "シェルは実行を継続",
L10nKey::CmdCloseOtherTabs => "他のタブを閉じる",
L10nKey::CmdCloseTabsToTheRight => "右側のタブを閉じる",
L10nKey::CmdReopenClosedTab => "閉じたタブをもう一度開く",
@@ -1561,7 +1563,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdRestartServer => "サーバーを再起動…",
L10nKey::CmdRestartServerSubtitle => "実行中のすべてのシェルを終了し、レイアウトは保持",
L10nKey::CmdQuitTty7 => "tty7 を終了",
L10nKey::CmdQuitTty7Subtitle => "シェルは実行を継続",
L10nKey::CmdQuitTty7Subtitle => "サーバーを停止し、実行中のすべてのシェルを終了",
L10nKey::CmdQuickConnect => "「{target}」に接続",
L10nKey::CmdQuickConnectSaveProfile => "「{target}」をプロファイルとして保存…",
L10nKey::CmdRecent => "最近",
+2
View File
@@ -1150,6 +1150,8 @@ l10n_keys! {
CmdForkSessionSubtitle,
CmdMarkTabAsUnread,
CmdClosePaneTab,
CmdCloseWindow,
CmdCloseWindowSubtitle,
CmdCloseOtherTabs,
CmdCloseTabsToTheRight,
CmdReopenClosedTab,
+3 -1
View File
@@ -1348,6 +1348,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页",
L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读",
L10nKey::CmdClosePaneTab => "关闭窗格/标签页",
L10nKey::CmdCloseWindow => "关闭窗口",
L10nKey::CmdCloseWindowSubtitle => "shell 保持运行",
L10nKey::CmdCloseOtherTabs => "关闭其他标签页",
L10nKey::CmdCloseTabsToTheRight => "关闭右侧标签页",
L10nKey::CmdReopenClosedTab => "重新打开已关闭标签页",
@@ -1422,7 +1424,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdRestartServer => "重启 server…",
L10nKey::CmdRestartServerSubtitle => "结束所有运行中的 shell;保留布局",
L10nKey::CmdQuitTty7 => "退出 tty7",
L10nKey::CmdQuitTty7Subtitle => "shell 保持运行",
L10nKey::CmdQuitTty7Subtitle => "停止服务;结束所有运行中的 shell",
L10nKey::CmdQuickConnect => "连接到“{target}”",
L10nKey::CmdQuickConnectSaveProfile => "将“{target}”保存为主机配置…",
L10nKey::CmdRecent => "最近使用",
+7
View File
@@ -265,6 +265,7 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
vec![
("NewTab", per_platform("secondary-t", "secondary-shift-t")),
("NewWorkspace", "secondary-shift-n"),
("CloseWindow", ""),
(
"CloseActiveTab",
per_platform("secondary-w", "secondary-shift-w"),
@@ -733,6 +734,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::Application,
t(L10nKey::AppMenuCommandPalette).to_string(),
),
"CloseWindow" => (
CommandGroup::Application,
t(L10nKey::CmdCloseWindow).to_string(),
),
"OpenSettings" => (
CommandGroup::Application,
t(L10nKey::CmdSettings).to_string(),
@@ -1036,6 +1041,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"DeleteWorkspace" => KeyBinding::new(keystroke, DeleteWorkspace, None),
"RenameWorkspace" => KeyBinding::new(keystroke, RenameWorkspace, None),
"ToggleSwitcher" => KeyBinding::new(keystroke, ToggleSwitcher, None),
"CloseWindow" => KeyBinding::new(keystroke, CloseWindow, None),
"CloseActiveTab" => KeyBinding::new(keystroke, CloseActiveTab, None),
"RenameTab" => KeyBinding::new(keystroke, RenameTab, None),
"NewWorktreeTab" => KeyBinding::new(keystroke, NewWorktreeTab, None),
@@ -1217,6 +1223,7 @@ mod tests {
// palette and the docs all say Zoom Pane.
assert_eq!(action_entry("ToggleMaximizePane").1, "Zoom Pane");
assert_eq!(action_entry("CloseActiveTab").1, "Close Pane / Tab");
assert_eq!(action_entry("CloseWindow").1, "Close Window");
assert_eq!(action_entry("ClearScrollback").1, "Clear Scrollback");
assert_eq!(action_entry("TogglePalette").1, "Command Palette…");
assert_eq!(action_entry("ToggleSwitcher").1, "Switch Workspace…");
+1
View File
@@ -1,6 +1,7 @@
pub mod app;
pub mod assets;
pub mod code_editor;
pub mod diff_list;
pub mod diff_overlay;
pub mod diff_rows;
pub mod document_column;
+8
View File
@@ -22,6 +22,7 @@ pub enum CommandKind {
RenameWorkspace,
StopWorkspace,
DeleteWorkspace,
CloseWindow,
SplitRight,
SplitDown,
ClosePane,
@@ -124,6 +125,7 @@ impl CommandKind {
RenameWorkspace => "rename-workspace",
StopWorkspace => "stop-workspace",
DeleteWorkspace => "delete-workspace",
CloseWindow => "close-window",
SplitRight => "split-right",
SplitDown => "split-down",
ClosePane => "close-pane",
@@ -230,6 +232,7 @@ impl CommandKind {
RenameWorkspace => "RenameWorkspace",
StopWorkspace => "StopWorkspace",
DeleteWorkspace => "DeleteWorkspace",
CloseWindow => "CloseWindow",
SplitRight => "SplitRight",
SplitDown => "SplitDown",
ClosePane => "CloseActiveTab",
@@ -574,6 +577,11 @@ impl Command {
Command::localized(L10nKey::CmdReportIssue, ReportIssue),
Command::localized(L10nKey::CmdRestartServer, RestartDaemon)
.with_subtitle(t(L10nKey::CmdRestartServerSubtitle)),
// Beside Quit, because the pair is the whole point of the action:
// both end the window you are looking at, and only one of them
// takes your shells with it. Read together the subtitles say which.
Command::localized(L10nKey::CmdCloseWindow, CloseWindow)
.with_subtitle(t(L10nKey::CmdCloseWindowSubtitle)),
Command::localized(L10nKey::CmdQuitTty7, Quit)
.with_subtitle(t(L10nKey::CmdQuitTty7Subtitle)),
];
+38 -1
View File
@@ -960,6 +960,7 @@ mod tests {
fn native_spec(user: &str, host: &str, port: u16) -> NativeSshSpec {
let mut profile = crate::core::ssh_profile::SshProfile::new(host.to_string());
profile.host = host.to_string();
profile.user = user.to_string();
profile.port = port;
crate::ui::ssh_connect::build_native_ssh_spec(
@@ -970,6 +971,36 @@ mod tests {
)
}
/// `SshProfile::new` takes a *name*, and the address lives in a separate
/// `host` field; every production caller assigns both. `native_spec` used
/// to assign only the name, so the spec it handed back addressed nobody
/// and `ConnectionKey::from_spec` spelled it `me@:22` — one key for every
/// machine in this module that talks to port 22.
///
/// `ORIGINS` is keyed by exactly that string, so the two tests below that
/// note an origin and read it back were writing to and reading from the
/// same slot. Whichever noted last won, and the other was handed the wrong
/// machine: 17 failures in 20 runs of this module alone, 6 in 20 of the
/// whole binary, and none when either test ran by itself.
#[test]
fn two_machines_do_not_share_one_route_origin_key() {
use crate::daemon::router::RouteTarget;
let build = RouteTarget::Ssh(Box::new(native_spec("me", "build-box", 22)));
let twin = RouteTarget::Ssh(Box::new(native_spec("me", "twin-box", 22)));
assert_eq!(
build.origin_key(),
"me@build-box:22",
"a route origin key names the machine it dials"
);
assert_ne!(
build.origin_key(),
twin.origin_key(),
"two machines sharing one key make `note_origin` overwrite the other's"
);
}
#[test]
fn a_routed_auth_prompt_carries_the_machine_that_raised_it() {
let _turn = claim_mailbox();
@@ -978,6 +1009,7 @@ mod tests {
let route =
crate::daemon::router::RouteTarget::Ssh(Box::new(native_spec("me", "build-box", 22)));
note_origin(&route, &target);
let origin_key = route.origin_key();
let handle = std::thread::spawn(move || {
use crate::daemon::router::RouteAuthResponder as _;
@@ -1005,7 +1037,12 @@ mod tests {
);
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(pending.host, target.host_id());
assert_eq!(
pending.host,
target.host_id(),
"the prompt names the machine noted under {:?}",
origin_key
);
pending.answer(AuthResponse::Secret("hunter2".into()));
assert_eq!(
handle.join().unwrap(),
-34
View File
@@ -40,24 +40,6 @@ pub(crate) fn segment_corners(
}
}
pub(crate) fn stack_corners(
i: usize,
count: usize,
outer: Pixels,
border: Pixels,
) -> Corners<Pixels> {
let r = inner_radius(outer, border);
let zero = px(0.);
let first = i < count && i == 0;
let last = i < count && i + 1 == count;
Corners {
top_left: if first { r } else { zero },
top_right: if first { r } else { zero },
bottom_left: if last { r } else { zero },
bottom_right: if last { r } else { zero },
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -109,20 +91,4 @@ mod tests {
Corners::all(px(0.))
);
}
#[test]
fn a_stack_caps_its_first_and_last_band() {
let r = inner_radius(CARD_RADIUS, HAIRLINE);
let zero = px(0.);
let top = stack_corners(0, 2, CARD_RADIUS, HAIRLINE);
assert_eq!((top.top_left, top.top_right), (r, r));
assert_eq!((top.bottom_left, top.bottom_right), (zero, zero));
let bottom = stack_corners(1, 2, CARD_RADIUS, HAIRLINE);
assert_eq!((bottom.bottom_left, bottom.bottom_right), (r, r));
assert_eq!((bottom.top_left, bottom.top_right), (zero, zero));
assert_eq!(stack_corners(0, 1, CARD_RADIUS, HAIRLINE), Corners::all(r));
}
}
+5 -5
View File
@@ -1,5 +1,5 @@
use gpui::{AnyElement, ElementId, Pixels, ScrollHandle, div, prelude::*, px};
use gpui_component::scroll::Scrollbar;
use gpui_component::scroll::{Scrollbar, ScrollbarHandle};
use gpui_component::v_flex;
/// Overlays the shared vertical scrollbar on a scroll area.
@@ -9,10 +9,10 @@ use gpui_component::v_flex;
/// dropping it straight into whatever is around it. Without that the wrapper
/// sizes to its content, the `size_full` scroll area inside grows with it, and
/// the pane stops scrolling because nothing overflows any more.
pub(crate) fn with_vertical_scrollbar(
pub(crate) fn with_vertical_scrollbar<H: ScrollbarHandle + Clone>(
id: impl Into<ElementId>,
scroll_area: impl IntoElement,
handle: &ScrollHandle,
handle: &H,
) -> AnyElement {
with_inset_vertical_scrollbar(id, scroll_area, handle, px(0.))
}
@@ -23,10 +23,10 @@ pub(crate) fn with_vertical_scrollbar(
/// the border is already the edge. A scroll area that *is* the window has no
/// such edge, and a bar that runs to the last pixel lands on the rounded
/// corner and reads as if it had been clipped.
pub(crate) fn with_inset_vertical_scrollbar(
pub(crate) fn with_inset_vertical_scrollbar<H: ScrollbarHandle + Clone>(
id: impl Into<ElementId>,
scroll_area: impl IntoElement,
handle: &ScrollHandle,
handle: &H,
inset_y: Pixels,
) -> AnyElement {
v_flex()