Merge branch 'main' into worktree-code-panel
# Conflicts: # src/core/config.rs # src/ui/mod.rs # src/ui/tab_sidebar.rs # src/ui/tab_strip.rs
@@ -62,8 +62,15 @@ jobs:
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
# `--locked` on the build so a Cargo.lock that disagrees with Cargo.toml
|
||||
# fails here instead of being silently rewritten. Without it the drift is
|
||||
# invisible to CI and lands on contributors instead: every local `cargo`
|
||||
# run rewrites the lock, leaving a permanently dirty working tree that has
|
||||
# to be re-discarded before every commit. The release workflow locks its
|
||||
# build too; only nightly stays unlocked, because it stamps Cargo.toml's
|
||||
# version and relies on cargo refreshing the lock's own root entry.
|
||||
- name: Build
|
||||
run: cargo build --target ${{ matrix.target }}
|
||||
run: cargo build --locked --target ${{ matrix.target }}
|
||||
|
||||
- name: Test
|
||||
run: cargo test --target ${{ matrix.target }}
|
||||
run: cargo test --locked --target ${{ matrix.target }}
|
||||
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# Same glob list as release.yml's Release step: the bundle scripts leave
|
||||
# intermediates in dist/ (tty7.app, entitlements.plist, the Windows
|
||||
# staging dir) that must not reach the release assets.
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: nightly-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
@@ -62,9 +62,13 @@ jobs:
|
||||
with:
|
||||
workspaces: tty7
|
||||
|
||||
# `--locked` because a release must ship the dependency set the tag
|
||||
# recorded, not whatever cargo would re-resolve at build time. Safe here
|
||||
# (unlike nightly) precisely because nothing rewrites Cargo.toml: this is
|
||||
# a plain checkout of the tagged commit.
|
||||
- name: Build
|
||||
working-directory: tty7
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
|
||||
# ---- Packaging: one step per OS ----------------------------------------
|
||||
# macOS gets a signed + notarized drag-to-Applications DMG. Windows gets
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
.brooks-lint-history.json
|
||||
openwiki/.last-update.json
|
||||
|
||||
# dev-build throwaway config dir (cargo dev)
|
||||
.tty7-dev/
|
||||
# dev-build throwaway config dirs (`cargo dev`, plus per-branch ones passed as
|
||||
# --config-dir so a test run can't disturb a real profile)
|
||||
.tty7-dev*/
|
||||
|
||||
# Claude Code agent worktrees (transient)
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -7,6 +7,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.7.2] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- **WSL shell integration** — prompt marks, cwd tracking, and agent
|
||||
detection now work inside WSL distros. The integration tags the shell
|
||||
it actually launches, never blocks the spawn path while probing the
|
||||
distro's shell, and declines untranslatable cwds rather than
|
||||
misreporting them. (#134)
|
||||
- **Git Bash shell integration on Windows** — Git Bash panes get the
|
||||
full integration, reporting proper Windows paths for cwd-dependent
|
||||
features. (#130)
|
||||
- **Smart double-click selection** — double-click selects the word under
|
||||
the cursor with language-aware boundaries, including CJK segmentation
|
||||
(the OS tokenizer on macOS, jieba elsewhere); angle-bracket pairs must
|
||||
hug their contents and contraction apostrophes stay out of quote
|
||||
pairing. (#128)
|
||||
- **Configurable file-link command** — file links can open with a custom
|
||||
command instead of the default editor. Contributed by @ayamir. (#143)
|
||||
- **Agent resume keeps launch flags** — resuming an agent session
|
||||
carries the flags the agent was originally launched with onto the
|
||||
resume command. (#144)
|
||||
- **Sidebar git line follows the agent's cwd** — the branch/status line
|
||||
under an agent tab tracks the directory the agent is actually working
|
||||
in, not just the pane's shell. (#127)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tab close button floats over the label** — the close affordance no
|
||||
longer reserves a slot in every tab, so labels get the full width
|
||||
until hovered. (#126)
|
||||
- **Dependency updates** — resvg 0.47, sha2 0.11, a cargo minor-patch
|
||||
group bump, and newer CI artifact actions. (#137, #138, #139, #140,
|
||||
#141)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tab completion falls through to the shell** — a Tab tty7 has no
|
||||
candidates for now hands the line to the shell's own completion
|
||||
instead of being swallowed (remote panes included); `cd`/`pushd` stop
|
||||
offering files; and the whole feature can be turned off with
|
||||
`tab_completion` in config or Settings → Terminal → Keyboard. (#146)
|
||||
- **Remote panes never leak local filesystem operations** — a remote
|
||||
pane's cwd is no longer used for local path lookups, and the agent-cwd
|
||||
bypass validates inherited directories. (#133)
|
||||
- **macOS text input goes through the IME** — synthesized keystrokes
|
||||
keep their text (remote/automated input no longer degrades), and Kitty
|
||||
full mode stays off the IME path. (#132)
|
||||
- **No more console flashes on Windows git probes** — git status probes
|
||||
and ssh ProxyCommand children no longer flash console windows. (#129)
|
||||
- **Windows titlebar menu spacing** — the titlebar "..." aligns with the
|
||||
window-control rhythm. (#131)
|
||||
- **Codex avatar and macOS tray sizing** — the Codex tab avatar uses its
|
||||
black brand field, and the tray icon renders at 22pt instead of the
|
||||
hardcoded 18pt. (#142)
|
||||
- **Agents settings layout** — rows stay aligned and notes terse. (#125)
|
||||
|
||||
## [26.7.1] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Follow the OS appearance** — a "Sync with system" mode with separate
|
||||
light and dark theme slots: the theme flips live when the OS switches
|
||||
appearance, native chrome follows along, and picking a theme while
|
||||
syncing writes the slot matching the current mode. Old configs are
|
||||
unchanged (sync defaults off). (#121)
|
||||
- **Mark as Unread on tabs** — the tab context menu can re-flag a finished
|
||||
agent result you've already looked at, re-arming the unread badge on the
|
||||
Done dot. Agent tabs only; disabled while the agent is still working.
|
||||
(#120)
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Duo" logo refresh** — a new brand mark (two offset session panes,
|
||||
mint behind ink, with a prompt chevron) replaces the orange window
|
||||
identity across every icon asset: app icon, logo, tray glyph, favicon,
|
||||
and social preview. Bare macOS binaries (`cargo run`) now show the icon
|
||||
in the Dock too. (#124)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Linked git worktrees group under their main repository** — the sidebar
|
||||
keys groups on the repository home instead of each worktree's own root,
|
||||
so a repo and its worktrees share one header while branch status stays
|
||||
per-worktree. (#118)
|
||||
- **macOS tray icon stays a template image in every state** — the
|
||||
attention state no longer swaps in a grey non-template glyph that was
|
||||
illegible on many menu-bar appearances; agent status lives in the
|
||||
tooltip and tray menu. (#122)
|
||||
- **No more console-window flashes from Windows agent hooks** — each hook
|
||||
emitter frees its throwaway console before it can paint (debug builds
|
||||
only; release builds were unaffected). (#119)
|
||||
|
||||
## [26.7.0] - 2026-07-16
|
||||
|
||||
First CalVer release: versions are now `YY.M.PATCH`, so the number says when
|
||||
|
||||
@@ -116,6 +116,12 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "adler32"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.6.1"
|
||||
@@ -246,9 +252,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "apple-native-keyring-store"
|
||||
@@ -1007,6 +1013,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -1152,6 +1164,15 @@ dependencies = [
|
||||
"shlex 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cedarwood"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
@@ -1814,6 +1835,12 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dary_heap"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
@@ -4065,6 +4092,39 @@ version = "1.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
|
||||
|
||||
[[package]]
|
||||
name = "include-flate"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce"
|
||||
dependencies = [
|
||||
"include-flate-codegen",
|
||||
"include-flate-compress",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate-codegen"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0"
|
||||
dependencies = [
|
||||
"include-flate-compress",
|
||||
"proc-macro-error3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate-compress"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd"
|
||||
dependencies = [
|
||||
"libflate",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -4251,6 +4311,30 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jieba-macros"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38fc0f3831de71556de69643b80a08a5c8cd260a23c6b8dbeb7cd923c779cac5"
|
||||
dependencies = [
|
||||
"phf_codegen 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jieba-rs"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"cedarwood",
|
||||
"include-flate",
|
||||
"jieba-macros",
|
||||
"phf 0.13.1",
|
||||
"regex",
|
||||
"rustc-hash 2.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -4349,9 +4433,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "4.1.4"
|
||||
version = "4.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe"
|
||||
checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a"
|
||||
dependencies = [
|
||||
"apple-native-keyring-store",
|
||||
"keyring-core",
|
||||
@@ -4506,6 +4590,30 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libflate"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df"
|
||||
dependencies = [
|
||||
"adler32",
|
||||
"crc32fast",
|
||||
"dary_heap",
|
||||
"libflate_lz77",
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libflate_lz77"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"no_std_io2",
|
||||
"rle-decode-fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.13"
|
||||
@@ -4788,7 +4896,7 @@ checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
"phf_codegen 0.11.3",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
@@ -5980,6 +6088,16 @@ dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
@@ -6400,6 +6518,16 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr3"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
@@ -6412,6 +6540,18 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error3"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -6885,9 +7025,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.4"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -6897,9 +7037,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -6986,6 +7126,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rle-decode-fast"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
|
||||
|
||||
[[package]]
|
||||
name = "ropey"
|
||||
version = "2.0.0-beta.1"
|
||||
@@ -7605,9 +7751,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -7625,22 +7771,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7665,9 +7811,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
@@ -8382,6 +8528,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -8713,9 +8870,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
version = "1.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -9382,10 +9539,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tty7"
|
||||
version = "26.7.0"
|
||||
version = "26.7.2"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"core-foundation 0.10.0",
|
||||
"getrandom 0.3.4",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
@@ -9393,6 +9551,7 @@ dependencies = [
|
||||
"gpui_platform",
|
||||
"ignore",
|
||||
"image",
|
||||
"jieba-rs",
|
||||
"keyring",
|
||||
"ksni",
|
||||
"libc",
|
||||
@@ -9404,8 +9563,10 @@ dependencies = [
|
||||
"notify-rust",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"plist",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest_client",
|
||||
"resvg",
|
||||
"ropey",
|
||||
@@ -9662,9 +9823,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.4"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -11473,6 +11634,34 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ztracing"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tty7"
|
||||
version = "26.7.0"
|
||||
version = "26.7.2"
|
||||
edition = "2024"
|
||||
description = "A terminal workbench: shells, persistent sessions, SSH, coding agents — GPU-rendered on Zed's gpui, pure Rust"
|
||||
repository = "https://github.com/l0ng-ai/tty7"
|
||||
@@ -21,6 +21,9 @@ gpui-component-assets = { workspace = true }
|
||||
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
# Smart double-click selection patterns (URL/email/path). Already in the tree
|
||||
# transitively, so pinning it here adds no new native code.
|
||||
regex = "1"
|
||||
smol.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde = { workspace = true }
|
||||
@@ -183,8 +186,25 @@ winresource = "0.1"
|
||||
# app appearance to match the active theme (see `ui::theme::sync_native_appearance`)
|
||||
# so the native traffic-light buttons render in the right light/dark style.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# CFStringTokenizer FFI for dictionary-based CJK word segmentation on
|
||||
# double-click (`terminal::smart_select`). Already in the tree transitively,
|
||||
# and it makes jieba unnecessary here — see the non-macos section below.
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSAppearance", "NSGraphics"] }
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSAppearance", "NSGraphics", "NSImage"] }
|
||||
# NSData feeds the runtime Dock icon for bare (non-bundled) binaries — see
|
||||
# `set_dock_icon_for_bare_binary` in main.rs.
|
||||
objc2-foundation = { version = "0.3", features = ["NSData"] }
|
||||
|
||||
# Dictionary-based Chinese word segmentation for double-click selection
|
||||
# (`terminal::smart_select`), as a *fallback* where the OS has no tokenizer of
|
||||
# its own. macOS is excluded on purpose: CFStringTokenizer segments Chinese
|
||||
# about as well (and Japanese/Korean far better) at zero cost, while jieba's
|
||||
# table costs ~55 MB resident once built and ~2 MB of binary for the embedded
|
||||
# dictionary. Keeping the dep off macOS means that dictionary isn't even
|
||||
# linked into the build that can't use it.
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
jieba-rs = "0.10"
|
||||
|
||||
# x11/wayland are the Linux windowing backends; only pull them on Linux. The
|
||||
# Windows backend (`gpui_windows`) and macOS backend are selected by gpui_platform
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<br />
|
||||
|
||||
[](https://github.com/l0ng-ai/tty7/actions/workflows/ci.yml)
|
||||
[](https://github.com/l0ng-ai/tty7/releases)
|
||||
[](https://github.com/l0ng-ai/tty7/releases)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/s3dethqz2V)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<br />
|
||||
|
||||
[](https://github.com/l0ng-ai/tty7/actions/workflows/ci.yml)
|
||||
[](https://github.com/l0ng-ai/tty7/releases)
|
||||
[](https://github.com/l0ng-ai/tty7/releases)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/s3dethqz2V)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,6 +1,11 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="8" y="8" width="104" height="104" rx="24" fill="#ff8a5c"/>
|
||||
<rect x="26" y="32" width="68" height="56" rx="13" fill="none" stroke="#0d1117" stroke-width="6"/>
|
||||
<line x1="26" y1="49" x2="94" y2="49" stroke="#0d1117" stroke-width="4" opacity="0.7"/>
|
||||
<rect x="60" y="60" width="12" height="20" rx="2.5" fill="#0d1117"/>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- "Duo" mark (design 2a): two offset session panes, prompt chevron in the
|
||||
front one. Tile inset + radius follow the macOS Big Sur icon grid
|
||||
(824pt tile @1024). -->
|
||||
<rect x="100" y="100" width="824" height="824" rx="185" fill="#ECEAE4"/>
|
||||
<g transform="translate(108.8 125.6) scale(8.4)">
|
||||
<rect x="28" y="16" width="58" height="46" rx="12" fill="#3FDD8C" opacity="0.8"/>
|
||||
<rect x="10" y="30" width="58" height="46" rx="12" fill="#17171A"/>
|
||||
<path d="M24 42 L35 53 L24 64" fill="none" stroke="#ECEAE4" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 427 B After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 24 KiB |
@@ -1,5 +1,6 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12" y="22" width="96" height="76" rx="15" fill="none" stroke="#3F3F46" stroke-width="6"/>
|
||||
<line x1="12" y1="44" x2="108" y2="44" stroke="#3F3F46" stroke-width="4" opacity="0.7"/>
|
||||
<rect x="52" y="58" width="15" height="26" rx="3" fill="#FF6B35"/>
|
||||
<svg width="120" height="120" viewBox="8 6 80 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- "Duo" mark on transparent — same geometry as app-icon.svg, no tile. -->
|
||||
<rect x="28" y="16" width="58" height="46" rx="12" fill="#3FDD8C" opacity="0.8"/>
|
||||
<rect x="10" y="30" width="58" height="46" rx="12" fill="#17171A"/>
|
||||
<path d="M24 42 L35 53 L24 64" fill="none" stroke="#ECEAE4" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 456 B |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 532 KiB After Width: | Height: | Size: 137 KiB |
@@ -1,5 +1,16 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="11" y="21" width="98" height="78" rx="18" fill="none" stroke="#000" stroke-width="9"/>
|
||||
<path d="M34 48 L50 61 L34 74" fill="none" stroke="#000" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="60" y1="76" x2="82" y2="76" stroke="#000" stroke-width="9" stroke-linecap="round"/>
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Template (alpha-only) tray glyph: the "Duo" mark, same geometry as
|
||||
app-icon.svg. Solid front pane with the chevron knocked out; the back
|
||||
pane reads through partial alpha (template images keep alpha, drop
|
||||
color). The mask spans both panes so the chevron cuts clean through. -->
|
||||
<defs>
|
||||
<mask id="chevron-cut">
|
||||
<rect width="96" height="96" fill="#fff"/>
|
||||
<path d="M24 42 L35 53 L24 64" fill="none" stroke="#000" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</mask>
|
||||
</defs>
|
||||
<g mask="url(#chevron-cut)">
|
||||
<rect x="28" y="16" width="58" height="46" rx="12" fill="#000" opacity="0.35"/>
|
||||
<rect x="10" y="30" width="58" height="46" rx="12" fill="#000"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 414 B After Width: | Height: | Size: 824 B |
@@ -5,7 +5,7 @@
|
||||
## Input
|
||||
|
||||
- **Ghost suggestions** — your history completes the whole line as you type; <kbd>→</kbd> to accept
|
||||
- **Explained tab completion** — every flag and subcommand with its description, for ~100 common commands
|
||||
- **Explained tab completion** — every flag and subcommand with its description, for ~100 common commands; when tty7 has nothing to offer the Tab falls through to your shell's own completion, and the whole feature can be turned off (Settings → Terminal → Keyboard, or `tab_completion` in `config.json`)
|
||||
- **Syntax highlighting** — as you type, nothing to install
|
||||
- **Fuzzy history search** — <kbd>⌃ R</kbd> shows what you ran, where, and whether it failed
|
||||
- **History from day one** — your existing shell history works as-is and carries across sessions
|
||||
@@ -18,7 +18,9 @@
|
||||
- **Repo-grouped sidebar** — the left tab sidebar groups rows under a header per git repository, non-repo tabs in a trailing *Scratch* section; branch switches and in-repo `cd`s never move a row (`sidebar_grouping` in `config.json`: `repo` default, `none` for a flat list)
|
||||
- **Command palette** <kbd>⌘ P</kbd> · scrollback search <kbd>⌘ F</kbd>
|
||||
- **⌘-click links** · desktop notifications · copy on select (opt-in, Settings → Terminal → Clipboard)
|
||||
- **Smart double-click selection** — double-click grabs the whole URL, file path, bracket/quote pair, or dictionary-segmented CJK word under the cursor; Shift-click extends a selection (toggle in Settings → Terminal → Mouse; word separators via `word_separators` in `config.json`)
|
||||
- **Eight themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker
|
||||
- **Sync with system** — Settings → Appearance; pick separate light and dark themes and tty7 follows the OS appearance live (`theme_follow_system`, `theme_preset_light` / `theme_preset_dark` in `config.json`)
|
||||
- **Window opacity & blur** — Settings → Appearance → Window; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur`
|
||||
- **CJK / IME input**
|
||||
|
||||
@@ -32,7 +34,7 @@ it never wraps or replaces the agent.
|
||||
- **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; run *Agent: Install Claude Code Hooks* from the palette to wire Claude Code up
|
||||
- **Notifications** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy
|
||||
- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N −M`), refreshed on `cd` and when a command finishes
|
||||
- **Session resume** — panes lost to a reboot re-launch their agent conversation (`claude --resume …`) on restore (`restore_agent_sessions`, on by default)
|
||||
- **Session resume** — panes lost to a reboot re-launch their agent conversation on restore, carrying the original launch flags (`claude --dangerously-skip-permissions --resume …`) (`restore_agent_sessions`, on by default)
|
||||
- **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt
|
||||
- **Tray icon** — a system tray / menu bar item that flips to an attention state the moment any agent needs your input; its menu lists every agent pane (brand avatar + status dot, click to reveal), switches the notification policy, and offers *Quit and Stop Daemon* alongside the plain session-keeping quit (`show_tray_icon`, on by default)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 输入
|
||||
|
||||
- **影子建议** —— 边打字边用你的历史补全整条命令,<kbd>→</kbd> 接受
|
||||
- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令
|
||||
- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令;tty7 没有候选时 Tab 自动交给 shell 自己的补全,整个功能也可关闭(设置 → 终端 → 键盘,或 `config.json` 里的 `tab_completion`)
|
||||
- **语法高亮** —— 边打边亮,什么都不用装
|
||||
- **模糊历史搜索** —— <kbd>⌃ R</kbd> 看到每条命令在哪跑的、什么时候、有没有失败
|
||||
- **历史开箱即用** —— 你已有的 shell 历史直接生效,并跨会话延续
|
||||
@@ -18,7 +18,9 @@
|
||||
- **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *Scratch* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json` 的 `sidebar_grouping`:默认 `repo`,`none` 恢复扁平列表)
|
||||
- **命令面板** <kbd>⌘ P</kbd> · 回滚搜索 <kbd>⌘ F</kbd>
|
||||
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板)
|
||||
- **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 终端 → 鼠标可开关;分隔符用 `config.json` 的 `word_separators` 配置)
|
||||
- **8 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择
|
||||
- **跟随系统外观** — 设置 → Appearance;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`)
|
||||
- **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur`
|
||||
- **CJK / 输入法输入**
|
||||
|
||||
@@ -31,7 +33,7 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包
|
||||
- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code
|
||||
- **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略
|
||||
- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新
|
||||
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话(`claude --resume …`;`restore_agent_sessions`,默认开启)
|
||||
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …`;`restore_agent_sessions`,默认开启)
|
||||
- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent
|
||||
- **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *Quit and Stop Daemon*(`show_tray_icon`,默认开启)
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ const MAX_STDIN: u64 = 64 * 1024;
|
||||
/// the controlling terminal. Always exits quietly — a hook that fails must
|
||||
/// never break the agent's own flow (agents surface nonzero exits).
|
||||
pub fn run_agent_hook(agent: &str, event: &str) {
|
||||
// Shed our own console before doing anything else. Debug builds are
|
||||
// console-subsystem (so `println!` logging works while developing the GUI),
|
||||
// so every hook process Claude Code spawns gets its *own* console window —
|
||||
// a rash of terminal windows that flash open and vanish as each end-of-turn
|
||||
// hook fires. We never use this console for I/O (stdin is piped and we write
|
||||
// to the *agent's* console via AttachConsole), so freeing it now tears the
|
||||
// window down before it can paint. No-op in release (GUI subsystem) and Unix.
|
||||
detach_console();
|
||||
// Not inside tty7 (or a remote shell): stay silent, so globally-installed
|
||||
// hooks don't leak escape sequences into other terminals.
|
||||
if std::env::var_os(TTY7_ENV_MARKER).is_none() {
|
||||
@@ -54,6 +62,26 @@ pub fn run_agent_hook(agent: &str, event: &str) {
|
||||
write_to_controlling_tty(&build_hook_sequence(agent, event, &input));
|
||||
}
|
||||
|
||||
/// Detach from — and, when we're the only process attached, destroy — the
|
||||
/// calling process's console. On Windows debug builds each `tty7 agent-hook …`
|
||||
/// process owns a throwaway console whose window would otherwise flash on
|
||||
/// screen; freeing it before the window paints removes the flash. The emitter
|
||||
/// re-attaches to the agent's console via `AttachConsole` when it writes, so
|
||||
/// this doesn't cost us the output path. No-op where there's no console to free.
|
||||
#[cfg(not(unix))]
|
||||
fn detach_console() {
|
||||
use windows_sys::Win32::System::Console::FreeConsole;
|
||||
// SAFETY: FreeConsole takes no arguments; it simply returns 0 when the
|
||||
// process has no attached console (release/GUI builds) and is otherwise a
|
||||
// clean detach.
|
||||
unsafe {
|
||||
FreeConsole();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn detach_console() {}
|
||||
|
||||
/// The sentinel event one hook invocation maps onto, or `None` to stay silent.
|
||||
/// Most hooks pass their event through; the exception is Copilot's single
|
||||
/// `notification` hook, which fires for *every* notification type — only
|
||||
@@ -82,7 +110,7 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
|
||||
"agent": agent,
|
||||
"event": event,
|
||||
});
|
||||
for key in ["session_id", "message"] {
|
||||
for key in ["session_id", "message", "cwd"] {
|
||||
if let Some(v) = payload
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -404,7 +432,9 @@ pub fn hooks_state(agent: HookAgent) -> HooksState {
|
||||
|
||||
/// Install (or rewrite in place) one agent's tty7 hooks. Idempotent: existing
|
||||
/// tty7 entries/files are replaced, never duplicated, and anything
|
||||
/// user-authored is left untouched. Returns a short human-readable summary.
|
||||
/// user-authored is left untouched. Returns a terse summary meant for the
|
||||
/// settings row's note line — the row already shows the agent and target
|
||||
/// path, so the summary never repeats them.
|
||||
pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
|
||||
let path = agent
|
||||
.target_path()
|
||||
@@ -412,10 +442,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
|
||||
match agent {
|
||||
HookAgent::Claude => {
|
||||
hook_map_install(&path, agent, CLAUDE_HOOK_EVENTS)?;
|
||||
Ok(format!(
|
||||
"Claude Code hooks installed in {} — restart running claude sessions to pick them up",
|
||||
path.display()
|
||||
))
|
||||
Ok("Installed".to_string())
|
||||
}
|
||||
HookAgent::Codex => {
|
||||
hook_map_install(&path, agent, CODEX_HOOK_EVENTS)?;
|
||||
@@ -423,11 +450,10 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
|
||||
// Best-effort: the file install above is complete and correct
|
||||
// either way, so a missing codex binary downgrades to advice
|
||||
// instead of failing the install.
|
||||
let summary = format!("Codex hooks installed in {}", path.display());
|
||||
Ok(match enable_codex_hooks_feature() {
|
||||
Ok(()) => summary,
|
||||
Ok(()) => "Installed".to_string(),
|
||||
Err(e) => format!(
|
||||
"{summary} — couldn't run `codex features enable hooks` ({e}); run it once manually"
|
||||
"Installed, but couldn't run `codex features enable hooks` ({e}) — run it once manually"
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -435,11 +461,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
|
||||
let content = owned_file_content(agent)
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?;
|
||||
owned_file_install(&path, &content, &agent.marker())?;
|
||||
Ok(format!(
|
||||
"{} integration installed at {}",
|
||||
agent.display_name(),
|
||||
path.display()
|
||||
))
|
||||
Ok("Installed".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,7 +499,11 @@ pub fn refresh_hooks_at_launch() -> usize {
|
||||
match install_hooks(agent) {
|
||||
Ok(summary) => {
|
||||
refreshed += 1;
|
||||
log::info!("refreshed stale agent hooks: {summary}");
|
||||
log::info!(
|
||||
"refreshed stale {} hooks at {}: {summary}",
|
||||
agent.display_name(),
|
||||
agent.target_display()
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"could not refresh stale {} hooks: {e}",
|
||||
@@ -705,11 +731,7 @@ fn hook_map_uninstall(path: &Path, agent: HookAgent) -> anyhow::Result<String> {
|
||||
return Ok("No tty7 hooks found; nothing to remove".to_string());
|
||||
}
|
||||
crate::core::config::write_atomic(path, serde_json::to_string_pretty(&root)?.as_bytes())?;
|
||||
Ok(format!(
|
||||
"{} hooks removed from {}",
|
||||
agent.display_name(),
|
||||
path.display()
|
||||
))
|
||||
Ok("Removed".to_string())
|
||||
}
|
||||
|
||||
/// The tty7 hook command inside one matcher entry
|
||||
@@ -740,10 +762,9 @@ fn enable_codex_hooks_feature() -> Result<(), String> {
|
||||
.chain(home_dir().map(|h| h.join(".local/bin/codex")))
|
||||
.find(|p| p.exists());
|
||||
let program = candidates.unwrap_or_else(|| PathBuf::from("codex"));
|
||||
match std::process::Command::new(&program)
|
||||
.args(["features", "enable", "hooks"])
|
||||
.output()
|
||||
{
|
||||
let mut cmd = std::process::Command::new(&program);
|
||||
cmd.args(["features", "enable", "hooks"]);
|
||||
match crate::core::proc::hide_console(&mut cmd).output() {
|
||||
Ok(out) if out.status.success() => Ok(()),
|
||||
Ok(out) => Err(format!(
|
||||
"codex exited with {}: {}",
|
||||
@@ -828,7 +849,7 @@ fn owned_file_uninstall(path: &Path, marker: &str) -> anyhow::Result<String> {
|
||||
{
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
Ok(format!("Removed {}", path.display()))
|
||||
Ok("Removed".to_string())
|
||||
}
|
||||
|
||||
/// Copilot hook file (`~/.copilot/hooks/tty7.json`): Copilot auto-loads every
|
||||
@@ -957,6 +978,7 @@ mod tests {
|
||||
assert_eq!(ev.kind, AgentEventKind::Notification);
|
||||
assert_eq!(ev.session_id.as_deref(), Some("abc-123"));
|
||||
assert!(ev.message.as_deref().unwrap().contains("permission"));
|
||||
assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w")));
|
||||
|
||||
// Garbage stdin still yields a well-formed bare event.
|
||||
let seq = build_hook_sequence("claude", "stop", "not json at all");
|
||||
|
||||
@@ -161,7 +161,18 @@ impl CLIAgent {
|
||||
/// native session id, or `None` for agents without a known resume flag.
|
||||
/// The id is what the agent reported in its `session-start` event (see
|
||||
/// [`AgentEvent`]); commands mirror cmux's per-agent resume table.
|
||||
pub fn resume_command(self, session_id: &str) -> Option<String> {
|
||||
///
|
||||
/// `launch_argv` is the argv the agent was originally launched with, when
|
||||
/// the daemon observed one. Its flags (`--dangerously-skip-permissions`,
|
||||
/// `--model …`) are carried onto the resume command so the restored
|
||||
/// session runs in the same mode the user picked — verbatim only when the
|
||||
/// whole tail passes the conservative shell-safety gate; otherwise the
|
||||
/// bare table command still resumes, just without the flags.
|
||||
pub fn resume_command(
|
||||
self,
|
||||
session_id: &str,
|
||||
launch_argv: Option<&[String]>,
|
||||
) -> Option<String> {
|
||||
// Ids come from the agent's own events, but they still land on a shell
|
||||
// command line — refuse anything that isn't a plain token so a
|
||||
// malicious/corrupt id can't smuggle shell syntax.
|
||||
@@ -172,25 +183,143 @@ impl CLIAgent {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// The user's launch flags, pre-joined with a leading space so they
|
||||
// splice into the format strings below; empty when none survive.
|
||||
let flags = launch_argv
|
||||
.and_then(|argv| self.replay_flags(argv))
|
||||
.map(|flags| {
|
||||
flags.iter().fold(String::new(), |mut s, f| {
|
||||
s.push(' ');
|
||||
s.push_str(f);
|
||||
s
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
match self {
|
||||
CLIAgent::Claude => Some(format!("claude --resume {session_id}")),
|
||||
CLIAgent::Codex => Some(format!("codex resume {session_id}")),
|
||||
CLIAgent::Gemini => Some(format!("gemini --resume {session_id}")),
|
||||
CLIAgent::OpenCode => Some(format!("opencode --session {session_id}")),
|
||||
CLIAgent::Amp => Some(format!("amp threads continue {session_id}")),
|
||||
CLIAgent::Cursor => Some(format!("cursor-agent --resume {session_id}")),
|
||||
CLIAgent::Claude => Some(format!("claude{flags} --resume {session_id}")),
|
||||
// Codex resumes via a subcommand that accepts the interactive
|
||||
// options after the positional id (`codex resume [OPTIONS]
|
||||
// [SESSION_ID]`).
|
||||
CLIAgent::Codex => Some(format!("codex resume {session_id}{flags}")),
|
||||
CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")),
|
||||
CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")),
|
||||
// Amp's global options (`--dangerously-allow-all`, …) are accepted
|
||||
// by the `threads continue` subcommand (verified: unknown options
|
||||
// are a parse error, globals pass).
|
||||
CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")),
|
||||
CLIAgent::Cursor => Some(format!("cursor-agent{flags} --resume {session_id}")),
|
||||
// Copilot CLI: `copilot --resume <sessionId>` (`-r` shorthand) —
|
||||
// the one hooks-covered agent that was missing from this table.
|
||||
CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The launch-flag tail of `argv` worth replaying on a resume command, or
|
||||
/// `None` to resume bare. Deliberately conservative: anything ambiguous
|
||||
/// falls back to no flags rather than a corrupted command line.
|
||||
///
|
||||
/// - The tail is everything after the token that names this agent (the
|
||||
/// launcher itself, or the script path in an interpreter-wrapped argv);
|
||||
/// leading `VAR=value` env assignments are skipped first so they can't
|
||||
/// mis-anchor (`CLAUDE_CONFIG_DIR=/opt/claude claude …`). No naming
|
||||
/// token at all (custom wrapper rules) → no flags.
|
||||
/// - Stale session-targeting flags (`--resume old-id`, `--continue`, a
|
||||
/// re-launched `codex resume <id>`) are stripped — the new id must win.
|
||||
/// - Every surviving token must be a plain shell-safe word, the first must
|
||||
/// be a `-` flag, and no two bare words may run consecutively — a bare
|
||||
/// word is only acceptable as the value directly behind a flag; anything
|
||||
/// else is a positional prompt that must not re-submit itself into the
|
||||
/// resumed session. Any violation drops the whole tail.
|
||||
fn replay_flags(self, argv: &[String]) -> Option<Vec<String>> {
|
||||
let names_self = |token: &str| {
|
||||
token.split(['/', '\\']).any(|seg| {
|
||||
CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self)
|
||||
})
|
||||
};
|
||||
let argv = &argv[argv.iter().take_while(|t| is_env_assignment(t)).count()..];
|
||||
let named = argv.iter().position(|t| names_self(t))?;
|
||||
let mut tail: Vec<&str> = argv[named + 1..].iter().map(String::as_str).collect();
|
||||
|
||||
// A relaunched `codex resume <old-id>`: drop the subcommand and its id
|
||||
// so they don't replay as a positional prompt.
|
||||
if self == CLIAgent::Codex && tail.first() == Some(&"resume") {
|
||||
tail.remove(0);
|
||||
if tail.first().is_some_and(|t| !t.starts_with('-')) {
|
||||
tail.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Session-targeting flags whose old value must not survive; each is
|
||||
// stripped together with one following non-flag value token (harmless
|
||||
// for the value-less ones — anything trailing them is positional).
|
||||
let stale: &[&str] = match self {
|
||||
CLIAgent::Claude => &[
|
||||
"--resume",
|
||||
"-r",
|
||||
"--continue",
|
||||
"-c",
|
||||
"--session-id",
|
||||
"--from-pr",
|
||||
],
|
||||
CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"],
|
||||
CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"],
|
||||
CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c"],
|
||||
// `--last` targets "the most recent session" and would contradict
|
||||
// the explicit id we inject.
|
||||
CLIAgent::Codex => &["--last"],
|
||||
_ => &[],
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < tail.len() {
|
||||
let t = tail[i];
|
||||
if stale.contains(&t)
|
||||
|| stale
|
||||
.iter()
|
||||
.any(|f| f.len() > 2 && t.starts_with(&format!("{f}=")))
|
||||
{
|
||||
tail.remove(i);
|
||||
if i < tail.len() && !tail[i].starts_with('-') {
|
||||
tail.remove(i);
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The safety gate: plain tokens only, and a flag-shaped tail — every
|
||||
// bare word must sit directly behind a `-` flag (its value slot); the
|
||||
// first token being bare, or two bare words in a row, is a positional
|
||||
// prompt and drops the whole tail. (A single bare word behind a
|
||||
// boolean flag is indistinguishable from a flag value and slips
|
||||
// through — the residual ambiguity of not knowing each flag's arity.)
|
||||
let safe = |t: &str| {
|
||||
!t.is_empty()
|
||||
&& t.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b))
|
||||
};
|
||||
if !tail.iter().all(|t| safe(t)) {
|
||||
return None;
|
||||
}
|
||||
let mut prev_was_flag = false;
|
||||
for t in &tail {
|
||||
let is_flag = t.starts_with('-');
|
||||
if !is_flag && !prev_was_flag {
|
||||
return None;
|
||||
}
|
||||
prev_was_flag = is_flag;
|
||||
}
|
||||
Some(tail.into_iter().map(String::from).collect())
|
||||
}
|
||||
|
||||
/// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility
|
||||
/// on both light and dark themes rather than exact brand black/white — a pure
|
||||
/// black or white dot vanishes against one theme, so vendors whose mark is
|
||||
/// monochrome (Codex/OpenAI, Cursor) get a recognizable mid-tone hue instead.
|
||||
/// on both light and dark themes rather than exact brand black/white. A pure
|
||||
/// black or white dot vanishes against one theme, so monochrome vendors get
|
||||
/// a recognizable mid-tone hue instead; Codex keeps its black field.
|
||||
pub fn accent_rgb(self) -> u32 {
|
||||
match self {
|
||||
CLIAgent::Claude => 0xD97757, // Claude terracotta
|
||||
CLIAgent::Codex => 0x10A37F, // OpenAI green (black mark reads as this)
|
||||
CLIAgent::Codex => 0x000000, // Codex black field
|
||||
CLIAgent::Gemini => 0x4285F4, // Google blue
|
||||
CLIAgent::Aider => 0x14B8A6, // teal
|
||||
CLIAgent::Amp => 0xF34E3F, // Amp red
|
||||
@@ -479,12 +608,39 @@ pub struct AgentSessionState {
|
||||
/// key its own `--resume` flag takes — persisted for restore.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
/// The argv the agent was launched with, as the daemon observed it (the
|
||||
/// foreground process-table poll on Unix, the shell integration's typed
|
||||
/// `133;C` capture on Windows). Persisted alongside the session id so
|
||||
/// restore can carry the user's launch flags
|
||||
/// (`--dangerously-skip-permissions`, `--model …`) onto the resume
|
||||
/// command — see [`CLIAgent::resume_command`]. Not touched by
|
||||
/// [`apply_event`](Self::apply_event); the daemon stamps it from the
|
||||
/// identity-detection side.
|
||||
#[serde(default)]
|
||||
pub launch_argv: Option<Vec<String>>,
|
||||
/// Whether this state came from the rich sentinel channel (hooks
|
||||
/// installed) rather than the opaque OSC 9/777 fallback. Rich state drives
|
||||
/// turn-level notifications; fallback state only paints the dot (the
|
||||
/// agent's own notification text was already toasted by the client).
|
||||
#[serde(default)]
|
||||
pub rich: bool,
|
||||
/// The agent's working directory as its hook payloads report it — the
|
||||
/// agent's own claim, which tracks internal chdirs the PTY can't show
|
||||
/// (Claude Code's EnterWorktree moves the session without any shell `cd`).
|
||||
/// Cleared on `session-end` so a finished session can't pin consumers to
|
||||
/// a stale path; while absent, consumers fall back to the pane's proc cwd.
|
||||
#[serde(default)]
|
||||
pub cwd: Option<std::path::PathBuf>,
|
||||
/// Tool completions seen in this session, counted only so consumers can
|
||||
/// spot *that* the agent did something — a turn's edits land tool by tool,
|
||||
/// and the status alone can't say so (`ToolComplete` is a no-op transition
|
||||
/// during normal work, by design). The sidebar's git probe watches this to
|
||||
/// refresh mid-turn instead of waiting for `stop`; see
|
||||
/// [`TerminalView::refresh_git_status`](crate::terminal::view::TerminalView).
|
||||
/// Monotonic within a session and never reset — consumers compare against
|
||||
/// the value they last saw, so only the *change* means anything.
|
||||
#[serde(default)]
|
||||
pub activity: u64,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
@@ -512,6 +668,9 @@ impl AgentSessionState {
|
||||
if let Some(id) = &ev.session_id {
|
||||
self.session_id = Some(id.clone());
|
||||
}
|
||||
if let Some(cwd) = &ev.cwd {
|
||||
self.cwd = Some(cwd.clone());
|
||||
}
|
||||
match ev.kind {
|
||||
AgentEventKind::SessionStart => {
|
||||
self.status = AgentStatus::Idle;
|
||||
@@ -549,6 +708,10 @@ impl AgentSessionState {
|
||||
// stream of completions during normal work is a no-op and can
|
||||
// never overwrite Done between turns.
|
||||
AgentEventKind::ToolComplete => {
|
||||
// The count moves even when the status doesn't: a tool call is
|
||||
// the one signal that the working tree may have just changed
|
||||
// under a turn that won't end for minutes.
|
||||
self.activity = self.activity.wrapping_add(1);
|
||||
if self.status == AgentStatus::Waiting {
|
||||
self.status = AgentStatus::Working;
|
||||
self.message = None;
|
||||
@@ -560,9 +723,12 @@ impl AgentSessionState {
|
||||
}
|
||||
// The agent session ended but its id stays: Claude & friends can
|
||||
// resume an *ended* session, which is exactly what restore does.
|
||||
// Its cwd claim does NOT stay: with no agent running, the pane's
|
||||
// real (proc-observed) directory is the truth again.
|
||||
AgentEventKind::SessionEnd => {
|
||||
self.status = AgentStatus::Idle;
|
||||
self.message = None;
|
||||
self.cwd = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,6 +762,9 @@ pub struct AgentEvent {
|
||||
pub kind: AgentEventKind,
|
||||
pub session_id: Option<String>,
|
||||
pub message: Option<String>,
|
||||
/// The agent's working directory at the moment the hook fired, when the
|
||||
/// payload carries one (Claude Code sends it on every hook event).
|
||||
pub cwd: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// Parse a complete OSC payload (identifier included, e.g.
|
||||
@@ -621,6 +790,8 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
|
||||
session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
cwd: Option<String>,
|
||||
}
|
||||
|
||||
let w: Wire = serde_json::from_slice(json).ok()?;
|
||||
@@ -631,6 +802,7 @@ pub fn parse_agent_event(payload: &[u8]) -> Option<AgentEvent> {
|
||||
kind,
|
||||
session_id: nonempty(w.session_id),
|
||||
message: nonempty(w.message),
|
||||
cwd: nonempty(w.cwd).map(std::path::PathBuf::from),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -744,6 +916,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_avatar_uses_its_black_brand_field() {
|
||||
assert_eq!(CLIAgent::Codex.accent_rgb(), 0x000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_newer_agents_by_command() {
|
||||
for (cmd, agent) in [
|
||||
@@ -880,6 +1057,7 @@ mod tests {
|
||||
kind,
|
||||
session_id: id.map(String::from),
|
||||
message: msg.map(String::from),
|
||||
cwd: None,
|
||||
};
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1")));
|
||||
@@ -939,22 +1117,260 @@ mod tests {
|
||||
assert_eq!(s.session_id.as_deref(), Some("sid-1"));
|
||||
}
|
||||
|
||||
/// Tool completions are deliberately a *status* no-op during normal work
|
||||
/// (the assertions above), which leaves consumers watching the status with
|
||||
/// no way to tell that an agent mid-turn just wrote a file. `activity` is
|
||||
/// what makes them observable: it moves on every completion, in every
|
||||
/// status, and never rewinds — the sidebar's git probe compares it against
|
||||
/// the value it last saw.
|
||||
#[test]
|
||||
fn tool_completions_count_even_when_the_status_holds_still() {
|
||||
let ev = |kind| AgentEvent {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
kind,
|
||||
session_id: None,
|
||||
message: None,
|
||||
cwd: None,
|
||||
};
|
||||
|
||||
let mut s = AgentSessionState::default();
|
||||
s.apply_event(&ev(AgentEventKind::PromptSubmit));
|
||||
assert_eq!(s.activity, 0, "a turn starting is not tool activity");
|
||||
|
||||
for n in 1..=3 {
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(s.status, AgentStatus::Working, "the status holds still…");
|
||||
assert_eq!(s.activity, n, "…while the counter is what moves");
|
||||
}
|
||||
|
||||
// A straggler after the turn ended still counts: it may well have
|
||||
// written a file, and it must not be mistaken for "nothing happened".
|
||||
s.apply_event(&ev(AgentEventKind::Stop));
|
||||
s.apply_event(&ev(AgentEventKind::ToolComplete));
|
||||
assert_eq!(
|
||||
s.status,
|
||||
AgentStatus::Done,
|
||||
"and still doesn't resurrect the turn"
|
||||
);
|
||||
assert_eq!(s.activity, 4);
|
||||
|
||||
// Session end resets plenty of state but not this — a rewind to 0 would
|
||||
// read to a delta-comparing consumer as one more tool call.
|
||||
s.apply_event(&ev(AgentEventKind::SessionEnd));
|
||||
assert_eq!(s.activity, 4);
|
||||
}
|
||||
|
||||
/// The agent's cwd claim: any event carrying one sets it, later events
|
||||
/// without one leave it alone (mid-turn events keep the worktree path
|
||||
/// alive), and session end drops it — an exited agent must not pin the
|
||||
/// pane's git line to a directory nothing runs in anymore.
|
||||
#[test]
|
||||
fn session_state_tracks_and_releases_the_agent_cwd() {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let ev = |kind, cwd: Option<&str>| AgentEvent {
|
||||
agent: Some(CLIAgent::Claude),
|
||||
kind,
|
||||
session_id: None,
|
||||
message: None,
|
||||
cwd: cwd.map(PathBuf::from),
|
||||
};
|
||||
|
||||
let mut s = AgentSessionState::default();
|
||||
s.apply_event(&ev(AgentEventKind::SessionStart, Some("/repo")));
|
||||
assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo")));
|
||||
|
||||
// EnterWorktree lands as a tool-complete carrying the new directory.
|
||||
s.apply_event(&ev(
|
||||
AgentEventKind::ToolComplete,
|
||||
Some("/repo/.claude/worktrees/fix-x"),
|
||||
));
|
||||
assert_eq!(
|
||||
s.cwd.as_deref(),
|
||||
Some(std::path::Path::new("/repo/.claude/worktrees/fix-x"))
|
||||
);
|
||||
|
||||
// An event without a cwd (another agent's sparser payload) keeps it.
|
||||
s.apply_event(&ev(AgentEventKind::Stop, None));
|
||||
assert_eq!(
|
||||
s.cwd.as_deref(),
|
||||
Some(std::path::Path::new("/repo/.claude/worktrees/fix-x"))
|
||||
);
|
||||
|
||||
s.apply_event(&ev(AgentEventKind::SessionEnd, None));
|
||||
assert_eq!(s.cwd, None, "session end releases the cwd claim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_commands_are_shell_safe() {
|
||||
assert_eq!(
|
||||
CLIAgent::Claude.resume_command("abc-123").as_deref(),
|
||||
CLIAgent::Claude.resume_command("abc-123", None).as_deref(),
|
||||
Some("claude --resume abc-123")
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Codex.resume_command("th_read.9").as_deref(),
|
||||
CLIAgent::Codex.resume_command("th_read.9", None).as_deref(),
|
||||
Some("codex resume th_read.9")
|
||||
);
|
||||
// No resume flag known → None.
|
||||
assert_eq!(CLIAgent::Aider.resume_command("abc"), None);
|
||||
assert_eq!(CLIAgent::Aider.resume_command("abc", None), None);
|
||||
// An id carrying shell syntax is refused outright.
|
||||
assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /"), None);
|
||||
assert_eq!(CLIAgent::Claude.resume_command("$(boom)"), None);
|
||||
assert_eq!(CLIAgent::Claude.resume_command(""), None);
|
||||
assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /", None), None);
|
||||
assert_eq!(CLIAgent::Claude.resume_command("$(boom)", None), None);
|
||||
assert_eq!(CLIAgent::Claude.resume_command("", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_carries_launch_flags() {
|
||||
let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
|
||||
// The headline case: the user's mode flags survive the restart.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command(
|
||||
"abc-123",
|
||||
Some(&argv(&["claude", "--dangerously-skip-permissions"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("claude --dangerously-skip-permissions --resume abc-123")
|
||||
);
|
||||
// Value-taking flags ride along whole.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command("abc", Some(&argv(&["claude", "--model", "opus"])))
|
||||
.as_deref(),
|
||||
Some("claude --model opus --resume abc")
|
||||
);
|
||||
// Interpreter-wrapped launch: flags start after the token naming the
|
||||
// agent, and the table's launcher name is what replays.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command(
|
||||
"abc",
|
||||
Some(&argv(&[
|
||||
"node",
|
||||
"/x/node_modules/@anthropic-ai/claude-code/cli.js",
|
||||
"--dangerously-skip-permissions",
|
||||
]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("claude --dangerously-skip-permissions --resume abc")
|
||||
);
|
||||
// A stale session-targeting flag is stripped — the new id must win.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command(
|
||||
"new-id",
|
||||
Some(&argv(&["claude", "--resume", "old-id", "--model", "opus"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("claude --model opus --resume new-id")
|
||||
);
|
||||
// Codex resumes via its subcommand, flags after the positional id; a
|
||||
// relaunched `codex resume <old>` sheds the old subcommand + id.
|
||||
assert_eq!(
|
||||
CLIAgent::Codex
|
||||
.resume_command("id-1", Some(&argv(&["codex", "--yolo"])))
|
||||
.as_deref(),
|
||||
Some("codex resume id-1 --yolo")
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Codex
|
||||
.resume_command("id-2", Some(&argv(&["codex", "resume", "id-1", "--yolo"])))
|
||||
.as_deref(),
|
||||
Some("codex resume id-2 --yolo")
|
||||
);
|
||||
// Anything shell-unsafe or positional-shaped drops the WHOLE tail —
|
||||
// resume still works, just bare.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command(
|
||||
"abc",
|
||||
Some(&argv(&["claude", "--allowedTools", "Bash(git:*)"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("claude --resume abc")
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command("abc", Some(&argv(&["claude", "fix-the-bug"])))
|
||||
.as_deref(),
|
||||
Some("claude --resume abc")
|
||||
);
|
||||
// A leading env assignment doesn't mis-anchor the flag tail.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command(
|
||||
"abc",
|
||||
Some(&argv(&[
|
||||
"CLAUDE_CONFIG_DIR=/opt/claude",
|
||||
"claude",
|
||||
"--dangerously-skip-permissions",
|
||||
]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("claude --dangerously-skip-permissions --resume abc")
|
||||
);
|
||||
// Two consecutive bare words = a positional prompt, not a flag value —
|
||||
// it must not re-submit itself into the resumed session.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command(
|
||||
"abc",
|
||||
Some(&argv(&["claude", "--model", "opus", "review", "this"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("claude --resume abc")
|
||||
);
|
||||
// Codex `--last` targets "most recent" and would contradict the
|
||||
// explicit id → stripped.
|
||||
assert_eq!(
|
||||
CLIAgent::Codex
|
||||
.resume_command(
|
||||
"id-3",
|
||||
Some(&argv(&["codex", "resume", "--last", "--yolo"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("codex resume id-3 --yolo")
|
||||
);
|
||||
// No token names the agent (custom wrapper rule) → bare.
|
||||
assert_eq!(
|
||||
CLIAgent::Claude
|
||||
.resume_command(
|
||||
"abc",
|
||||
Some(&argv(&["cc", "--dangerously-skip-permissions"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("claude --resume abc")
|
||||
);
|
||||
// Amp: global mode flags ride after the `threads continue` positional.
|
||||
assert_eq!(
|
||||
CLIAgent::Amp
|
||||
.resume_command("t-1", Some(&argv(&["amp", "--dangerously-allow-all"])))
|
||||
.as_deref(),
|
||||
Some("amp threads continue t-1 --dangerously-allow-all")
|
||||
);
|
||||
// A relaunch via `amp threads continue …` is subcommand-shaped, not
|
||||
// flag-shaped → bare (the gate rejects the leading bare word).
|
||||
assert_eq!(
|
||||
CLIAgent::Amp
|
||||
.resume_command("t-2", Some(&argv(&["amp", "threads", "continue", "t-1"])))
|
||||
.as_deref(),
|
||||
Some("amp threads continue t-2")
|
||||
);
|
||||
// Copilot resumes by flag, stale session targeting stripped.
|
||||
assert_eq!(
|
||||
CLIAgent::Copilot
|
||||
.resume_command(
|
||||
"s-9",
|
||||
Some(&argv(&["copilot", "--resume", "s-1", "--allow-all-tools"]))
|
||||
)
|
||||
.as_deref(),
|
||||
Some("copilot --allow-all-tools --resume s-9")
|
||||
);
|
||||
assert_eq!(
|
||||
CLIAgent::Copilot.resume_command("s-9", None).as_deref(),
|
||||
Some("copilot --resume s-9")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,6 +42,18 @@ pub struct Config {
|
||||
/// `~/.config/tty7/themes/*`); unknown ids fall back to the default theme. The
|
||||
/// native chrome is forced to match the theme's light/dark brightness.
|
||||
pub theme_preset: String,
|
||||
/// Follow the OS light/dark appearance. When `true` the active theme is
|
||||
/// resolved from `theme_preset_light` / `theme_preset_dark` by the current
|
||||
/// system appearance (switching live when the OS mode flips) and
|
||||
/// `theme_preset` is ignored; the native chrome follows the OS instead of
|
||||
/// being pinned to the theme.
|
||||
pub theme_follow_system: bool,
|
||||
/// Theme id used while `theme_follow_system` is on and the OS is in light
|
||||
/// mode. Same registry/fallback rules as `theme_preset`.
|
||||
pub theme_preset_light: String,
|
||||
/// Theme id used while `theme_follow_system` is on and the OS is in dark
|
||||
/// mode. Same registry/fallback rules as `theme_preset`.
|
||||
pub theme_preset_dark: String,
|
||||
/// Global window-opacity override, 0.2–1.0. `None` (the default) follows the
|
||||
/// active theme's own `opacity`; when set it applies to every theme, so a
|
||||
/// chosen translucency survives theme switches.
|
||||
@@ -75,6 +87,14 @@ pub struct Config {
|
||||
/// Detect URLs (OSC 8 hyperlinks + bare URLs in the text), underline them on
|
||||
/// hover, and open them on ⌘/Ctrl-click. On by default.
|
||||
pub link_url: bool,
|
||||
/// Optional command template run when ⌘/Ctrl-clicking a detected file-path
|
||||
/// link, instead of tty7's built-in "open in the default app" behavior. The
|
||||
/// template is tokenized on whitespace and the placeholders `{path}`,
|
||||
/// `{line}`, and `{column}` are substituted per argument; an argument that
|
||||
/// contains a placeholder with no value (e.g. `{line}` on a link that has no
|
||||
/// line number) is dropped. `None` (the default) keeps the built-in open.
|
||||
/// Example: `"herdr edit {path} --line {line}"`.
|
||||
pub link_file_command: Option<String>,
|
||||
/// When a pane is in a detected SSH session, Command-clicking loopback URLs
|
||||
/// opens them through a temporary local SSH port-forward. Off by default
|
||||
/// because it starts background `ssh` processes.
|
||||
@@ -152,6 +172,13 @@ pub struct Config {
|
||||
/// visual flash (the current behavior).
|
||||
#[serde(default, deserialize_with = "de_lenient")]
|
||||
pub bell: BellMode,
|
||||
/// Tab at the prompt opens tty7's own completion menu (commands, paths,
|
||||
/// per-command signatures). On by default. When off — or whenever the
|
||||
/// engine has nothing to offer — the prompt line is handed to the shell
|
||||
/// and Tab goes to the PTY, so the shell's native completion (compsys,
|
||||
/// fzf-tab, …) answers instead.
|
||||
#[serde(default = "default_true")]
|
||||
pub tab_completion: bool,
|
||||
|
||||
// ── Appearance ──────────────────────────────────────────────────────────
|
||||
/// The shape drawn for the terminal cursor.
|
||||
@@ -189,6 +216,19 @@ pub struct Config {
|
||||
/// the clipboard is never overwritten by a stray selection unless opted
|
||||
/// into.
|
||||
pub copy_on_select: bool,
|
||||
/// Double-click smart selection: expand the selection to the whole URL,
|
||||
/// email address, file path, or matching bracket pair under the cursor
|
||||
/// when the plain word sits inside one. On by default; off restores the
|
||||
/// bare word-boundary double-click.
|
||||
#[serde(default = "default_true")]
|
||||
pub smart_select: bool,
|
||||
/// Characters (besides whitespace) that end a double-click word
|
||||
/// selection, in both the terminal grid and the prompt's command editor.
|
||||
/// The default mirrors alacritty's semantic escape set — note `/ . - _`
|
||||
/// are *not* separators, so paths select as one word. JSON-only (no GUI
|
||||
/// widget yet).
|
||||
#[serde(default = "default_word_separators")]
|
||||
pub word_separators: String,
|
||||
/// Window state at launch: normal / maximized / fullscreen.
|
||||
#[serde(default, deserialize_with = "de_lenient")]
|
||||
pub startup_mode: StartupMode,
|
||||
@@ -450,6 +490,11 @@ impl Default for Config {
|
||||
// The default theme id (mirrors `ui::presets::DEFAULT_ID`; core can't
|
||||
// depend on ui). Unknown ids fall back to it anyway.
|
||||
theme_preset: "light".to_string(),
|
||||
theme_follow_system: false,
|
||||
// The built-in light/dark pair; each side is user-swappable in
|
||||
// Settings once "sync with system" is on.
|
||||
theme_preset_light: "light".to_string(),
|
||||
theme_preset_dark: "dark".to_string(),
|
||||
window_opacity: None,
|
||||
window_blur: None,
|
||||
keybindings: HashMap::new(),
|
||||
@@ -464,6 +509,7 @@ impl Default for Config {
|
||||
// out: URL detection on, cursor blinking, 10k scrollback, new tabs
|
||||
// after the active one, notify only while unfocused.
|
||||
link_url: true,
|
||||
link_file_command: None,
|
||||
ssh_loopback_forward: false,
|
||||
cursor_blink: true,
|
||||
scrollback_limit: 10_000,
|
||||
@@ -487,6 +533,7 @@ impl Default for Config {
|
||||
// Visual flash preserves the pre-config behavior (the bell always
|
||||
// flashed); opting into None/Audible is a deliberate change.
|
||||
bell: BellMode::Visual,
|
||||
tab_completion: true,
|
||||
cursor_style: CursorStyle::Block,
|
||||
// Input/mouse defaults preserve today's behavior: Option composes
|
||||
// characters as macOS ships it (opt into Option-as-Meta); GPUI
|
||||
@@ -500,6 +547,8 @@ impl Default for Config {
|
||||
mouse_reporting: true,
|
||||
clipboard_trim_trailing_spaces: false,
|
||||
copy_on_select: false,
|
||||
smart_select: true,
|
||||
word_separators: default_word_separators(),
|
||||
startup_mode: StartupMode::Normal,
|
||||
remember_window_size: true,
|
||||
working_directory: WorkingDirectory::default(),
|
||||
@@ -584,6 +633,14 @@ impl Config {
|
||||
self.right_panel_width = default_right_panel_width();
|
||||
}
|
||||
self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0);
|
||||
// An empty or whitespace-only file-open command means "no override"; the
|
||||
// settings text field yields `""` when cleared, so fold it back to `None`
|
||||
// rather than trying to run an empty command.
|
||||
if let Some(command) = &self.link_file_command
|
||||
&& command.trim().is_empty()
|
||||
{
|
||||
self.link_file_command = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the current config back to disk, creating the parent directory if
|
||||
@@ -772,6 +829,13 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Serde default for [`Config::word_separators`]: alacritty's stock semantic
|
||||
/// escape set, the boundary characters double-click word selection used
|
||||
/// before this was configurable.
|
||||
fn default_word_separators() -> String {
|
||||
",│`|:\"' ()[]{}<>\t".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a
|
||||
/// command had to cross before this was configurable.
|
||||
fn default_notify_threshold_secs() -> u64 {
|
||||
@@ -882,6 +946,27 @@ mod tests {
|
||||
assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_follow_system_defaults_and_round_trips() {
|
||||
// Old configs (no follow-system keys) must land on off + the built-in
|
||||
// light/dark pair, so nothing changes until the user opts in.
|
||||
let cfg: Config = serde_json::from_str(r#"{"theme_preset":"dracula"}"#).unwrap();
|
||||
assert!(!cfg.theme_follow_system);
|
||||
assert_eq!(cfg.theme_preset_light, "light");
|
||||
assert_eq!(cfg.theme_preset_dark, "dark");
|
||||
assert_eq!(cfg.theme_preset, "dracula");
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.theme_follow_system = true;
|
||||
cfg.theme_preset_light = "one_light".to_string();
|
||||
cfg.theme_preset_dark = "dracula".to_string();
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let back: Config = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.theme_follow_system);
|
||||
assert_eq!(back.theme_preset_light, "one_light");
|
||||
assert_eq!(back.theme_preset_dark, "dracula");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_features_are_optional_and_parse_as_gpui_features() {
|
||||
let cfg: Config =
|
||||
@@ -1080,6 +1165,7 @@ mod tests {
|
||||
let cfg = Config::default();
|
||||
assert!(cfg.restore_session);
|
||||
assert!(cfg.mouse_reporting);
|
||||
assert!(cfg.tab_completion);
|
||||
assert_eq!(cfg.notify_threshold_secs, 10);
|
||||
assert_eq!(cfg.bell, BellMode::Visual);
|
||||
|
||||
@@ -1088,9 +1174,14 @@ mod tests {
|
||||
let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap();
|
||||
assert!(cfg.restore_session);
|
||||
assert!(cfg.mouse_reporting);
|
||||
assert!(cfg.tab_completion);
|
||||
assert_eq!(cfg.notify_threshold_secs, 10);
|
||||
assert_eq!(cfg.bell, BellMode::Visual);
|
||||
|
||||
// The opt-out round-trips.
|
||||
let cfg: Config = serde_json::from_str(r#"{"tab_completion": false}"#).unwrap();
|
||||
assert!(!cfg.tab_completion);
|
||||
|
||||
// Valid values round-trip; a bad bell string falls back without failing
|
||||
// the whole parse.
|
||||
let cfg: Config = serde_json::from_str(
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod config;
|
||||
#[allow(dead_code)]
|
||||
pub mod keychain;
|
||||
pub mod osc;
|
||||
pub mod proc;
|
||||
pub mod session;
|
||||
pub mod shells;
|
||||
pub mod ssh_config;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! One place for the Windows subprocess flag every helper shell-out needs.
|
||||
//!
|
||||
//! tty7 is a GUI process with no console of its own, so launching a console
|
||||
//! subsystem program (`git.exe`, `wsl.exe`, …) makes Windows allocate a fresh
|
||||
//! console for it — a black window that pops up and vanishes. That is invisible
|
||||
//! on a one-off invocation and very visible on the git-status probe, which runs
|
||||
//! four `git` calls every time a pane's cwd changes or a command ends.
|
||||
//!
|
||||
//! `CREATE_NO_WINDOW` suppresses the console entirely; stdout/stderr pipes are
|
||||
//! unaffected, so output capture keeps working. Every non-PTY `Command` in the
|
||||
//! app should go through [`hide_console`] (or [`hide_console_tokio`] for the
|
||||
//! async flavor) before it runs. PTY children are not in scope — the daemon
|
||||
//! owns those and passes its own flags (see [`crate::daemon::spawn`]).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Suppress the console window Windows would otherwise allocate for a console
|
||||
/// subsystem child. No-op on Unix, so callers stay `cfg`-free.
|
||||
pub fn hide_console(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// [`hide_console`] for `tokio::process::Command`. Separate because tokio's
|
||||
/// builder is a distinct type with its own `creation_flags`, not a `Deref` to
|
||||
/// the std one.
|
||||
pub fn hide_console_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
@@ -54,6 +54,12 @@ pub enum SessionPane {
|
||||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
#[serde(default)]
|
||||
agent_session_id: Option<String>,
|
||||
/// The argv the agent was launched with, as the daemon observed it —
|
||||
/// lets the resume command carry the user's launch flags
|
||||
/// (`--dangerously-skip-permissions`, …) instead of resuming bare.
|
||||
/// `None` for old sessions or when nothing was captured.
|
||||
#[serde(default)]
|
||||
agent_launch_argv: Option<Vec<String>>,
|
||||
},
|
||||
/// A split of two subtrees along `axis`, with `a` taking `ratio` of space.
|
||||
Split {
|
||||
@@ -192,6 +198,7 @@ mod tests {
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
},
|
||||
},
|
||||
SessionTab {
|
||||
@@ -206,6 +213,7 @@ mod tests {
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
}),
|
||||
b: Box::new(SessionPane::Leaf {
|
||||
cwd: Some(PathBuf::from("/tmp")),
|
||||
@@ -213,6 +221,7 @@ mod tests {
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -244,6 +253,10 @@ mod tests {
|
||||
ssh_spec: None,
|
||||
agent: Some(crate::core::cli_agent::CLIAgent::Claude),
|
||||
agent_session_id: Some("abc-123".into()),
|
||||
agent_launch_argv: Some(vec![
|
||||
"claude".into(),
|
||||
"--dangerously-skip-permissions".into(),
|
||||
]),
|
||||
};
|
||||
let back: SessionPane =
|
||||
serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap();
|
||||
@@ -251,10 +264,20 @@ mod tests {
|
||||
SessionPane::Leaf {
|
||||
agent,
|
||||
agent_session_id,
|
||||
agent_launch_argv,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude));
|
||||
assert_eq!(agent_session_id.as_deref(), Some("abc-123"));
|
||||
assert_eq!(
|
||||
agent_launch_argv.as_deref(),
|
||||
Some(
|
||||
&[
|
||||
"claude".to_string(),
|
||||
"--dangerously-skip-permissions".to_string()
|
||||
][..]
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => panic!("expected leaf"),
|
||||
}
|
||||
@@ -266,6 +289,7 @@ mod tests {
|
||||
SessionPane::Leaf {
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
@@ -305,6 +329,7 @@ mod tests {
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -278,7 +278,11 @@ fn detect_windows() -> Vec<DetectedShell> {
|
||||
out.push(DetectedShell {
|
||||
label: "Git Bash".into(),
|
||||
program: bash.to_string_lossy().into_owned(),
|
||||
// Interactive login shell — matches Git Bash's own launcher.
|
||||
// Interactive login shell — matches Git Bash's own launcher. These
|
||||
// are tty7's args, not the user's, so shell integration may replace
|
||||
// them with its own spelling of the same thing (see
|
||||
// `protocol::ShellSpec::args_are_tty7_defaults`); they stand as the
|
||||
// fallback for when integration doesn't apply or fails to set up.
|
||||
args: vec!["-i".into(), "-l".into()],
|
||||
});
|
||||
}
|
||||
@@ -296,6 +300,22 @@ fn detect_windows() -> Vec<DetectedShell> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Git Bash's `bash.exe`, if Git for Windows is installed. Exposed only to
|
||||
/// tests, so `daemon::shell_integration`'s live-PTY check can spawn the same
|
||||
/// binary the dropdown does (and skip itself when there is none).
|
||||
#[cfg(all(windows, test))]
|
||||
pub fn git_bash_path() -> Option<PathBuf> {
|
||||
find_git_bash()
|
||||
}
|
||||
|
||||
/// Installed WSL distributions. Exposed only to tests, for the same reason as
|
||||
/// [`git_bash_path`]: the live-PTY check needs a real distro to launch into,
|
||||
/// and skips itself when there is none.
|
||||
#[cfg(all(windows, test))]
|
||||
pub fn wsl_distros() -> Vec<String> {
|
||||
list_wsl_distros()
|
||||
}
|
||||
|
||||
/// Git Bash from the usual Git-for-Windows install roots (machine-wide x64,
|
||||
/// x86, and the per-user installer's home).
|
||||
#[cfg(windows)]
|
||||
@@ -319,17 +339,13 @@ fn find_git_bash() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Installed WSL distribution names via `wsl.exe -l -q`, or empty when WSL is
|
||||
/// absent. `CREATE_NO_WINDOW` keeps the probe from flashing a console window
|
||||
/// (we're a GUI process).
|
||||
/// absent. [`hide_console`](crate::core::proc::hide_console) keeps the probe
|
||||
/// from flashing a console window (we're a GUI process).
|
||||
#[cfg(windows)]
|
||||
fn list_wsl_distros() -> Vec<String> {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let Ok(output) = std::process::Command::new("wsl.exe")
|
||||
.args(["-l", "-q"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
else {
|
||||
let mut cmd = std::process::Command::new("wsl.exe");
|
||||
cmd.args(["-l", "-q"]);
|
||||
let Ok(output) = crate::core::proc::hide_console(&mut cmd).output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !output.status.success() {
|
||||
|
||||
@@ -74,6 +74,12 @@ pub struct SshProfile {
|
||||
pub warn_on_close: Option<bool>,
|
||||
/// Suppress the server login banner.
|
||||
pub skip_banner: bool,
|
||||
/// Bootstrap tty7's shell integration into the remote shell (prompt marks,
|
||||
/// exit codes, cwd — what the inline line editor runs on). On by default;
|
||||
/// a remote we can't integrate declines itself, so this is the escape hatch
|
||||
/// for one we *can* but shouldn't.
|
||||
#[serde(default = "default_true")]
|
||||
pub shell_integration: bool,
|
||||
/// Commands sent automatically right after the shell opens.
|
||||
pub login_scripts: Vec<String>,
|
||||
/// Request X11 forwarding.
|
||||
@@ -110,6 +116,7 @@ impl Default for SshProfile {
|
||||
connect_timeout_s: None,
|
||||
warn_on_close: None,
|
||||
skip_banner: false,
|
||||
shell_integration: true,
|
||||
login_scripts: Vec::new(),
|
||||
x11: false,
|
||||
algorithms: Algorithms::default(),
|
||||
@@ -434,6 +441,22 @@ pub fn expand_tilde(path: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Profiles written before the shell-integration switch existed must load
|
||||
/// with it *on*: a plain `#[serde(default)]` would give `false` and quietly
|
||||
/// opt every existing profile out of the feature it never knew about.
|
||||
#[test]
|
||||
fn profiles_saved_before_the_switch_existed_default_to_integrated() {
|
||||
let profile: SshProfile =
|
||||
serde_json::from_str(r#"{"name":"prod","host":"h","user":"u"}"#).unwrap();
|
||||
assert!(profile.shell_integration);
|
||||
// …and a profile that explicitly opted out stays opted out.
|
||||
let off: SshProfile = serde_json::from_str(
|
||||
r#"{"name":"prod","host":"h","user":"u","shell_integration":false}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!off.shell_integration);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_connect_parses_basic_forms() {
|
||||
let q = parse_quick_connect("deploy@10.0.0.5").unwrap();
|
||||
@@ -679,3 +702,11 @@ fn new_id() -> Uuid {
|
||||
fn default_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
/// Serde default for [`SshProfile::shell_integration`]. Named rather than
|
||||
/// `#[serde(default)]` because the default is `true`, and because profiles
|
||||
/// written before the field existed must deserialize as opted *in* — the
|
||||
/// integration is the behavior we want everywhere it works.
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -62,10 +62,9 @@ pub fn is_inside_repo(cwd: &Path) -> bool {
|
||||
/// Run `git -C <dir> <args>`, returning trimmed stdout on success and trimmed
|
||||
/// stderr as the error otherwise.
|
||||
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = crate::core::proc::hide_console(&mut cmd)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run git: {e}"))?;
|
||||
if out.status.success() {
|
||||
|
||||
@@ -102,6 +102,33 @@ fn default_shell_name(cmd: &CommandBuilder) -> String {
|
||||
cmd.get_shell()
|
||||
}
|
||||
|
||||
/// The shell a spawn resolved to, plus who authored its args.
|
||||
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
|
||||
struct ChosenShell {
|
||||
program: String,
|
||||
args: Vec<String>,
|
||||
/// True when `args` are tty7's own defaults from shell discovery rather
|
||||
/// than the user's, so shell integration may replace them. See
|
||||
/// [`ShellSpec::args_are_tty7_defaults`].
|
||||
args_are_tty7_defaults: bool,
|
||||
}
|
||||
|
||||
/// Whether the chosen shell carries args tty7 must not second-guess, which is
|
||||
/// what makes `shell_integration::setup` decline bash and PowerShell (their
|
||||
/// injections replace argv rather than extend it). Two things have to hold for
|
||||
/// the args to be off-limits:
|
||||
///
|
||||
/// - there are some — an empty `args: []` (just picking the program) leaves
|
||||
/// nothing for bash's `--rcfile … -i` to conflict with; and
|
||||
/// - the *user* wrote them. The new-tab dropdown's args are tty7's own
|
||||
/// (`core::shells::detect_shells`), so integration is free to express the
|
||||
/// same intent its own way: Git Bash's `-i -l` means "interactive login
|
||||
/// shell", exactly what `setup_bash` rebuilds out of `--rcfile … -i` plus a
|
||||
/// replayed login-file chain. See [`ShellSpec::args_are_tty7_defaults`].
|
||||
fn has_custom_args(chosen: Option<&ChosenShell>) -> bool {
|
||||
chosen.is_some_and(|c| !c.args.is_empty() && !c.args_are_tty7_defaults)
|
||||
}
|
||||
|
||||
/// Which shell a spawn launches, by precedence: the explicit per-spawn override
|
||||
/// (the new-tab dropdown) > the configured `shell` in `config.json` > `None`,
|
||||
/// meaning the platform default (`default_prog()`). Kept as a function so the
|
||||
@@ -109,8 +136,21 @@ fn default_shell_name(cmd: &CommandBuilder) -> String {
|
||||
fn choose_shell(
|
||||
spawn_override: Option<ShellSpec>,
|
||||
configured: Option<(String, Vec<String>)>,
|
||||
) -> Option<(String, Vec<String>)> {
|
||||
spawn_override.map(|s| (s.program, s.args)).or(configured)
|
||||
) -> Option<ChosenShell> {
|
||||
spawn_override
|
||||
.map(|s| ChosenShell {
|
||||
program: s.program,
|
||||
args: s.args,
|
||||
args_are_tty7_defaults: s.args_are_tty7_defaults,
|
||||
})
|
||||
.or_else(|| {
|
||||
// Straight from `config.json` — the user wrote these.
|
||||
configured.map(|(program, args)| ChosenShell {
|
||||
program,
|
||||
args,
|
||||
args_are_tty7_defaults: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_shell_integration(
|
||||
@@ -123,7 +163,7 @@ fn apply_shell_integration(
|
||||
// sentinel builder. Integrations that need argv (fish `-C`, bash `--rcfile`,
|
||||
// PowerShell flags) must use an explicit command builder first. Env-only zsh
|
||||
// integration keeps the default login-shell path.
|
||||
if integration.force_non_login || (cmd.is_default_prog() && !integration.args.is_empty()) {
|
||||
if integration.replaces_argv || (cmd.is_default_prog() && !integration.args.is_empty()) {
|
||||
*cmd = CommandBuilder::new(resolved_program);
|
||||
}
|
||||
cmd.args(&integration.args);
|
||||
@@ -144,26 +184,70 @@ fn build_spawn_config(
|
||||
shell: Option<ShellSpec>,
|
||||
) -> anyhow::Result<SpawnConfig> {
|
||||
let initial_cwd = initial_working_directory(cwd);
|
||||
let (cmd, integration_dir) = build_shell_command(shell, &initial_cwd)?;
|
||||
// Resolved here rather than inside `build_shell_command` because the WSL tag
|
||||
// must be read off the shell we *actually* launch. `shell` is only the
|
||||
// per-spawn override; `config.json` supplies the program when it is `None`,
|
||||
// and a `wsl.exe` configured there is just as much a WSL pane as one picked
|
||||
// from the dropdown.
|
||||
let configured = choose_shell(shell, crate::core::config::shell_command());
|
||||
let remote = wsl_remote_context(configured.as_ref());
|
||||
let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd)?;
|
||||
Ok(SpawnConfig {
|
||||
cmd,
|
||||
initial_cwd,
|
||||
integration_dir,
|
||||
remote: None,
|
||||
remote,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tag a `wsl.exe` pane as living in another filesystem namespace, from the
|
||||
/// resolved shell rather than the process table — `wsl.exe` is exactly what tty7
|
||||
/// launched, so there is nothing to detect.
|
||||
///
|
||||
/// This is what makes `TerminalView::local_cwd` decline the distro's cwd, and
|
||||
/// so what keeps the local git probe, path completion, link resolution and cwd
|
||||
/// inheritance away from a path that means nothing on this side (and that
|
||||
/// Windows would read as drive-relative). It is set whether or not shell
|
||||
/// integration succeeded: an unintegrated WSL pane reports no cwd today, but if
|
||||
/// it ever does the gate must already be in place.
|
||||
///
|
||||
/// Takes the post-[`choose_shell`] program, not the per-spawn override: a
|
||||
/// `wsl.exe` written into `config.json` reaches the same integration and so must
|
||||
/// reach the same tag.
|
||||
fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option<RemoteContext> {
|
||||
if !cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
let chosen = shell?;
|
||||
let base = std::path::Path::new(&chosen.program)
|
||||
.file_name()?
|
||||
.to_str()?
|
||||
.to_ascii_lowercase();
|
||||
if base.strip_suffix(".exe").unwrap_or(&base) != "wsl" {
|
||||
return None;
|
||||
}
|
||||
Some(RemoteContext {
|
||||
kind: RemoteKind::Wsl,
|
||||
argv: Vec::new(),
|
||||
// The distro, when the args name one; otherwise `wsl.exe` picks the
|
||||
// default and we have no name for it without another probe. Shared with
|
||||
// the integration so the two can't disagree about which distro an argv
|
||||
// names — they are handed the very same args.
|
||||
target: shell_integration::wsl_distro(&chosen.args).unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the argv for a spawn from an already-resolved shell (see
|
||||
/// [`choose_shell`]); `None` means the platform default (the login shell on
|
||||
/// Unix, PowerShell on Windows).
|
||||
fn build_shell_command(
|
||||
shell: Option<ShellSpec>,
|
||||
configured: Option<ChosenShell>,
|
||||
initial_cwd: &Option<PathBuf>,
|
||||
) -> anyhow::Result<(CommandBuilder, Option<PathBuf>)> {
|
||||
// Build the shell command; `None` means the platform default (the login
|
||||
// shell on Unix, PowerShell on Windows).
|
||||
let configured = choose_shell(shell, crate::core::config::shell_command());
|
||||
let mut cmd = match &configured {
|
||||
Some((program, args)) => {
|
||||
let mut c = CommandBuilder::new(program);
|
||||
c.args(args);
|
||||
Some(chosen) => {
|
||||
let mut c = CommandBuilder::new(&chosen.program);
|
||||
c.args(&chosen.args);
|
||||
c
|
||||
}
|
||||
None => default_prog(),
|
||||
@@ -174,20 +258,20 @@ fn build_shell_command(
|
||||
// it's whatever `default_prog()` resolved (passwd/`$SHELL` on Unix,
|
||||
// `powershell.exe` on Windows — see `default_shell_name`).
|
||||
let resolved_program = match &configured {
|
||||
Some((program, _)) => program.clone(),
|
||||
Some(chosen) => chosen.program.clone(),
|
||||
None => default_shell_name(&cmd),
|
||||
};
|
||||
|
||||
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell
|
||||
// — see `daemon::shell_integration`). Best effort — `None` (an unsupported
|
||||
// shell, or a bash/PowerShell with unpreservable custom args) means we launch
|
||||
// bare. A configured shell only counts as having "custom args" to preserve
|
||||
// when it actually specifies any — an empty `args: []` (just picking the
|
||||
// program) leaves nothing for bash's `--rcfile -i` to conflict with.
|
||||
let has_custom_args = configured
|
||||
.as_ref()
|
||||
.is_some_and(|(_, args)| !args.is_empty());
|
||||
let integration = shell_integration::setup(Some(&resolved_program), has_custom_args);
|
||||
// Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell,
|
||||
// and through `wsl.exe` into a distro — see `daemon::shell_integration`).
|
||||
// Best effort — `None` (an unsupported shell, or one with unpreservable
|
||||
// custom args) means we launch bare. The args go in because the WSL path
|
||||
// reads the distro out of them.
|
||||
let integration = shell_integration::setup(
|
||||
Some(&resolved_program),
|
||||
configured.as_ref().map_or(&[][..], |c| c.args.as_slice()),
|
||||
has_custom_args(configured.as_ref()),
|
||||
);
|
||||
if let Some(integration) = &integration {
|
||||
apply_shell_integration(&mut cmd, &resolved_program, integration);
|
||||
}
|
||||
@@ -212,7 +296,17 @@ fn initial_working_directory(cwd: Option<PathBuf>) -> Option<PathBuf> {
|
||||
// client didn't pass an explicit cwd (tab-inherit / session restore still
|
||||
// win). Inherit -> `forced` is `None`, so we keep the fallback as before.
|
||||
let forced = crate::core::config::working_directory_base();
|
||||
cwd.or(forced).or(fallback)
|
||||
// Whatever wins must actually be a directory *here*. A client cwd is only
|
||||
// as good as the OSC 7 that produced it, and a shell that reports a path
|
||||
// this machine cannot resolve — a remote namespace, or an msys path like
|
||||
// `/c/Users/x` that Windows reads as drive-relative — would otherwise turn
|
||||
// a new tab or split into a hard spawn failure ("The directory name is
|
||||
// invalid") instead of quietly falling back. Cheap to check, and it bounds
|
||||
// the whole class rather than one shell's spelling at a time.
|
||||
[cwd, forced, fallback]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|d| d.is_dir())
|
||||
}
|
||||
|
||||
fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<PathBuf>) {
|
||||
@@ -374,6 +468,11 @@ struct PaneState {
|
||||
/// the foreground `argv` (same process-table poll as `remote`). `None` when
|
||||
/// no known agent runs — see [`crate::core::cli_agent`].
|
||||
agent: Option<crate::core::cli_agent::CLIAgent>,
|
||||
/// The argv the detected agent was launched with, held here until a rich
|
||||
/// session exists to stamp it into (the sentinel events that create the
|
||||
/// session can land before the first argv poll, and vice versa). Cleared
|
||||
/// with the chip. See [`stamp_launch_argv`].
|
||||
agent_argv: Option<Vec<String>>,
|
||||
/// The rich agent-session status (idle/working/waiting/done + native
|
||||
/// session id), folded from the sentinel OSC events the agent's hooks emit
|
||||
/// (with an opaque OSC 9/777 fallback). Cleared when the agent exits.
|
||||
@@ -409,8 +508,9 @@ struct ForegroundProbes {
|
||||
/// process group) — "no opinion", never applied, so it can't wipe an agent
|
||||
/// identified another way (sentinel events, the Windows `133;C;<cmd>`
|
||||
/// mark). `Some(answer)` is a real poll result; its inner `None` ("polled,
|
||||
/// no agent") clears the chip.
|
||||
agent: Box<dyn Fn() -> Option<Option<crate::core::cli_agent::CLIAgent>> + Send>,
|
||||
/// no agent") clears the chip. A detected agent travels with the `argv` it
|
||||
/// was identified from, kept for flag carry-over on session resume.
|
||||
agent: Box<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + Send>,
|
||||
}
|
||||
|
||||
/// The local-PTY backend: the same handles `DaemonPane` has always owned.
|
||||
@@ -575,6 +675,7 @@ impl DaemonPane {
|
||||
remote: spawn.remote.clone(),
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
agent_argv: None,
|
||||
alive: true,
|
||||
}));
|
||||
let shutting_down = Arc::new(AtomicBool::new(false));
|
||||
@@ -683,6 +784,7 @@ impl DaemonPane {
|
||||
// agent detection never runs for it.
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
agent_argv: None,
|
||||
alive: true,
|
||||
}));
|
||||
let shutting_down = Arc::new(AtomicBool::new(false));
|
||||
@@ -890,15 +992,23 @@ impl DaemonPane {
|
||||
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
|
||||
}
|
||||
let remote = if poll_now {
|
||||
// A native-SSH pane already carries its own remote
|
||||
// context; process-table detection must not clobber
|
||||
// it (this pane *is* SSH). Only a plain PTY pane gets
|
||||
// foreground `ssh` detection.
|
||||
// A pane tty7 itself spawned as remote (native SSH,
|
||||
// or WSL) already carries its own context from the
|
||||
// spawn spec; process-table detection must not
|
||||
// clobber it. Only `Ssh` — the kind this very probe
|
||||
// produces — may be replaced, so a pane that has
|
||||
// since left a foreground `ssh` clears correctly.
|
||||
//
|
||||
// Testing `!= Ssh` rather than `== NativeSsh` is
|
||||
// load-bearing for WSL: `wsl.exe` is not `ssh`, so
|
||||
// the probe returns `None` and would blank the
|
||||
// context on the very next poll — twice a second,
|
||||
// each time also clearing the pane's cwd.
|
||||
let managed = {
|
||||
let st = state.lock().unwrap();
|
||||
st.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.kind == RemoteKind::NativeSsh)
|
||||
.is_some_and(|remote| remote.kind != RemoteKind::Ssh)
|
||||
};
|
||||
(!managed).then(&foreground_remote)
|
||||
} else {
|
||||
@@ -1648,7 +1758,8 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
|
||||
if shell_mark_capture_changed(&st.shell, &shell) {
|
||||
apply_agent(
|
||||
st,
|
||||
agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()),
|
||||
agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached())
|
||||
.map(|agent| (agent, Vec::new())),
|
||||
);
|
||||
}
|
||||
st.shell = shell.clone();
|
||||
@@ -1686,6 +1797,12 @@ fn agent_from_shell_mark(
|
||||
shell: &ShellState,
|
||||
custom: &std::collections::HashMap<String, String>,
|
||||
) -> Option<crate::core::cli_agent::CLIAgent> {
|
||||
// Identity only — deliberately NOT a launch-argv source for resume flag
|
||||
// carry-over. The `133;C` capture is terminal *output* (any program can
|
||||
// print the OSC and forge it), and forged flags would ride an
|
||||
// auto-executed resume command on the next restore; the Unix argv comes
|
||||
// from the process table and has no such problem. Windows sessions
|
||||
// therefore resume bare.
|
||||
shell
|
||||
.command
|
||||
.as_deref()
|
||||
@@ -1739,6 +1856,16 @@ fn apply_agent_signals(
|
||||
sess.message = Some(body);
|
||||
}
|
||||
|
||||
// A session the events just created starts without the launch argv the
|
||||
// identity poll captured — seed it so resume gets the flags no matter
|
||||
// which side observed the pane first. Updates keep flowing through
|
||||
// [`stamp_launch_argv`].
|
||||
if let (Some(sess), Some(argv)) = (&mut st.agent_session, &st.agent_argv)
|
||||
&& sess.launch_argv.is_none()
|
||||
{
|
||||
sess.launch_argv = Some(argv.clone());
|
||||
}
|
||||
|
||||
if st.agent_session != before
|
||||
&& let Some(sub) = &st.subscriber
|
||||
{
|
||||
@@ -1750,14 +1877,35 @@ fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) {
|
||||
if st.remote == remote {
|
||||
return;
|
||||
}
|
||||
// The cwd belonged to whichever side we are leaving, and it does not
|
||||
// survive the crossing: a remote path is meaningless locally, and a local
|
||||
// one is meaningless on the remote. Drop it so the pane reports no cwd
|
||||
// until the new shell's OSC 7 lands, rather than attributing the old
|
||||
// namespace's directory to the new one — otherwise a local shell without
|
||||
// shell integration keeps serving the remote's last path to the local
|
||||
// `git` probe for the rest of its life.
|
||||
// `DaemonMsg::Cwd` carries a bare path with no "cleared" form, so the
|
||||
// client mirrors this on its own when it sees the `RemoteContext` below.
|
||||
st.cwd = None;
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::RemoteContext(remote.clone()));
|
||||
}
|
||||
st.remote = remote;
|
||||
}
|
||||
|
||||
fn apply_agent(st: &mut PaneState, agent: Option<crate::core::cli_agent::CLIAgent>) {
|
||||
fn apply_agent(
|
||||
st: &mut PaneState,
|
||||
detected: Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>,
|
||||
) {
|
||||
let (agent, argv) = match detected {
|
||||
Some((agent, argv)) => (Some(agent), Some(argv)),
|
||||
None => (None, None),
|
||||
};
|
||||
if st.agent == agent {
|
||||
// Same chip, but the observed argv can still be news: the sentinel
|
||||
// events may have branded the pane before the first argv poll, or the
|
||||
// user relaunched the agent with different flags.
|
||||
stamp_launch_argv(st, argv);
|
||||
return;
|
||||
}
|
||||
// The agent leaving the foreground ends its session: clear the rich state
|
||||
@@ -1770,10 +1918,38 @@ fn apply_agent(st: &mut PaneState, agent: Option<crate::core::cli_agent::CLIAgen
|
||||
let _ = sub.send(DaemonMsg::AgentStatus(None));
|
||||
}
|
||||
}
|
||||
if agent.is_none() {
|
||||
st.agent_argv = None;
|
||||
}
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::Agent(agent));
|
||||
}
|
||||
st.agent = agent;
|
||||
stamp_launch_argv(st, argv);
|
||||
}
|
||||
|
||||
/// Record the detected agent's launch argv and mirror it into the rich session
|
||||
/// state (pushing the change to the client) when one exists — resume-after-
|
||||
/// restart reads it from there. `None` (a poll that saw no argv) and an empty
|
||||
/// argv (Windows mark detection, identity without a trustworthy argv) never
|
||||
/// wipe a captured value; the chip clearing in [`apply_agent`] does that.
|
||||
fn stamp_launch_argv(st: &mut PaneState, argv: Option<Vec<String>>) {
|
||||
let Some(argv) = argv else { return };
|
||||
if argv.is_empty() {
|
||||
return;
|
||||
}
|
||||
if st.agent_argv.as_ref() == Some(&argv) {
|
||||
return;
|
||||
}
|
||||
st.agent_argv = Some(argv.clone());
|
||||
if let Some(sess) = &mut st.agent_session
|
||||
&& sess.launch_argv.as_ref() != Some(&argv)
|
||||
{
|
||||
sess.launch_argv = Some(argv);
|
||||
if let Some(sub) = &st.subscriber {
|
||||
let _ = sub.send(DaemonMsg::AgentStatus(st.agent_session.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a foreground command — not the shell itself — currently owns the
|
||||
@@ -1840,14 +2016,16 @@ fn foreground_remote_context(_master: &Mutex<Box<dyn MasterPty + Send>>) -> Opti
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn foreground_agent(
|
||||
master: &Mutex<Box<dyn MasterPty + Send>>,
|
||||
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
|
||||
) -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> {
|
||||
let detect = || {
|
||||
let pid = master.lock().ok().and_then(|m| m.process_group_leader())?;
|
||||
let argv = crate::daemon::remote::foreground_argv(pid)?;
|
||||
crate::core::cli_agent::CLIAgent::detect_from_argv_with(
|
||||
let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with(
|
||||
&argv,
|
||||
crate::core::config::agent_commands_cached(),
|
||||
)
|
||||
)?;
|
||||
// The argv rides along: resume-after-restart replays its flags.
|
||||
Some((agent, argv))
|
||||
};
|
||||
Some(detect())
|
||||
}
|
||||
@@ -1859,7 +2037,7 @@ fn foreground_agent(
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn foreground_agent(
|
||||
_master: &Mutex<Box<dyn MasterPty + Send>>,
|
||||
) -> Option<Option<crate::core::cli_agent::CLIAgent>> {
|
||||
) -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -2027,7 +2205,9 @@ fn strip_uri_drive_slash(path: &str) -> &str {
|
||||
}
|
||||
|
||||
/// Parse an OSC 7 `file://HOST/PATH` (or bare absolute path) payload.
|
||||
fn parse_osc7(payload: &[u8]) -> Option<PathBuf> {
|
||||
/// `pub(crate)` so the shell-integration tests can round-trip what the snippets
|
||||
/// actually emit through the parser that consumes it.
|
||||
pub(crate) fn parse_osc7(payload: &[u8]) -> Option<PathBuf> {
|
||||
let rest = payload.strip_prefix(b"7;")?;
|
||||
let path_bytes: &[u8] = if let Some(after) = rest.strip_prefix(b"file://") {
|
||||
let idx = after.iter().position(|&c| c == b'/')?;
|
||||
@@ -2116,6 +2296,48 @@ fn proc_name(pid: i32) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
/// A cwd the client reports is only as trustworthy as the OSC 7 behind it.
|
||||
/// Passing one this machine cannot resolve straight to `cmd.cwd()` turns a
|
||||
/// new tab or split into a hard spawn failure, so anything that isn't a
|
||||
/// real directory here must fall through to the next candidate instead.
|
||||
#[test]
|
||||
fn initial_working_directory_skips_paths_that_are_not_directories() {
|
||||
let real = std::env::temp_dir();
|
||||
assert!(real.is_dir(), "temp dir should exist");
|
||||
|
||||
// A usable client cwd still wins outright.
|
||||
assert_eq!(
|
||||
initial_working_directory(Some(real.clone())),
|
||||
Some(real.clone())
|
||||
);
|
||||
|
||||
// A remote-namespace path, and the msys shape Windows reads as
|
||||
// drive-relative: neither resolves here, so neither may be used.
|
||||
for bogus in [
|
||||
"/home/someone/definitely-not-here",
|
||||
"/c/Users/definitely-not-here",
|
||||
] {
|
||||
let got = initial_working_directory(Some(PathBuf::from(bogus)));
|
||||
assert_ne!(
|
||||
got.as_deref(),
|
||||
Some(Path::new(bogus)),
|
||||
"{bogus} is not a directory here and must not be handed to spawn"
|
||||
);
|
||||
// Whatever we fall back to must itself be usable.
|
||||
if let Some(d) = got {
|
||||
assert!(d.is_dir(), "fallback {d:?} must be a real directory");
|
||||
}
|
||||
}
|
||||
|
||||
// A file is not a directory either.
|
||||
let file = real.join("tty7-iwd-probe");
|
||||
std::fs::write(&file, b"x").expect("write probe file");
|
||||
let got = initial_working_directory(Some(file.clone()));
|
||||
assert_ne!(got.as_deref(), Some(file.as_path()));
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// End-to-end check of the *live* agent-detection chain this feature rides
|
||||
/// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding
|
||||
@@ -2163,11 +2385,17 @@ mod tests {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
let (agent, argv) = detected.expect("agent detected from live PTY child");
|
||||
assert_eq!(
|
||||
detected,
|
||||
Some(crate::core::cli_agent::CLIAgent::Codex),
|
||||
agent,
|
||||
crate::core::cli_agent::CLIAgent::Codex,
|
||||
"a live PTY child with argv[0]=codex must be detected as Codex"
|
||||
);
|
||||
assert_eq!(
|
||||
argv.first().map(String::as_str),
|
||||
Some("codex"),
|
||||
"the observed argv rides along with the detection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawn shell precedence: explicit override > configured > platform
|
||||
@@ -2177,27 +2405,138 @@ mod tests {
|
||||
let over = ShellSpec {
|
||||
program: "fish".into(),
|
||||
args: vec!["-l".into()],
|
||||
args_are_tty7_defaults: true,
|
||||
};
|
||||
let cfg = ("zsh".to_string(), vec!["-i".to_string()]);
|
||||
|
||||
// Override wins even when a shell is configured.
|
||||
// Override wins even when a shell is configured, carrying its
|
||||
// arg-ownership flag through.
|
||||
assert_eq!(
|
||||
choose_shell(Some(over.clone()), Some(cfg.clone())),
|
||||
Some(("fish".to_string(), vec!["-l".to_string()]))
|
||||
Some(ChosenShell {
|
||||
program: "fish".to_string(),
|
||||
args: vec!["-l".to_string()],
|
||||
args_are_tty7_defaults: true,
|
||||
})
|
||||
);
|
||||
// No override → the configured shell, whose args are the user's and so
|
||||
// are never tty7 defaults.
|
||||
assert_eq!(
|
||||
choose_shell(None, Some(cfg.clone())),
|
||||
Some(ChosenShell {
|
||||
program: "zsh".to_string(),
|
||||
args: vec!["-i".to_string()],
|
||||
args_are_tty7_defaults: false,
|
||||
})
|
||||
);
|
||||
// No override → the configured shell.
|
||||
assert_eq!(choose_shell(None, Some(cfg.clone())), Some(cfg));
|
||||
// Neither → platform default.
|
||||
assert_eq!(choose_shell(None, None), None);
|
||||
}
|
||||
|
||||
/// Only *user*-authored args block integration. Locks the contract stated
|
||||
/// on [`has_custom_args`] — in particular that the Git Bash dropdown row's
|
||||
/// `-i -l` does not, which is what lets it get shell integration at all.
|
||||
#[test]
|
||||
fn only_user_authored_args_block_shell_integration() {
|
||||
let chosen = |args: Vec<&str>, tty7: bool| ChosenShell {
|
||||
program: r"C:\Program Files\Git\bin\bash.exe".to_string(),
|
||||
args: args.into_iter().map(str::to_string).collect(),
|
||||
args_are_tty7_defaults: tty7,
|
||||
};
|
||||
|
||||
// The Git Bash dropdown row: tty7 wrote `-i -l`, so `setup_bash` may
|
||||
// replace them.
|
||||
assert!(!has_custom_args(Some(&chosen(vec!["-i", "-l"], true))));
|
||||
// The same args from the user's config.json are theirs to keep.
|
||||
assert!(has_custom_args(Some(&chosen(vec!["-i", "-l"], false))));
|
||||
// No args at all: nothing to preserve either way.
|
||||
assert!(!has_custom_args(Some(&chosen(vec![], false))));
|
||||
assert!(!has_custom_args(Some(&chosen(vec![], true))));
|
||||
// Platform default — no configured shell at all.
|
||||
assert!(!has_custom_args(None));
|
||||
}
|
||||
|
||||
/// A WSL pane must be tagged as living in another filesystem namespace, so
|
||||
/// `TerminalView::local_cwd` declines the distro's cwd and the local git
|
||||
/// probe / completion / link resolution / cwd inheritance never see a path
|
||||
/// that means nothing here — and that Windows would read as drive-relative
|
||||
/// (`/home/me` -> `C:\home\me`) rather than reject.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn wsl_panes_are_tagged_as_a_foreign_filesystem() {
|
||||
let spec = |program: &str, args: Vec<&str>| ChosenShell {
|
||||
program: program.to_string(),
|
||||
args: args.into_iter().map(str::to_string).collect(),
|
||||
args_are_tty7_defaults: true,
|
||||
};
|
||||
|
||||
// The dropdown's WSL row.
|
||||
let ctx = wsl_remote_context(Some(&spec(
|
||||
"wsl.exe",
|
||||
vec!["--distribution", "Ubuntu-24.04", "--cd", "~"],
|
||||
)))
|
||||
.expect("wsl.exe must be tagged");
|
||||
assert_eq!(ctx.kind, RemoteKind::Wsl);
|
||||
// The distro rides along as the target so the UI has a name for it.
|
||||
assert_eq!(ctx.target, "Ubuntu-24.04");
|
||||
// Nothing reads `argv` for this kind; it is not an ssh invocation.
|
||||
assert!(ctx.argv.is_empty());
|
||||
|
||||
// Short flag, and no flag at all (wsl.exe then picks the default
|
||||
// distro — still a WSL pane, just one we have no name for).
|
||||
assert_eq!(
|
||||
wsl_remote_context(Some(&spec("wsl.exe", vec!["-d", "Debian"])))
|
||||
.expect("short flag")
|
||||
.target,
|
||||
"Debian"
|
||||
);
|
||||
assert_eq!(
|
||||
wsl_remote_context(Some(&spec("wsl.exe", vec![])))
|
||||
.expect("default distro is still WSL")
|
||||
.target,
|
||||
""
|
||||
);
|
||||
// The `--distribution=NAME` spelling too — the tag reads the distro with
|
||||
// the integration's own parser, so the two cannot disagree about an argv
|
||||
// they are both handed.
|
||||
assert_eq!(
|
||||
wsl_remote_context(Some(&spec("wsl.exe", vec!["--distribution=Arch"])))
|
||||
.expect("joined flag")
|
||||
.target,
|
||||
"Arch"
|
||||
);
|
||||
// Case- and suffix-insensitive, like every other Windows program name.
|
||||
assert!(wsl_remote_context(Some(&spec(r"C:\Windows\System32\WSL.EXE", vec![]))).is_some());
|
||||
|
||||
// Regression: the tag is read off the *resolved* shell, not the
|
||||
// per-spawn override. A `wsl.exe` written into `config.json` reaches
|
||||
// `setup_wsl` with no override in play (empty args, so nothing custom to
|
||||
// preserve), so the distro starts reporting its own cwd — and an
|
||||
// untagged pane would hand `/home/me/proj` straight to the local git
|
||||
// probe, which Windows resolves drive-relative to `C:\home\me\proj`.
|
||||
let from_config = choose_shell(None, Some(("wsl.exe".to_string(), Vec::new())));
|
||||
assert_eq!(
|
||||
wsl_remote_context(from_config.as_ref()).map(|c| c.kind),
|
||||
Some(RemoteKind::Wsl),
|
||||
"a configured wsl.exe is as much a WSL pane as a dropdown one"
|
||||
);
|
||||
|
||||
// Everything else is a local pane and must not be tagged — tagging it
|
||||
// would silently disable its git status, completion and cwd inheritance.
|
||||
assert!(wsl_remote_context(Some(&spec("powershell.exe", vec![]))).is_none());
|
||||
assert!(
|
||||
wsl_remote_context(Some(&spec(r"C:\Program Files\Git\bin\bash.exe", vec![]))).is_none()
|
||||
);
|
||||
assert!(wsl_remote_context(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arg_based_integration_rebuilds_default_shell_builder() {
|
||||
let mut cmd = CommandBuilder::new_default_prog();
|
||||
let injection = shell_integration::Injection {
|
||||
env: std::collections::HashMap::new(),
|
||||
args: vec!["-C".to_string(), "echo ready".to_string()],
|
||||
force_non_login: false,
|
||||
replaces_argv: false,
|
||||
dir: None,
|
||||
};
|
||||
|
||||
@@ -2220,7 +2559,7 @@ mod tests {
|
||||
let injection = shell_integration::Injection {
|
||||
env,
|
||||
args: Vec::new(),
|
||||
force_non_login: false,
|
||||
replaces_argv: false,
|
||||
dir: None,
|
||||
};
|
||||
|
||||
@@ -2897,6 +3236,7 @@ mod tests {
|
||||
remote: None,
|
||||
agent: None,
|
||||
agent_session: None,
|
||||
agent_argv: None,
|
||||
alive,
|
||||
}
|
||||
}
|
||||
@@ -2993,7 +3333,10 @@ mod tests {
|
||||
status: AgentStatus::Working,
|
||||
message: None,
|
||||
session_id: Some("sid".into()),
|
||||
launch_argv: None,
|
||||
rich: true,
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
});
|
||||
apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
|
||||
assert_eq!(
|
||||
|
||||
@@ -76,6 +76,18 @@ pub struct ShellSpec {
|
||||
pub program: String,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// True when `args` were authored by tty7's own shell discovery
|
||||
/// (`core::shells`) rather than by the user, and so may be replaced by
|
||||
/// shell integration — Git Bash's `-i -l` is tty7's way of saying "an
|
||||
/// interactive login shell", which `setup_bash`'s `--rcfile … -i` plus its
|
||||
/// replayed login-file chain expresses differently but equivalently.
|
||||
/// User-configured args get no such liberty; see
|
||||
/// `daemon::shell_integration::setup`'s `has_custom_args`.
|
||||
///
|
||||
/// Defaults to `false` on the wire so an older client's frame — which can
|
||||
/// only carry user-configured args — keeps them untouched.
|
||||
#[serde(default)]
|
||||
pub args_are_tty7_defaults: bool,
|
||||
}
|
||||
|
||||
/// Whether a short `ssh` option flag consumes the following argument as its
|
||||
@@ -120,14 +132,21 @@ pub struct PaneInfo {
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
/// A foreground remote session the daemon can prove from the local process table.
|
||||
/// A pane whose filesystem is not the host's — either a remote session, or a
|
||||
/// local one behind a boundary the host's own tools can't follow (WSL).
|
||||
///
|
||||
/// The common consequence, whatever the kind, is that the pane's cwd names a
|
||||
/// path in *that* namespace: see `TerminalView::local_cwd`, which is what keeps
|
||||
/// a local `git` / `read_dir` / spawn away from it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RemoteContext {
|
||||
pub kind: RemoteKind,
|
||||
/// Original foreground argv. Kept so follow-up operations can preserve ssh
|
||||
/// config flags such as `-F`, `-p`, and `-J` rather than guessing.
|
||||
/// config flags such as `-F`, `-p`, and `-J` rather than guessing. Empty
|
||||
/// for kinds that aren't detected from a foreground process.
|
||||
pub argv: Vec<String>,
|
||||
/// The destination token (`host`, `user@host`, or ssh config alias).
|
||||
/// The destination token: `host`, `user@host`, or ssh config alias for the
|
||||
/// ssh kinds; the distro name for [`RemoteKind::Wsl`].
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
@@ -142,6 +161,15 @@ pub enum RemoteKind {
|
||||
/// (`daemon::ssh`). Forwarding / SFTP reach the connection through the
|
||||
/// in-memory registry.
|
||||
NativeSsh,
|
||||
/// A `wsl.exe` pane: not remote in the network sense, but its shell lives
|
||||
/// inside a distro with its own filesystem namespace, so a cwd it reports
|
||||
/// (`/home/me/proj`) means nothing to the Windows-side host — and on
|
||||
/// Windows is *drive-relative* rather than invalid, so it silently resolves
|
||||
/// to `C:\home\me\proj`. Set at spawn time from the `ShellSpec`, not
|
||||
/// detected from the process table. Nothing SSH-specific applies to it:
|
||||
/// callers that mean "an SSH pane" must test the kind, not merely that a
|
||||
/// `RemoteContext` is present.
|
||||
Wsl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -542,6 +570,19 @@ pub struct NativeSshSpec {
|
||||
pub verify_host_keys: bool,
|
||||
#[serde(default)]
|
||||
pub skip_banner: bool,
|
||||
/// Bootstrap tty7's shell integration (OSC 133 prompt marks + cwd
|
||||
/// reporting) into the remote shell — what powers the inline line editor,
|
||||
/// exit-code marks and cwd tracking for this pane. See
|
||||
/// [`crate::daemon::shell_integration::remote`].
|
||||
///
|
||||
/// On by default, and a remote we can't integrate declines itself (the
|
||||
/// probe answers "unknown shell" and the session starts bare), so this is
|
||||
/// for the case the probe can't detect: a remote where the bootstrap *would*
|
||||
/// work but the user would rather it didn't — a bash host where the
|
||||
/// login-shell → `--rcfile` swap upsets something, or simply a host they
|
||||
/// want left exactly as stock ssh leaves it.
|
||||
#[serde(default = "default_true")]
|
||||
pub shell_integration: bool,
|
||||
/// Lines sent verbatim (each + `\n`) to the shell channel after it starts,
|
||||
/// sequentially, with no expect-logic.
|
||||
#[serde(default)]
|
||||
@@ -598,6 +639,7 @@ impl std::fmt::Debug for NativeSshSpec {
|
||||
.field("term", &self.term)
|
||||
.field("verify_host_keys", &self.verify_host_keys)
|
||||
.field("skip_banner", &self.skip_banner)
|
||||
.field("shell_integration", &self.shell_integration)
|
||||
.field("login_script", &self.login_script)
|
||||
.field("display_name", &self.display_name)
|
||||
.field("profile_id", &self.profile_id)
|
||||
@@ -1454,6 +1496,7 @@ mod tests {
|
||||
shell: Some(ShellSpec {
|
||||
program: "wsl.exe".into(),
|
||||
args: vec!["--distribution".into(), "Ubuntu".into()],
|
||||
args_are_tty7_defaults: true,
|
||||
}),
|
||||
},
|
||||
ClientMsg::Attach {
|
||||
@@ -1589,6 +1632,13 @@ mod tests {
|
||||
argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()],
|
||||
target: "dev".into(),
|
||||
})),
|
||||
// A WSL pane's context rides the same wire; `kind` is serialized
|
||||
// kebab-case, so this pins the encoding of the new variant.
|
||||
DaemonMsg::RemoteContext(Some(RemoteContext {
|
||||
kind: RemoteKind::Wsl,
|
||||
argv: Vec::new(),
|
||||
target: "Ubuntu-24.04".into(),
|
||||
})),
|
||||
DaemonMsg::RemoteContext(None),
|
||||
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)),
|
||||
DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)),
|
||||
@@ -1597,7 +1647,13 @@ mod tests {
|
||||
status: crate::core::cli_agent::AgentStatus::Waiting,
|
||||
message: Some("Claude needs your permission to use Bash".into()),
|
||||
session_id: Some("abc-123".into()),
|
||||
launch_argv: Some(vec![
|
||||
"claude".into(),
|
||||
"--dangerously-skip-permissions".into(),
|
||||
]),
|
||||
rich: true,
|
||||
cwd: Some("/repo/.claude/worktrees/fix-x".into()),
|
||||
activity: 12,
|
||||
})),
|
||||
DaemonMsg::AgentStatus(None),
|
||||
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
|
||||
@@ -1748,6 +1804,8 @@ mod tests {
|
||||
let shell = ShellSpec {
|
||||
program: "fish".to_string(),
|
||||
args: vec!["-l".to_string()],
|
||||
// Set so the round-trip covers the flag, not just program + args.
|
||||
args_are_tty7_defaults: true,
|
||||
};
|
||||
let msg = ClientMsg::Spawn {
|
||||
cwd: Some(PathBuf::from("/work")),
|
||||
@@ -1933,6 +1991,7 @@ mod tests {
|
||||
term: "xterm-256color".into(),
|
||||
verify_host_keys: true,
|
||||
skip_banner: false,
|
||||
shell_integration: true,
|
||||
login_script: vec![],
|
||||
display_name: None,
|
||||
profile_id: None,
|
||||
@@ -1956,6 +2015,7 @@ mod tests {
|
||||
term: "xterm-256color".into(),
|
||||
verify_host_keys: true,
|
||||
skip_banner: false,
|
||||
shell_integration: true,
|
||||
login_script: vec!["tmux attach".into()],
|
||||
display_name: Some("prod-web".into()),
|
||||
profile_id: Some("uuid-1".into()),
|
||||
@@ -1979,6 +2039,10 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(spec.term, "xterm-256color"); // defaulted
|
||||
assert!(spec.verify_host_keys); // defaulted true
|
||||
// A spec persisted before shell integration existed must come back
|
||||
// opted *in* — `#[serde(default)]` on a bool would silently turn it off
|
||||
// for every reconnect to a pane saved by an older build.
|
||||
assert!(spec.shell_integration);
|
||||
assert_eq!(spec.password, None);
|
||||
assert!(spec.jump.is_none());
|
||||
}
|
||||
|
||||
@@ -528,6 +528,10 @@ mod tests {
|
||||
/// match — this is what keeps a stale pidfile with a recycled pid from
|
||||
/// getting an innocent process killed. Driven with a real `sleep` child:
|
||||
/// alive, path readable, basename `sleep` ≠ the test binary's.
|
||||
///
|
||||
/// `spawn` returns after the fork, possibly before the child has exec'd —
|
||||
/// until then its executable path still reads as *this* test binary — so
|
||||
/// the path assertions poll until the exec is visible.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn reap_guard_rejects_a_live_process_of_another_executable() {
|
||||
@@ -538,11 +542,19 @@ mod tests {
|
||||
let pid = child.id() as libc::pid_t;
|
||||
|
||||
assert!(process_alive(pid), "the sleep child is alive and ours");
|
||||
assert_eq!(
|
||||
process_path(pid).and_then(|p| p.file_name().map(|n| n.to_os_string())),
|
||||
Some("sleep".into()),
|
||||
"process_path resolves an arbitrary pid, not just our parent"
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let basename = process_path(pid).and_then(|p| p.file_name().map(|n| n.to_os_string()));
|
||||
if basename == Some("sleep".into()) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"process_path resolves an arbitrary pid, not just our parent \
|
||||
(still {basename:?} after 5s)"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
!process_matches_own_exe(pid),
|
||||
"sleep must not match the test binary; matching here would mean the reap could kill it"
|
||||
|
||||
@@ -173,6 +173,10 @@ fn spawn_proxy_command(
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
// The daemon is detached and has no console to lend this child, so without
|
||||
// the flag a `ProxyCommand` (`ssh -W`, `connect.exe`, `cloudflared`) gets a
|
||||
// console of its own that stays up for the whole session.
|
||||
crate::core::proc::hide_console_tokio(&mut cmd);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("spawn ProxyCommand failed: {e}"))?;
|
||||
|
||||
@@ -35,12 +35,13 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::Duration;
|
||||
|
||||
use russh::Pty;
|
||||
use russh::{ChannelMsg, Pty};
|
||||
|
||||
use crate::daemon::protocol::{
|
||||
LoopbackForward, LoopbackForwardId, LoopbackForwardInfo, ManagedForward, NativeSshSpec,
|
||||
SshForwardRule, SshPhase, WinSize,
|
||||
};
|
||||
use crate::daemon::shell_integration::remote;
|
||||
|
||||
use forward::RemoteForwardTable;
|
||||
use handler::ClientHandler;
|
||||
@@ -84,6 +85,11 @@ pub struct SshManager {
|
||||
/// The WS4 managed-forward registry (Local/Remote/Dynamic + native loopback),
|
||||
/// driven on this manager's runtime.
|
||||
forwards: SshForwardRegistry,
|
||||
/// Memoized remote shell-integration probes, keyed like connections. A
|
||||
/// present `None` means "probed, nothing to inject" — cached just as firmly
|
||||
/// as a hit so an unintegrable host isn't re-probed on every new tab. See
|
||||
/// [`SshManager::remote_bootstrap`].
|
||||
probes: Mutex<HashMap<ConnectionKey, Option<(remote::RemoteShell, String)>>>,
|
||||
}
|
||||
|
||||
impl SshManager {
|
||||
@@ -101,6 +107,7 @@ impl SshManager {
|
||||
runtime,
|
||||
conns: Mutex::new(HashMap::new()),
|
||||
forwards: SshForwardRegistry::default(),
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -316,10 +323,29 @@ impl SshManager {
|
||||
let _ = channel.agent_forward(false).await;
|
||||
}
|
||||
|
||||
channel
|
||||
.request_shell(true)
|
||||
.await
|
||||
.map_err(|e| format!("shell request failed: {e}"))?;
|
||||
// Shell integration (OSC 133 + cwd reporting) for the remote shell. When
|
||||
// the remote is one we know how to bootstrap, the shell is started by an
|
||||
// `exec` request carrying a setup script that ends in `exec <shell>`,
|
||||
// rather than by a bare `shell` request; see `shell_integration::remote`.
|
||||
// Anything unrecognized — or a probe that couldn't be run — falls through
|
||||
// to the plain shell request, which is exactly what every session did
|
||||
// before this existed.
|
||||
// Opting out short-circuits the probe too, not just the bootstrap: a
|
||||
// profile with the switch off should cost nothing and touch nothing.
|
||||
let bootstrap = match spec.shell_integration {
|
||||
true => self.remote_bootstrap(&conn).await,
|
||||
false => None,
|
||||
};
|
||||
match bootstrap {
|
||||
Some(script) => channel
|
||||
.exec(true, script)
|
||||
.await
|
||||
.map_err(|e| format!("shell request failed: {e}"))?,
|
||||
None => channel
|
||||
.request_shell(true)
|
||||
.await
|
||||
.map_err(|e| format!("shell request failed: {e}"))?,
|
||||
}
|
||||
|
||||
// Login script: each line verbatim + newline, in order, no expect-logic.
|
||||
for line in &spec.login_script {
|
||||
@@ -341,6 +367,41 @@ impl SshManager {
|
||||
self.conns.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
/// The shell-integration bootstrap script for `conn`'s next shell, or `None`
|
||||
/// to start that shell bare.
|
||||
///
|
||||
/// Deciding costs one `exec` round-trip against the remote (see
|
||||
/// [`probe_remote_shell`]), so the answer is memoized on the connection key —
|
||||
/// the same identity connections are reused under. Opening a second tab to a
|
||||
/// host therefore pays nothing, and a *reconnect* to a host probed earlier
|
||||
/// pays nothing either: which shell a login lands in doesn't change between
|
||||
/// connections, so the cache deliberately outlives them.
|
||||
///
|
||||
/// Two panes racing to a not-yet-probed host may both probe. That is a
|
||||
/// duplicated round-trip on a cold connection, not a correctness problem —
|
||||
/// the probe has no side effects and both arrive at the same answer — so it
|
||||
/// isn't worth serializing every spawn behind a per-key lock the way
|
||||
/// connection establishment is.
|
||||
async fn remote_bootstrap(&self, conn: &Arc<SshConnection>) -> Option<String> {
|
||||
let key = conn.key().clone();
|
||||
let cached = { self.probes.lock().unwrap().get(&key).cloned() };
|
||||
let probed = match cached {
|
||||
Some(hit) => hit,
|
||||
None => {
|
||||
let probed = probe_remote_shell(conn).await;
|
||||
match &probed {
|
||||
Some((shell, path)) => {
|
||||
log::debug!("ssh {key:?}: remote shell {shell:?} at {path}")
|
||||
}
|
||||
None => log::debug!("ssh {key:?}: no remote shell integration"),
|
||||
}
|
||||
self.probes.lock().unwrap().insert(key, probed.clone());
|
||||
probed
|
||||
}
|
||||
};
|
||||
probed.map(|(shell, path)| remote::bootstrap_command(shell, &path))
|
||||
}
|
||||
|
||||
/// Establish (or reuse) the connection for `spec`, recursing through the jump
|
||||
/// chain. Boxed because it is `async`-recursive. The returned `bool` is `true`
|
||||
/// when an existing connection was reused (no fresh authentication) — the
|
||||
@@ -443,6 +504,54 @@ impl SshManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait for the shell probe before giving up on integrating a
|
||||
/// remote. Generous, because the probe runs under the remote's login shell and
|
||||
/// therefore behind whatever its `.zshenv` does; short enough that a host which
|
||||
/// never answers costs a pause, not a hang. Expiring is not an error — the
|
||||
/// session continues with a plain shell.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Cap on probe output, in case the remote's startup files are chatty. Far more
|
||||
/// than the two lines we asked for; a remote that exceeds it has already told us
|
||||
/// everything [`remote::parse_probe`] could use.
|
||||
const PROBE_OUTPUT_LIMIT: usize = 8 * 1024;
|
||||
|
||||
/// Ask the remote which login shell it would start, on a throwaway channel.
|
||||
///
|
||||
/// This is a non-PTY `exec`, so it runs and exits without touching the session
|
||||
/// the user is about to get; nothing here can break that session, and every
|
||||
/// failure path returns `None`, meaning "start the shell bare".
|
||||
///
|
||||
/// stderr is folded in with stdout because the marker-based parse tolerates
|
||||
/// noise, and a remote whose startup files complain on stderr would otherwise
|
||||
/// have its (perfectly good) answer thrown away.
|
||||
async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell, String)> {
|
||||
let mut channel = conn.open_session_channel().await.ok()?;
|
||||
channel.exec(true, remote::PROBE_COMMAND).await.ok()?;
|
||||
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let collect = async {
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => {
|
||||
out.extend_from_slice(&data);
|
||||
if out.len() >= PROBE_OUTPUT_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ChannelMsg::Eof | ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
// A timeout doesn't discard what did arrive: the answer is on the second
|
||||
// line, so a remote that printed it and then stalled before closing the
|
||||
// channel is still perfectly readable.
|
||||
let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await;
|
||||
|
||||
remote::parse_probe(&String::from_utf8_lossy(&out))
|
||||
}
|
||||
|
||||
/// A conservative set of PTY modes for the shell channel — an interactive TTY
|
||||
/// with canonical input, echo, and signal handling on, and standard baud codes.
|
||||
/// The remote line discipline uses these as its starting point.
|
||||
@@ -487,6 +596,7 @@ mod tests {
|
||||
term: "xterm-256color".into(),
|
||||
verify_host_keys: true,
|
||||
skip_banner: false,
|
||||
shell_integration: true,
|
||||
login_script: vec![],
|
||||
display_name: None,
|
||||
profile_id: None,
|
||||
@@ -523,6 +633,7 @@ mod tests {
|
||||
runtime,
|
||||
conns: Mutex::new(HashMap::new()),
|
||||
forwards: SshForwardRegistry::default(),
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
};
|
||||
let key = ConnectionKey::from_spec(&base_spec());
|
||||
mgr.conns
|
||||
|
||||
@@ -246,6 +246,39 @@ fn enrich_path_from_login_shell() {
|
||||
unsafe { std::env::set_var("PATH", merged) };
|
||||
}
|
||||
|
||||
/// A bare (non-bundled) binary — `cargo dev` / `cargo run` — has no
|
||||
/// `Info.plist` pointing the Dock at `tty7.icns`, so macOS shows the generic
|
||||
/// executable icon. Feed the Dock the bundled logo at runtime in that case;
|
||||
/// launches from the real `.app` keep the `.icns` and skip this.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_dock_icon_for_bare_binary() {
|
||||
use objc2::{AnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::{NSApplication, NSImage};
|
||||
use objc2_foundation::NSData;
|
||||
|
||||
let bundled = std::env::current_exe().is_ok_and(|p| {
|
||||
p.components()
|
||||
.any(|c| c.as_os_str().to_string_lossy().ends_with(".app"))
|
||||
});
|
||||
if bundled {
|
||||
return;
|
||||
}
|
||||
// gpui's `run` closure executes on the main thread; bail defensively
|
||||
// rather than panic if that ever stops holding.
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
static ICON_PNG: &[u8] = include_bytes!("../assets/app-icon.png");
|
||||
let data = NSData::with_bytes(ICON_PNG);
|
||||
if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) {
|
||||
// SAFETY: passing a valid NSImage on the main thread; AppKit copies the
|
||||
// reference, no ownership transferred.
|
||||
unsafe {
|
||||
NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&image));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter
|
||||
// Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook
|
||||
@@ -323,6 +356,8 @@ fn main() {
|
||||
gpui_component::init(cx);
|
||||
register_bundled_fonts(cx);
|
||||
cx.activate(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
set_dock_icon_for_bare_binary();
|
||||
// Load user config once and stash it as a global for views to read.
|
||||
cx.set_global(Config::load());
|
||||
// Build the theme registry (built-ins + user theme files) before the
|
||||
|
||||
@@ -222,24 +222,76 @@ impl CmdEditor {
|
||||
self.cursor = self.chars.len();
|
||||
}
|
||||
|
||||
/// Bounds `(start, end)` of the word (run of non-whitespace) containing char
|
||||
/// index `idx`. On whitespace this collapses to `(idx, idx)`.
|
||||
pub fn word_bounds(&self, idx: usize) -> (usize, usize) {
|
||||
/// Bounds `(start, end)` of the word containing char index `idx`: the run
|
||||
/// of chars that are neither whitespace nor in `separators` (the
|
||||
/// configured word-separator set, shared with the grid's semantic
|
||||
/// selection). A separator char is its own one-char word, matching the
|
||||
/// grid; on whitespace the run collapses and the leftward walk snaps to
|
||||
/// the previous word's start.
|
||||
///
|
||||
/// `smart` mirrors `Config::smart_select`: with it off this is exactly
|
||||
/// [`Self::plain_word_bounds`], so the Settings toggle governs the prompt
|
||||
/// editor and the grid alike.
|
||||
pub fn word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
|
||||
let idx = idx.min(self.chars.len());
|
||||
if !smart {
|
||||
return self.plain_word_bounds(idx, separators, smart);
|
||||
}
|
||||
// A bracket or quote selects through its match, same as the grid.
|
||||
// Checked before CJK segmentation so full-width `()`/`“”` pair
|
||||
// instead of being segmented as lone punctuation tokens. Only for
|
||||
// the double-click itself — drags use `plain_word_bounds` so the
|
||||
// selection doesn't lurch when the pointer crosses a quote.
|
||||
if let Some((s, e)) = super::smart_select::pair_range(&self.chars, idx) {
|
||||
return (s, e + 1);
|
||||
}
|
||||
// CJK prose has no separators between words: segment it with the
|
||||
// platform dictionary instead of selecting the whole unbroken run.
|
||||
if let Some(&c) = self.chars.get(idx)
|
||||
&& super::smart_select::is_cjk(c)
|
||||
{
|
||||
let text: String = self.chars.iter().collect();
|
||||
if let Some((s, e)) = super::smart_select::cjk_word_range(&text, idx) {
|
||||
return (s, e + 1);
|
||||
}
|
||||
}
|
||||
self.plain_word_bounds(idx, separators, smart)
|
||||
}
|
||||
|
||||
/// [`Self::word_bounds`] without the pair/segmentation smarts: the plain
|
||||
/// separator-walk word. Used for word-granular drags, where pair matching
|
||||
/// would make the selection jump around as the pointer crosses a quote.
|
||||
/// `smart` still governs the mixed-script narrowing, so a drag matches
|
||||
/// what the double-click that started it selected.
|
||||
fn plain_word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
|
||||
let idx = idx.min(self.chars.len());
|
||||
if let Some(&c) = self.chars.get(idx)
|
||||
&& !c.is_whitespace()
|
||||
&& separators.contains(c)
|
||||
{
|
||||
return (idx, idx + 1);
|
||||
}
|
||||
let boundary = |c: char| c.is_whitespace() || separators.contains(c);
|
||||
let mut s = idx;
|
||||
while s > 0 && !self.chars[s - 1].is_whitespace() {
|
||||
while s > 0 && !boundary(self.chars[s - 1]) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = idx;
|
||||
while e < self.chars.len() && !self.chars[e].is_whitespace() {
|
||||
while e < self.chars.len() && !boundary(self.chars[e]) {
|
||||
e += 1;
|
||||
}
|
||||
// Mixed-script runs (a Latin word glued to CJK text) shrink to the
|
||||
// clicked char's script class — same correction as the grid's.
|
||||
if smart && idx < e {
|
||||
let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1);
|
||||
return (ns, ne + 1);
|
||||
}
|
||||
(s, e)
|
||||
}
|
||||
|
||||
/// Select the word (run of non-whitespace) containing char index `idx`.
|
||||
pub fn select_word_at(&mut self, idx: usize) {
|
||||
let (s, e) = self.word_bounds(idx);
|
||||
/// Select the word containing char index `idx` (see [`Self::word_bounds`]).
|
||||
pub fn select_word_at(&mut self, idx: usize, separators: &str, smart: bool) {
|
||||
let (s, e) = self.word_bounds(idx, separators, smart);
|
||||
self.anchor = Some(s);
|
||||
self.cursor = e;
|
||||
}
|
||||
@@ -249,8 +301,15 @@ impl CmdEditor {
|
||||
/// selection grows by whole words: dragging past the anchor word selects
|
||||
/// forward to the far edge of the word under `idx`, dragging before it selects
|
||||
/// backward to that word's near edge. The cursor sits at the moving edge.
|
||||
pub fn extend_word_to(&mut self, anchor_start: usize, anchor_end: usize, idx: usize) {
|
||||
let (ws, we) = self.word_bounds(idx.min(self.chars.len()));
|
||||
pub fn extend_word_to(
|
||||
&mut self,
|
||||
anchor_start: usize,
|
||||
anchor_end: usize,
|
||||
idx: usize,
|
||||
separators: &str,
|
||||
smart: bool,
|
||||
) {
|
||||
let (ws, we) = self.plain_word_bounds(idx.min(self.chars.len()), separators, smart);
|
||||
if we >= anchor_end {
|
||||
self.anchor = Some(anchor_start);
|
||||
self.cursor = we;
|
||||
@@ -573,15 +632,34 @@ mod tests {
|
||||
assert_eq!(e.selection(), None);
|
||||
}
|
||||
|
||||
/// The default word-separator set (mirrors `Config::word_separators`).
|
||||
const SEPS: &str = ",│`|:\"' ()[]{}<>\t";
|
||||
|
||||
#[test]
|
||||
fn select_word_and_all() {
|
||||
let mut e = ed("git push origin", 6);
|
||||
e.select_word_at(6); // cursor on "push"
|
||||
e.select_word_at(6, SEPS, true); // cursor on "push"
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push"));
|
||||
e.select_all();
|
||||
assert_eq!(e.selection(), Some((0, 15)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_word_stops_at_separators() {
|
||||
// Quotes and commas bound a word; a separator char is its own word.
|
||||
let mut e = ed("echo 'a,b'", 0);
|
||||
e.select_word_at(6, SEPS, true); // on "a"
|
||||
assert_eq!(e.selected_text().as_deref(), Some("a"));
|
||||
e.select_word_at(7, SEPS, true); // on the comma itself
|
||||
assert_eq!(e.selected_text().as_deref(), Some(","));
|
||||
e.select_word_at(5, SEPS, true); // on the opening quote: pairs to the close
|
||||
assert_eq!(e.selected_text().as_deref(), Some("'a,b'"));
|
||||
// `/ . - _ =` are not separators: a path stays one word.
|
||||
let mut e = ed("cat ./a-b/c_d.txt", 0);
|
||||
e.select_word_at(8, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_to_keeps_anchor() {
|
||||
let mut e = ed("abcdef", 2);
|
||||
@@ -595,20 +673,20 @@ mod tests {
|
||||
fn extend_word_to_grows_by_whole_words_both_directions() {
|
||||
// Double-click "push" (chars 4..8), then drag over later/earlier words.
|
||||
let mut e = ed("git push origin main", 4);
|
||||
e.select_word_at(6);
|
||||
e.select_word_at(6, SEPS, true);
|
||||
let (s, a) = e.selection().unwrap(); // (4, 8) == "push"
|
||||
assert_eq!((s, a), (4, 8));
|
||||
|
||||
// Drag forward into "origin": selection reaches that word's far edge.
|
||||
e.extend_word_to(s, a, 10);
|
||||
e.extend_word_to(s, a, 10, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push origin"));
|
||||
// Drag on into "main": grows to its end.
|
||||
e.extend_word_to(s, a, 18);
|
||||
e.extend_word_to(s, a, 18, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push origin main"));
|
||||
|
||||
// Drag backward before the anchor word into "git": anchor flips to the
|
||||
// word's far edge, selection covers "git push".
|
||||
e.extend_word_to(s, a, 1);
|
||||
e.extend_word_to(s, a, 1, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("git push"));
|
||||
}
|
||||
|
||||
@@ -673,15 +751,15 @@ mod tests {
|
||||
// that word — the same left-scan that makes a double-click at the end
|
||||
// of the line select the last word.
|
||||
let mut e = ed("ab cd", 0);
|
||||
e.select_word_at(2); // the space between the words
|
||||
e.select_word_at(2, SEPS, true); // the space between the words
|
||||
assert_eq!(e.selected_text().as_deref(), Some("ab"));
|
||||
// Index at/past the end selects the trailing word, clamped.
|
||||
e.select_word_at(99);
|
||||
e.select_word_at(99, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("cd"));
|
||||
// On a gap wider than one cell there is no adjacent word to the left of
|
||||
// the clicked cell: the empty range collapses to no selection.
|
||||
let mut e = ed("ab cd", 0);
|
||||
e.select_word_at(3); // second space: both neighbours are whitespace
|
||||
e.select_word_at(3, SEPS, true); // second space: both neighbours are whitespace
|
||||
assert_eq!(e.selection(), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -118,10 +118,38 @@ const BUILTINS: &[&str] = &[
|
||||
/// (or `$PATH` entries) can't blow up the UI or the cycle.
|
||||
const MAX_CANDIDATES: usize = 400;
|
||||
|
||||
/// Commands whose arguments are directories, never files. They have no Fig
|
||||
/// signature (shell builtins), so the generic path fallback handles them —
|
||||
/// which must not offer files (`cd tar` completing to `tar.exe` is never
|
||||
/// right).
|
||||
const DIR_ONLY_COMMANDS: &[&str] = &["cd", "pushd", "popd", "rmdir"];
|
||||
|
||||
/// The command name the cursor's word is an argument of: the first token of
|
||||
/// the current simple command (after the last shell separator), reduced to its
|
||||
/// basename so `/bin/rmdir` matches like `rmdir`. `None` when there is no
|
||||
/// command token before the word.
|
||||
fn current_command(chars: &[char], word_start: usize) -> Option<String> {
|
||||
let prefix: String = chars[..word_start].iter().collect();
|
||||
let seg_start = prefix
|
||||
.rfind(['|', '&', ';', '\n', '('])
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let cmd = prefix[seg_start..].split_whitespace().next()?;
|
||||
let base = cmd
|
||||
.rfind(std::path::is_separator)
|
||||
.map(|i| &cmd[i + 1..])
|
||||
.unwrap_or(cmd);
|
||||
(!base.is_empty()).then(|| base.to_string())
|
||||
}
|
||||
|
||||
/// Compute completions for `line` at char position `cursor`, resolving relative
|
||||
/// paths against `cwd`: command names in command position, filesystem paths
|
||||
/// elsewhere. Returns `None` when there's nothing to offer.
|
||||
pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
|
||||
///
|
||||
/// `cwd` is `None` when the pane has no directory on *this* machine — a remote
|
||||
/// pane. Command completion still runs; everything that would touch the local
|
||||
/// filesystem is skipped rather than answered from the wrong machine.
|
||||
pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Completion> {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let cursor = cursor.min(chars.len());
|
||||
|
||||
@@ -141,9 +169,31 @@ pub fn complete(line: &str, cursor: usize, cwd: &Path) -> Option<Completion> {
|
||||
// back to filesystem paths. A signature slot that declares suggestions
|
||||
// or generators owns the position: it returns `Some` (possibly with no
|
||||
// sync candidates but pending scripts) rather than ceding to paths.
|
||||
//
|
||||
// A missing `cwd` means a remote pane, and it disables only the parts
|
||||
// that read *this* machine: paths and generators (see
|
||||
// [`complete_signature`]). The rest of a signature is static text —
|
||||
// `git push`, `--verbose` — and is just as true on the remote, so it is
|
||||
// still offered. Withholding it too would make every Tab in a remote
|
||||
// pane a no-match, and a no-match hands the line to the shell, which
|
||||
// costs the user the inline editor for that prompt.
|
||||
match complete_signature(&chars, word_start, &word, cwd) {
|
||||
Some(sig) => (sig.cands, sig.pending),
|
||||
None => (complete_path(&word, cwd), Vec::new()),
|
||||
None => match cwd {
|
||||
// No signature and no local filesystem to fall back on. Offering
|
||||
// this machine's names would insert them into a remote command
|
||||
// line where they do not exist; returning nothing instead lets
|
||||
// the caller hand the Tab to the remote's own completion, which
|
||||
// can actually see that filesystem.
|
||||
None => (Vec::new(), Vec::new()),
|
||||
// No signature: generic paths, narrowed to directories when
|
||||
// the command only takes those (`cd`, `pushd`, …).
|
||||
Some(cwd) => {
|
||||
let dirs_only = current_command(&chars, word_start)
|
||||
.is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str()));
|
||||
(complete_path(&word, cwd, dirs_only), Vec::new())
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
let candidates: Vec<Candidate> = word_cands
|
||||
@@ -233,7 +283,9 @@ fn sort_candidates_by_closeness(cands: &mut [Candidate]) {
|
||||
/// Filesystem path completion. Splits `word` into the directory part (kept
|
||||
/// verbatim in each candidate so the typed path prefix is preserved) and the
|
||||
/// final-segment prefix to match in that directory. Ordered by closeness.
|
||||
fn complete_path(word: &str, cwd: &Path) -> Vec<WordCand> {
|
||||
/// `dirs_only` drops file entries — for commands / argument slots that only
|
||||
/// accept directories.
|
||||
fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> {
|
||||
// Split on the last path separator. `is_separator` is `/` on Unix and both
|
||||
// `/` and `\` on Windows, so a `C:\Users\me\f`-style word splits correctly
|
||||
// under the (future) Windows line editor; separators are ASCII so the byte
|
||||
@@ -257,7 +309,15 @@ fn complete_path(word: &str, cwd: &Path) -> Vec<WordCand> {
|
||||
if !name.starts_with(prefix) {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
// Follow symlinks when classifying: a symlink to a directory must count
|
||||
// as one (it both takes the trailing `/` and survives a dirs-only
|
||||
// filter — `cd` into a linked dir is routine).
|
||||
let is_dir = entry
|
||||
.file_type()
|
||||
.is_ok_and(|t| t.is_dir() || (t.is_symlink() && entry.path().is_dir()));
|
||||
if dirs_only && !is_dir {
|
||||
continue;
|
||||
}
|
||||
let kind = if is_dir {
|
||||
CandidateKind::Dir
|
||||
} else {
|
||||
@@ -297,11 +357,19 @@ struct SigResult {
|
||||
/// value, suggestion, or generator slot (so a bare argument still lists files).
|
||||
/// A slot with generators returns `Some` even with zero sync candidates — its
|
||||
/// results are still inbound, and falling back to paths there is exactly #51.
|
||||
///
|
||||
/// `cwd` is `None` for a remote pane, which suppresses the two things that would
|
||||
/// answer with *this* machine's state: path completion, and generators. The
|
||||
/// generator exclusion matters more than it looks — a generator is a local
|
||||
/// `/bin/sh -c` (see [`super::generator`]), so `git checkout <Tab>` against a
|
||||
/// remote would offer the branches of whatever repo the *local* cwd happens to
|
||||
/// sit in. Wrong filenames are obvious when they fail; wrong branch names look
|
||||
/// plausible and land in a real command.
|
||||
fn complete_signature(
|
||||
chars: &[char],
|
||||
word_start: usize,
|
||||
word: &str,
|
||||
cwd: &Path,
|
||||
cwd: Option<&Path>,
|
||||
) -> Option<SigResult> {
|
||||
// Only the current simple command matters: start after the last shell
|
||||
// separator so `foo | git <tab>` completes `git`, not `foo`.
|
||||
@@ -345,10 +413,15 @@ fn complete_signature(
|
||||
if let Some(arg) = pending_value {
|
||||
let mut out = Vec::new();
|
||||
push_arg_suggestions(&mut out, arg, word);
|
||||
if arg.wants_paths() {
|
||||
out.extend(complete_path(word, cwd));
|
||||
if let Some(cwd) = cwd {
|
||||
if arg.wants_paths() {
|
||||
out.extend(complete_path(word, cwd, arg.wants_dirs_only()));
|
||||
}
|
||||
}
|
||||
let pending = collect_generators(arg);
|
||||
let pending = match cwd {
|
||||
Some(_) => collect_generators(arg),
|
||||
None => Vec::new(),
|
||||
};
|
||||
// A slot that declares suggestions or generators owns the position even
|
||||
// when nothing matches yet; only a truly featureless value slot cedes to
|
||||
// path completion.
|
||||
@@ -382,10 +455,14 @@ fn complete_signature(
|
||||
let mut claims_slot = false;
|
||||
if let Some(arg) = node.args().first() {
|
||||
push_arg_suggestions(&mut out, arg, word);
|
||||
if arg.wants_paths() {
|
||||
out.extend(complete_path(word, cwd));
|
||||
if let Some(cwd) = cwd {
|
||||
if arg.wants_paths() {
|
||||
out.extend(complete_path(word, cwd, arg.wants_dirs_only()));
|
||||
}
|
||||
}
|
||||
if cwd.is_some() {
|
||||
pending = collect_generators(arg);
|
||||
}
|
||||
pending = collect_generators(arg);
|
||||
// Suggestions/generators mean this positional owns the slot: don't cede
|
||||
// to paths just because the sync list came back empty.
|
||||
claims_slot = !arg.suggestions.is_empty() || !pending.is_empty();
|
||||
@@ -688,7 +765,7 @@ mod tests {
|
||||
/// The candidate texts `complete` returns for `line` with the cursor at the
|
||||
/// end, or an empty vec when it offers nothing.
|
||||
fn texts(line: &str) -> Vec<String> {
|
||||
complete(line, line.chars().count(), Path::new("/"))
|
||||
complete(line, line.chars().count(), Some(Path::new("/")))
|
||||
.map(|c| c.candidates.into_iter().map(|c| c.text).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -699,7 +776,7 @@ mod tests {
|
||||
assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}");
|
||||
assert!(t.iter().any(|s| s == "status"));
|
||||
// Descriptions ride along for the menu's second column.
|
||||
let c = complete("git ", 4, Path::new("/")).unwrap();
|
||||
let c = complete("git ", 4, Some(Path::new("/"))).unwrap();
|
||||
let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap();
|
||||
assert_eq!(commit.kind, CandidateKind::Value);
|
||||
assert!(commit.description.is_some());
|
||||
@@ -714,7 +791,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn signature_offers_flags_for_the_active_subcommand() {
|
||||
let c = complete("git commit --", 13, Path::new("/")).unwrap();
|
||||
let c = complete("git commit --", 13, Some(Path::new("/"))).unwrap();
|
||||
let msg = c.candidates.iter().find(|c| c.text == "--message").unwrap();
|
||||
assert_eq!(msg.kind, CandidateKind::Flag);
|
||||
assert_eq!(
|
||||
@@ -741,7 +818,8 @@ mod tests {
|
||||
// position — it returns the generator scripts and no path candidates.
|
||||
let dir = temp_tree("gen-checkout", &[("sentinel.txt", false), ("subdir", true)]);
|
||||
let line = "git checkout ";
|
||||
let c = complete(line, line.chars().count(), &dir).expect("generator slot is a completion");
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path()))
|
||||
.expect("generator slot is a completion");
|
||||
assert!(
|
||||
!c.pending.is_empty(),
|
||||
"the branch/tag generators ride along as pending scripts"
|
||||
@@ -767,7 +845,7 @@ mod tests {
|
||||
fn generator_script_tokens_join_with_single_spaces() {
|
||||
// The converter word-split original string scripts; joining restores a
|
||||
// single `/bin/sh -c` command.
|
||||
let c = complete("git checkout ", 13, Path::new("/")).unwrap();
|
||||
let c = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
|
||||
let branch = c
|
||||
.pending
|
||||
.iter()
|
||||
@@ -821,11 +899,39 @@ mod tests {
|
||||
assert_eq!(s.selected().unwrap().text, "branch-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_only_commands_complete_only_directories() {
|
||||
// `cd tar` must offer `target/`, never `tar.gz` (#136) — same for the
|
||||
// other dir-only builtins, and for absolute spellings by basename.
|
||||
let dir = temp_tree("dironly", &[("target", true), ("tar.gz", false)]);
|
||||
let only_dirs = |line: &str| {
|
||||
complete(line, line.chars().count(), Some(dir.as_path()))
|
||||
.map(|c| c.candidates.into_iter().map(|c| c.text).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(only_dirs("cd tar"), vec!["target"]);
|
||||
assert_eq!(only_dirs("pushd tar"), vec!["target"]);
|
||||
assert_eq!(only_dirs("/bin/rmdir tar"), vec!["target"]);
|
||||
// Only the current simple command counts: `cd` after a pipe governs.
|
||||
assert_eq!(only_dirs("foo | cd tar"), vec!["target"]);
|
||||
// A bare argument slot narrows too.
|
||||
assert_eq!(only_dirs("cd "), vec!["target"]);
|
||||
// A generic command keeps offering files alongside directories.
|
||||
let both = only_dirs("frobnicate tar");
|
||||
assert!(both.contains(&"tar.gz".to_string()), "{both:?}");
|
||||
assert!(both.contains(&"target".to_string()), "{both:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_falls_back_to_paths() {
|
||||
// A command with no signature still path-completes (no panic, no menu here).
|
||||
let dir = temp_tree("fallback", &[("readme.md", false)]);
|
||||
let c = complete("frobnicate read", "frobnicate read".chars().count(), &dir).unwrap();
|
||||
let c = complete(
|
||||
"frobnicate read",
|
||||
"frobnicate read".chars().count(),
|
||||
Some(dir.as_path()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c.candidates[0].text, "readme.md");
|
||||
}
|
||||
|
||||
@@ -925,7 +1031,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn command_position_offers_builtins_with_word_range() {
|
||||
let c = complete("ech", 3, Path::new("/")).unwrap();
|
||||
let c = complete("ech", 3, Some(Path::new("/"))).unwrap();
|
||||
let echo = c.candidates.iter().find(|c| c.text == "echo").unwrap();
|
||||
assert_eq!(echo.kind, CandidateKind::Command);
|
||||
assert_eq!((echo.start, echo.end), (0, 3)); // replaces the word "ech"
|
||||
@@ -938,7 +1044,7 @@ mod tests {
|
||||
&[("apple.txt", false), ("apply.sh", false), ("assets", true)],
|
||||
);
|
||||
let line = "cat a";
|
||||
let c = complete(line, line.chars().count(), &dir).unwrap();
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
|
||||
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
// Closeness order: assets(6) < apply.sh(8) < apple.txt(9).
|
||||
assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]);
|
||||
@@ -952,7 +1058,7 @@ mod tests {
|
||||
let dir = temp_tree("nested", &[("sub", true)]);
|
||||
std::fs::write(dir.join("sub/file.rs"), b"").unwrap();
|
||||
let line = "cat sub/f";
|
||||
let c = complete(line, line.chars().count(), &dir).unwrap();
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
|
||||
assert_eq!(c.candidates[0].text, "sub/file.rs");
|
||||
assert_eq!(c.candidates[0].start, 4);
|
||||
}
|
||||
@@ -960,9 +1066,9 @@ mod tests {
|
||||
#[test]
|
||||
fn hidden_files_only_with_dot_prefix() {
|
||||
let dir = temp_tree("hidden", &[(".secret", false), ("visible", false)]);
|
||||
let c = complete("ls v", 4, &dir).unwrap();
|
||||
let c = complete("ls v", 4, Some(dir.as_path())).unwrap();
|
||||
assert!(c.candidates.iter().all(|c| !c.text.starts_with('.')));
|
||||
let c = complete("ls .", 4, &dir).unwrap();
|
||||
let c = complete("ls .", 4, Some(dir.as_path())).unwrap();
|
||||
assert!(c.candidates.iter().any(|c| c.text == ".secret"));
|
||||
}
|
||||
|
||||
@@ -978,18 +1084,87 @@ mod tests {
|
||||
],
|
||||
);
|
||||
let line = "cat x";
|
||||
let c = complete(line, line.chars().count(), &dir).unwrap();
|
||||
let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap();
|
||||
let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]);
|
||||
}
|
||||
|
||||
/// A remote pane has no local cwd. Path candidates must come back empty
|
||||
/// rather than from tty7's own directory — inserting a local filename into
|
||||
/// a remote command line names a file that isn't there. Command completion
|
||||
/// is unaffected: it reads `$PATH`, not the cwd.
|
||||
#[test]
|
||||
fn a_remote_pane_completes_commands_but_never_local_paths() {
|
||||
let dir = temp_tree("remote", &[("only-here.txt", false), ("subdir", true)]);
|
||||
|
||||
// With a local cwd the file is offered...
|
||||
let c = complete("cat only", 8, Some(dir.as_path())).expect("local pane completes paths");
|
||||
assert!(c.candidates.iter().any(|c| c.text.starts_with("only-here")));
|
||||
|
||||
// ...and with none it is not, from the same line.
|
||||
assert!(complete("cat only", 8, None).is_none());
|
||||
// Nor does a bare argument position dump anything.
|
||||
assert!(complete("cat ", 4, None).is_none());
|
||||
|
||||
// Command position still works — that source never touches the cwd.
|
||||
let c = complete("ech", 3, None).expect("command completion needs no cwd");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "echo"));
|
||||
}
|
||||
|
||||
/// The static half of a signature — subcommands, flags — describes the
|
||||
/// *command*, not the machine, so it survives the loss of a local cwd. This
|
||||
/// is what keeps Tab useful in a remote pane: a position with no candidates
|
||||
/// hands the line to the shell (`handoff_tab_to_shell`), which costs the
|
||||
/// user the inline editor until the next prompt, so answering "nothing" for
|
||||
/// every `git <Tab>` was a real regression once remote panes gained an
|
||||
/// editor at all.
|
||||
#[test]
|
||||
fn a_remote_pane_still_gets_a_signatures_static_candidates() {
|
||||
let c = complete("git ", 4, None).expect("subcommands need no filesystem");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "commit"));
|
||||
assert!(c.candidates.iter().any(|c| c.text == "push"));
|
||||
|
||||
// Prefix filtering works the same as it does locally.
|
||||
let c = complete("git ch", 6, None).expect("subcommands need no filesystem");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "checkout"));
|
||||
assert!(!c.candidates.iter().any(|c| c.text == "commit"));
|
||||
|
||||
// Flags too.
|
||||
let c = complete("git commit --", 13, None).expect("flags need no filesystem");
|
||||
assert!(c.candidates.iter().any(|c| c.text == "--message"));
|
||||
}
|
||||
|
||||
/// Generators are local `/bin/sh -c` child processes, so against a remote
|
||||
/// they would answer with this machine's state — `git checkout <Tab>`
|
||||
/// offering the branches of whatever repo tty7's own cwd sits in. Unlike a
|
||||
/// wrong filename, a wrong branch name is plausible enough to be accepted.
|
||||
#[test]
|
||||
fn a_remote_pane_never_runs_a_generator() {
|
||||
// Locally this slot is generator-owned (the branch list).
|
||||
let local = complete("git checkout ", 13, Some(Path::new("/"))).unwrap();
|
||||
assert!(
|
||||
!local.pending.is_empty(),
|
||||
"expected the local branch generator to still be declared"
|
||||
);
|
||||
|
||||
// Remotely the same slot may keep its static candidates, but must not
|
||||
// schedule a single script.
|
||||
if let Some(remote) = complete("git checkout ", 13, None) {
|
||||
assert!(
|
||||
remote.pending.is_empty(),
|
||||
"a remote pane scheduled local generators: {:?}",
|
||||
remote.pending
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidates_returns_none() {
|
||||
let dir = temp_tree("empty", &[("zzz", false)]);
|
||||
assert!(complete("cat q", 5, &dir).is_none());
|
||||
assert!(complete("cat q", 5, Some(dir.as_path())).is_none());
|
||||
// A blank line offers nothing (no dump of every command on bare Tab).
|
||||
assert!(complete("", 0, &dir).is_none());
|
||||
assert!(complete(" ", 3, &dir).is_none());
|
||||
assert!(complete("", 0, Some(dir.as_path())).is_none());
|
||||
assert!(complete(" ", 3, Some(dir.as_path())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -997,7 +1172,7 @@ mod tests {
|
||||
// Caret sits right after "ap" with more text following; the candidate
|
||||
// replaces only `word_start..cursor`, leaving the tail untouched.
|
||||
let dir = temp_tree("midline", &[("apple.txt", false)]);
|
||||
let c = complete("cat ap x.log", 6, &dir).unwrap();
|
||||
let c = complete("cat ap x.log", 6, Some(dir.as_path())).unwrap();
|
||||
let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap();
|
||||
assert_eq!((apple.start, apple.end), (4, 6));
|
||||
// Applying it splices over just that range.
|
||||
|
||||
@@ -1209,7 +1209,7 @@ impl TerminalElement {
|
||||
return;
|
||||
}
|
||||
if button == MouseButton::Left {
|
||||
v.on_select_start(col, row, left, clicks, cx);
|
||||
v.on_select_start(col, row, left, clicks, mods.shift, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A repo's git snapshot: the branch it's on and how much the working tree has
|
||||
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
|
||||
@@ -62,39 +63,46 @@ pub fn probe(cwd: &Path) -> Option<RepoSnapshot> {
|
||||
if !cwd.exists() {
|
||||
return None;
|
||||
}
|
||||
// Doubles as the "is this a git repo" gate: fails outside a work tree.
|
||||
let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
|
||||
// One `rev-parse` answers every path question at once: the work-tree root
|
||||
// (which doubles as the "is this a git repo" gate — it fails outside a
|
||||
// work tree) plus the git-dir/common-dir pair that tells a linked worktree
|
||||
// from a main checkout. Asking separately cost two process spawns per
|
||||
// probe, which mattered once probes stopped being rare: they now also fire
|
||||
// on window activation and on an agent's tool calls, across every pane.
|
||||
let paths = git(
|
||||
cwd,
|
||||
&[
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--show-toplevel",
|
||||
"--git-dir",
|
||||
"--git-common-dir",
|
||||
],
|
||||
)?;
|
||||
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
|
||||
let root = PathBuf::from(lines.next()?);
|
||||
// A git old enough to reject `--path-format` fails the whole invocation
|
||||
// above, so reaching here means the two dirs are present — but degrade to
|
||||
// "main checkout" rather than trusting that, same as the old code did.
|
||||
let home = repo_home(&root, lines.next(), lines.next());
|
||||
let branch = branch_name(cwd)?;
|
||||
Some(RepoSnapshot {
|
||||
home: repo_home(cwd, &root),
|
||||
home,
|
||||
root,
|
||||
branch,
|
||||
counts: diff_numstat(cwd),
|
||||
})
|
||||
}
|
||||
|
||||
/// The repository "home" every checkout of one repo shares: for a linked
|
||||
/// worktree (its git dir differs from the common git dir) the main work
|
||||
/// tree's root — the parent of `<main>/.git`; for the main checkout itself,
|
||||
/// a submodule, or any failure to tell, the work-tree root unchanged. A bare
|
||||
/// common dir (no trailing `.git` component, the bare-repo-plus-worktrees
|
||||
/// layout) anchors on the bare directory itself — still one shared key.
|
||||
fn repo_home(cwd: &Path, root: &Path) -> PathBuf {
|
||||
let both = git(
|
||||
cwd,
|
||||
&[
|
||||
"rev-parse",
|
||||
"--path-format=absolute",
|
||||
"--git-dir",
|
||||
"--git-common-dir",
|
||||
],
|
||||
);
|
||||
let Some(both) = both else {
|
||||
return root.to_path_buf();
|
||||
};
|
||||
let mut lines = both.lines();
|
||||
let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
|
||||
/// The repository "home" every checkout of one repo shares, from the work-tree
|
||||
/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree
|
||||
/// (its git dir differs from the common git dir) the main work tree's root —
|
||||
/// the parent of `<main>/.git`; for the main checkout itself, a submodule, or
|
||||
/// any failure to tell, the work-tree root unchanged. A bare common dir (no
|
||||
/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on
|
||||
/// the bare directory itself — still one shared key.
|
||||
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
|
||||
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
|
||||
return root.to_path_buf();
|
||||
};
|
||||
if git_dir == common {
|
||||
@@ -133,6 +141,9 @@ pub struct GitStatusCache {
|
||||
/// In-flight cwds re-triggered meanwhile — reprobed once their flight
|
||||
/// lands, so the newest trigger's state is never skipped.
|
||||
dirty: HashSet<PathBuf>,
|
||||
/// When each cwd's last probe *landed*, for the throttle that opportunistic
|
||||
/// triggers go through ([`begin_probe_throttled`](Self::begin_probe_throttled)).
|
||||
last_probe: HashMap<PathBuf, Instant>,
|
||||
}
|
||||
|
||||
impl gpui::Global for GitStatusCache {}
|
||||
@@ -177,12 +188,75 @@ impl GitStatusCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim an *opportunistic* probe for `cwd`: one triggered by a cheap,
|
||||
/// frequent signal — the window regaining focus, an agent finishing a tool
|
||||
/// call — rather than by a rare edge like a command ending.
|
||||
///
|
||||
/// Unlike [`begin_probe`](Self::begin_probe) this declines instead of
|
||||
/// queueing: a probe already in flight, or one against a repo probed less
|
||||
/// than `min_interval` ago, drops the trigger entirely (no dirty mark, no
|
||||
/// rerun). That's the whole point of the two entry points — the rare edges
|
||||
/// must never be missed, while these signals repeat on their own, so a
|
||||
/// count that's a second stale beats a `git` storm across every pane of a
|
||||
/// repo the moment the user alt-tabs back.
|
||||
///
|
||||
/// The throttle counts per *repo*, not per cwd (see
|
||||
/// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock
|
||||
/// rather than waiting for the landing: without that, a dozen panes
|
||||
/// scattered over one repo's subdirectories would all claim in the same
|
||||
/// instant — each of them passing a throttle no probe had answered yet —
|
||||
/// and produce a dozen identical full-repo diffs.
|
||||
pub fn begin_probe_throttled(&mut self, cwd: &Path, min_interval: Duration) -> bool {
|
||||
if self.in_flight.contains(cwd) {
|
||||
return false;
|
||||
}
|
||||
let key = self.throttle_key(cwd).to_path_buf();
|
||||
if self
|
||||
.last_probe
|
||||
.get(&key)
|
||||
.is_some_and(|at| at.elapsed() < min_interval)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.last_probe.insert(key, Instant::now());
|
||||
self.in_flight.insert(cwd.to_path_buf());
|
||||
true
|
||||
}
|
||||
|
||||
/// What the opportunistic throttle counts against: the work-tree root once
|
||||
/// some probe has answered for `cwd`, and `cwd` itself before that.
|
||||
///
|
||||
/// The counts a probe produces are repo-wide — `git diff --numstat HEAD`
|
||||
/// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src`
|
||||
/// and `repo/docs` are three ways of asking one question, and want one
|
||||
/// shared clock rather than one each. In-flight dedup stays keyed by cwd:
|
||||
/// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe)
|
||||
/// has to be able to release exactly what was claimed.
|
||||
///
|
||||
/// Before any probe has landed the root is simply unknown, so the first
|
||||
/// sweep over a repo still costs one probe per distinct cwd; every sweep
|
||||
/// after that collapses to one.
|
||||
fn throttle_key<'a>(&'a self, cwd: &'a Path) -> &'a Path {
|
||||
match self.roots.get(cwd) {
|
||||
Some(Some(root)) => root,
|
||||
_ => cwd,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a landed probe for `cwd` into the cache. A failed diff inside a
|
||||
/// live repo keeps the root's previous counts (a transient `git` error is
|
||||
/// not "the tree went clean"). Returns whether the cwd was re-triggered
|
||||
/// while this probe flew — the caller should start one more probe.
|
||||
pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option<RepoSnapshot>) -> bool {
|
||||
self.in_flight.remove(cwd);
|
||||
// Re-stamp on landing so the gap is measured from fresh counts, and
|
||||
// under the root this probe just resolved — which is how a cwd first
|
||||
// learns to share its repo's clock (at claim time it had none).
|
||||
let key = match &snapshot {
|
||||
Some(snap) => snap.root.clone(),
|
||||
None => self.throttle_key(cwd).to_path_buf(),
|
||||
};
|
||||
self.last_probe.insert(key, Instant::now());
|
||||
match snapshot {
|
||||
Some(snap) => {
|
||||
let (added, removed) = snap.counts.unwrap_or_else(|| {
|
||||
@@ -251,18 +325,19 @@ fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> {
|
||||
/// Run `git -C <cwd> <args>` and return stdout on success, `None` on a
|
||||
/// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read
|
||||
/// truly read-only; stdin is nulled so a misconfigured git can't block on a
|
||||
/// prompt. Shared with [`git_diff`](crate::terminal::git_diff) so every git
|
||||
/// read in the app goes through the same lock-free, prompt-proof invocation.
|
||||
/// prompt; `hide_console` keeps this GUI process from flashing a console window
|
||||
/// on Windows for every probe. Shared with [`git_diff`](crate::terminal::git_diff)
|
||||
/// so every git read in the app goes through the same lock-free, prompt-proof
|
||||
/// invocation.
|
||||
pub(crate) fn git(cwd: &Path, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C")
|
||||
.arg(cwd)
|
||||
.args(args)
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.ok()?;
|
||||
.stderr(Stdio::null());
|
||||
let out = crate::core::proc::hide_console(&mut cmd).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
@@ -421,4 +496,97 @@ mod tests {
|
||||
assert_eq!(cache.status_for(main).unwrap().branch, "main");
|
||||
assert_eq!(cache.status_for(wt).unwrap().branch, "feat/x");
|
||||
}
|
||||
|
||||
/// The four shapes `repo_home` has to tell apart, straight from the
|
||||
/// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns.
|
||||
#[test]
|
||||
fn repo_home_resolves_worktree_layouts() {
|
||||
let root = Path::new("/repo/.wt/feat");
|
||||
|
||||
// A main checkout: the two dirs agree, so the work tree is its own home.
|
||||
assert_eq!(
|
||||
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A linked worktree: the common dir is the main checkout's `.git`, so
|
||||
// the home is that `.git`'s parent — the main work tree.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
|
||||
PathBuf::from("/repo")
|
||||
);
|
||||
// A bare repo with worktrees hanging off it: no `.git` component to
|
||||
// strip, so the bare dir itself is the shared key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
|
||||
PathBuf::from("/bare.git")
|
||||
);
|
||||
// A git too old (or too odd) to answer both: degrade to the work tree
|
||||
// rather than guessing a grouping key.
|
||||
assert_eq!(
|
||||
repo_home(root, Some("/repo/.git"), None),
|
||||
root.to_path_buf()
|
||||
);
|
||||
assert_eq!(repo_home(root, None, None), root.to_path_buf());
|
||||
}
|
||||
|
||||
/// The opportunistic path declines where the edge path queues: an in-flight
|
||||
/// probe drops the trigger (and leaves nothing dirty, so no rerun), and a
|
||||
/// probe that just landed rate-limits the next one.
|
||||
#[test]
|
||||
fn throttled_probes_decline_instead_of_queueing() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let cwd = Path::new("/repo");
|
||||
let gap = Duration::from_secs(60);
|
||||
|
||||
assert!(cache.begin_probe_throttled(cwd, gap));
|
||||
// In flight: declined, and unlike `begin_probe` it doesn't mark dirty —
|
||||
// the landing reports "nothing pending" rather than asking for a rerun.
|
||||
assert!(!cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
|
||||
// Landed just now: still inside the gap, so the next trigger is dropped.
|
||||
assert!(!cache.begin_probe_throttled(cwd, gap));
|
||||
// …but a zero gap always lets one through, and edge triggers never
|
||||
// consult the throttle at all.
|
||||
assert!(cache.begin_probe_throttled(cwd, Duration::ZERO));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
|
||||
assert!(cache.begin_probe(cwd));
|
||||
}
|
||||
|
||||
/// The throttle is per repo, not per cwd: panes sitting in different
|
||||
/// subdirectories ask one question (the counts are repo-wide), so once the
|
||||
/// cache knows where they live, a window activation costs one probe for
|
||||
/// the repo rather than one per pane.
|
||||
#[test]
|
||||
fn throttle_collapses_subdirectories_of_one_repo() {
|
||||
let mut cache = GitStatusCache::default();
|
||||
let (top, src, docs) = (
|
||||
Path::new("/repo"),
|
||||
Path::new("/repo/src"),
|
||||
Path::new("/repo/docs"),
|
||||
);
|
||||
let gap = Duration::from_secs(60);
|
||||
|
||||
// Nothing known yet, so each cwd is its own key and each gets a probe.
|
||||
for cwd in [top, src, docs] {
|
||||
assert!(cache.begin_probe_throttled(cwd, gap));
|
||||
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((3, 1))))));
|
||||
}
|
||||
|
||||
// Now all three resolve to `/repo`, so the next sweep collapses: the
|
||||
// first pane to ask spends the probe and the rest ride on it.
|
||||
assert!(!cache.begin_probe_throttled(top, gap));
|
||||
assert!(!cache.begin_probe_throttled(src, gap));
|
||||
|
||||
// …and the claim itself is what stops the stampede — with the clock
|
||||
// wound back far enough to let one through, the *others* still decline
|
||||
// while it is in flight, even though nothing has landed yet.
|
||||
assert!(cache.begin_probe_throttled(docs, Duration::ZERO));
|
||||
assert!(!cache.begin_probe_throttled(top, gap));
|
||||
assert!(!cache.begin_probe_throttled(src, gap));
|
||||
|
||||
// A pane elsewhere is untouched by any of it.
|
||||
let other = Path::new("/other");
|
||||
assert!(cache.begin_probe_throttled(other, gap));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,51 @@ pub(super) fn reshape_option_keystroke(
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a keystroke is ordinary text that macOS should deliver through the
|
||||
/// input context (`insertText:` → `replace_text_in_range` → `commit_text`)
|
||||
/// rather than the raw `key_char` path.
|
||||
///
|
||||
/// gpui derives `key_char` by running the event's *virtual keycode* back through
|
||||
/// the current layout (`chars_for_modified_key` in its macOS backend); it never
|
||||
/// reads the event's Unicode payload. That is fine for a physical keyboard, where
|
||||
/// the keycode is the truth, but wrong for any event whose text lives only in the
|
||||
/// payload — notably remote-control apps, which synthesize keystrokes as
|
||||
/// `CGEventCreateKeyboardEvent(src, 0, …)` + `CGEventKeyboardSetUnicodeString()`.
|
||||
/// Keycode 0 is `a`, so every remotely typed character arrived as `a`.
|
||||
///
|
||||
/// gpui already diverts printable keys to the input context, but only while a
|
||||
/// composing input source is active (`is_ime_input_source_active`), so the bug
|
||||
/// appeared and vanished depending on which input method was selected — and the
|
||||
/// plain ABC layout, the macOS default, always lost the text. Declining the key
|
||||
/// here instead makes the IME the single delivery path for text on macOS: gpui
|
||||
/// falls through to `handleEvent:`, and the Unicode payload survives.
|
||||
///
|
||||
/// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below,
|
||||
/// and Option is owned by [`reshape_option_keystroke`]'s Meta policy.
|
||||
///
|
||||
/// REPORT_ALL_KEYS_AS_ESC is excluded too: it asks for every key as
|
||||
/// `CSI <code>;<mods>[;<text>]u`, and the IME path terminates in
|
||||
/// `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. Under that
|
||||
/// mode text keys must stay on the [`keystroke_to_bytes`] path so they get
|
||||
/// encoded. Disambiguate-only sessions are unaffected — [`encode_kitty`]
|
||||
/// declines unmodified text keys there, so the IME route is equivalent.
|
||||
///
|
||||
/// Compiled under `test` on every platform so the routing rule is covered by
|
||||
/// CI everywhere, not just on the macOS runner.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool {
|
||||
if kitty.report_all_keys {
|
||||
return false;
|
||||
}
|
||||
let m = &ks.modifiers;
|
||||
if m.control || m.platform || m.function || m.alt {
|
||||
return false;
|
||||
}
|
||||
ks.key_char
|
||||
.as_deref()
|
||||
.is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}'))
|
||||
}
|
||||
|
||||
/// Translate a GPUI keystroke into the bytes a PTY expects.
|
||||
///
|
||||
/// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try
|
||||
@@ -522,7 +567,26 @@ impl InputHandler for TerminalInputHandler {
|
||||
None
|
||||
}
|
||||
|
||||
fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, _cx: &mut App) -> bool {
|
||||
fn apple_press_and_hold_enabled(&mut self) -> bool {
|
||||
// A terminal wants auto-repeat, not the accent palette: holding `j` in
|
||||
// vim scrolls, it does not offer `ĵ`. This used to be moot because
|
||||
// `on_key_down` consumed printable keys before gpui consulted it; now
|
||||
// that text defers to the IME (see `defer_to_ime`), gpui reaches its
|
||||
// held-key branch, and answering `false` there makes it repeat the
|
||||
// character instead of handing the key to press-and-hold.
|
||||
false
|
||||
}
|
||||
|
||||
fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool {
|
||||
// REPORT_ALL_KEYS_AS_ESC wants every key as `CSI <code>;<mods>[;<text>]u`,
|
||||
// which only `keystroke_to_bytes` produces — the IME path commits raw
|
||||
// UTF-8. Keep printable keys on the dispatch path so they get encoded,
|
||||
// matching the same gate in `on_key_down`. CJK composition and "escape
|
||||
// every key" are mutually exclusive by construction; an app that asks
|
||||
// for the latter gets it.
|
||||
if self.view.read(cx).kitty_flags().report_all_keys {
|
||||
return false;
|
||||
}
|
||||
// While a multi-key keybinding is mid-sequence — e.g. the tmux preset's
|
||||
// `ctrl-b` prefix is held pending — the next key belongs to the keymap,
|
||||
// not the IME. macOS otherwise diverts printable keys straight to the IME
|
||||
@@ -554,9 +618,29 @@ impl InputHandler for TerminalInputHandler {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{KittyFlags, keystroke_to_bytes, reshape_option_keystroke, tab_bytes};
|
||||
use super::{
|
||||
KittyFlags, defer_to_ime, keystroke_to_bytes, reshape_option_keystroke, tab_bytes,
|
||||
};
|
||||
use gpui::{Keystroke, Modifiers};
|
||||
|
||||
/// Kitty full mode: every key escaped, with the produced text attached.
|
||||
fn full_mode() -> KittyFlags {
|
||||
KittyFlags {
|
||||
disambiguate: true,
|
||||
report_all_keys: true,
|
||||
report_text: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Level 1 only — the mode a shell leaves on after a TUI exits.
|
||||
fn disambiguate_only() -> KittyFlags {
|
||||
KittyFlags {
|
||||
disambiguate: true,
|
||||
report_all_keys: false,
|
||||
report_text: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy call shape used by the pre-existing tests: encode with the Kitty
|
||||
/// protocol off, exercising exactly the byte output shells see by default.
|
||||
fn legacy(ks: &Keystroke) -> Option<Vec<u8>> {
|
||||
@@ -571,6 +655,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_defers_to_the_ime_unless_kitty_wants_every_key() {
|
||||
let plain = Modifiers::default();
|
||||
let a = ks(plain, "a", Some("a"));
|
||||
|
||||
// Default and disambiguate-only: text belongs to the IME, which is the
|
||||
// only path that carries a synthesized event's real Unicode payload.
|
||||
assert!(defer_to_ime(&a, KittyFlags::default()));
|
||||
assert!(defer_to_ime(&a, disambiguate_only()));
|
||||
|
||||
// Full mode: the IME commits raw UTF-8, so deferring would drop the
|
||||
// `CSI 97;1;97u` the app negotiated for. Stay on the encoder path.
|
||||
assert!(!defer_to_ime(&a, full_mode()));
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&a, full_mode()),
|
||||
Some(b"\x1b[97;1;97u".to_vec()),
|
||||
);
|
||||
|
||||
// Space is text too, and follows the same rule.
|
||||
let space = ks(plain, "space", Some(" "));
|
||||
assert!(defer_to_ime(&space, KittyFlags::default()));
|
||||
assert!(!defer_to_ime(&space, full_mode()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shifted_text_follows_the_same_ime_rule() {
|
||||
let shift = Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
let upper = ks(shift, "a", Some("A"));
|
||||
assert!(defer_to_ime(&upper, KittyFlags::default()));
|
||||
assert!(!defer_to_ime(&upper, full_mode()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_text_keys_never_defer_to_the_ime() {
|
||||
let plain = Modifiers::default();
|
||||
// No `key_char` at all — arrows, F-keys, backspace, escape.
|
||||
assert!(!defer_to_ime(
|
||||
&ks(plain, "left", None),
|
||||
KittyFlags::default()
|
||||
));
|
||||
assert!(!defer_to_ime(
|
||||
&ks(plain, "backspace", None),
|
||||
KittyFlags::default()
|
||||
));
|
||||
// Control chars are filtered even when a `key_char` is present.
|
||||
assert!(!defer_to_ime(
|
||||
&ks(plain, "enter", Some("\n")),
|
||||
KittyFlags::default()
|
||||
));
|
||||
assert!(!defer_to_ime(
|
||||
&ks(plain, "tab", Some("\t")),
|
||||
KittyFlags::default()
|
||||
));
|
||||
// Chords belong to the encoders, not the IME.
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!defer_to_ime(
|
||||
&ks(ctrl, "c", Some("c")),
|
||||
KittyFlags::default()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keystroke_to_bytes_maps_control_letters() {
|
||||
let ctrl = Modifiers {
|
||||
|
||||
@@ -35,6 +35,7 @@ mod reverse_search;
|
||||
pub mod search;
|
||||
mod signature;
|
||||
mod size;
|
||||
mod smart_select;
|
||||
mod typeahead;
|
||||
pub mod view;
|
||||
|
||||
|
||||
@@ -100,6 +100,14 @@ struct ShellState {
|
||||
/// back) from the stale pre-submit state — even when 1 Hz polling misses
|
||||
/// the intermediate not-at-prompt window of a fast command.
|
||||
seq: u64,
|
||||
/// Monotonic count of *entered-prompt edges*: bumped only when a report
|
||||
/// flips `at_prompt` false → true. Unlike `seq` it ignores same-prompt
|
||||
/// redraws — prompt frameworks re-emit the PS1-embedded `133;B` on every
|
||||
/// `reset-prompt` / completion-list reprint, and each re-emission is
|
||||
/// another `Prompt` frame. The Tab handoff keys its release off this
|
||||
/// (see `TerminalView::editor_handoff`): only a command actually running
|
||||
/// (`133;C` → not-at-prompt) starts a new cycle.
|
||||
cycle: u64,
|
||||
}
|
||||
|
||||
/// The shared handles the reader thread writes into as daemon frames arrive;
|
||||
@@ -670,6 +678,8 @@ impl RemoteTerminal {
|
||||
at_prompt,
|
||||
last_exit,
|
||||
seq: guard.seq + 1,
|
||||
cycle: guard.cycle
|
||||
+ u64::from(at_prompt && !guard.at_prompt),
|
||||
};
|
||||
}
|
||||
// The shell just reported a fresh prompt, so at
|
||||
@@ -702,6 +712,17 @@ impl RemoteTerminal {
|
||||
}
|
||||
DaemonMsg::RemoteContext(ctx) => {
|
||||
flush_batch!();
|
||||
// Crossing the local/remote boundary invalidates
|
||||
// the cwd: it names a directory in the namespace
|
||||
// we just left. Drop it so the pane reports none
|
||||
// until the new shell's OSC 7 lands — otherwise
|
||||
// an `exit` from `ssh` leaves the remote's last
|
||||
// path in place, and a local shell without shell
|
||||
// integration never overwrites it, so the local
|
||||
// `git` probe keeps running against it.
|
||||
if let Ok(mut guard) = cwd.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
if let Ok(mut guard) = remote.lock() {
|
||||
*guard = ctx;
|
||||
}
|
||||
@@ -933,6 +954,13 @@ impl RemoteTerminal {
|
||||
self.shell_state.lock().map(|s| s.seq).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Monotonic count of entered-prompt edges — see [`ShellState::cycle`].
|
||||
/// Stable across same-prompt redraws (which bump `seq` but not this);
|
||||
/// only leaving the prompt for a command and coming back advances it.
|
||||
pub fn prompt_cycle(&self) -> u64 {
|
||||
self.shell_state.lock().map(|s| s.cycle).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Exit code of the most recently completed foreground command, as sniffed
|
||||
/// from OSC 133;D daemon-side. `None` before any command has finished.
|
||||
pub fn last_exit_code(&self) -> Option<i32> {
|
||||
@@ -1587,6 +1615,7 @@ fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Confi
|
||||
Config {
|
||||
scrolling_history: user_config.scrollback_limit,
|
||||
default_cursor_style: alacritty_cursor_style(user_config.cursor_style),
|
||||
semantic_escape_chars: user_config.word_separators.clone(),
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
@@ -2404,7 +2433,10 @@ mod tests {
|
||||
status: AgentStatus::Waiting,
|
||||
message: Some("Claude needs your permission".into()),
|
||||
session_id: Some("sid-1".into()),
|
||||
launch_argv: None,
|
||||
rich: true,
|
||||
cwd: None,
|
||||
activity: 0,
|
||||
}))
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
|
||||
@@ -125,6 +125,13 @@ impl Arg {
|
||||
.iter()
|
||||
.any(|t| t == "filepaths" || t == "folders")
|
||||
}
|
||||
|
||||
/// Whether this arg's filesystem completion is directories only — a Fig
|
||||
/// `folders` template with no `filepaths` alongside it.
|
||||
pub fn wants_dirs_only(&self) -> bool {
|
||||
self.template.iter().any(|t| t == "folders")
|
||||
&& !self.template.iter().any(|t| t == "filepaths")
|
||||
}
|
||||
}
|
||||
|
||||
/// A static value suggestion for an argument.
|
||||
|
||||
@@ -10,7 +10,7 @@ use gpui_component::input::{InputEvent, InputState};
|
||||
use gpui_component::select::{SearchableVec, SelectEvent, SelectState};
|
||||
use gpui_component::slider::{SliderEvent, SliderState};
|
||||
use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _};
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -383,6 +383,10 @@ pub struct Tty7App {
|
||||
/// the same repo shows the just-refreshed branch/diff line, not a stale
|
||||
/// per-row copy. Never read.
|
||||
_git_status_watch: Subscription,
|
||||
/// Keeps the window-appearance observer alive: while
|
||||
/// `Config::theme_follow_system` is on, an OS light/dark flip re-resolves
|
||||
/// the theme slot and repaints. Never read.
|
||||
_appearance_watch: Subscription,
|
||||
/// `Some` while the command palette overlay is open; `None` when closed.
|
||||
/// The view owns its search input, filtered list and keyboard handling and
|
||||
/// emits a `PaletteEvent`; we build the catalog and run the chosen command.
|
||||
@@ -446,6 +450,12 @@ pub struct Tty7App {
|
||||
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
|
||||
/// row into view.
|
||||
pub(crate) sidebar_scroll: gpui::ScrollHandle,
|
||||
/// `Some` while a tab / group is being dragged to a new position, in either
|
||||
/// the strip or the rail: the frozen geometry the live preview reflow is
|
||||
/// computed against (see [`crate::ui::reorder`]). Shared by `Rc` because the
|
||||
/// `on_drag` that opens it only gets `&mut App`, not the entity. Cleared on
|
||||
/// the first frame after gpui ends the drag.
|
||||
pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>,
|
||||
/// Filter box in the sidebar's top control bar ("Search tabs…"); its text
|
||||
/// narrows the visible rows by fuzzy-ish substring match on the tab label.
|
||||
pub(crate) sidebar_search: Entity<InputState>,
|
||||
@@ -627,13 +637,40 @@ impl Tty7App {
|
||||
// so treat it like a release. Dismissing on *both* flips also keeps a
|
||||
// reveal scheduled just before the switch from popping the badges up
|
||||
// in a window the user already left.
|
||||
let activation_watch = cx.observe_window_activation(window, |this, _window, cx| {
|
||||
let activation_watch = cx.observe_window_activation(window, |this, window, cx| {
|
||||
this.dismiss_mod_hint(cx);
|
||||
// The panes' link-modifier tracking loses the release the same
|
||||
// way, and a stale "⌘ held" is worse than missing badges: a
|
||||
// plain unmodified click would open links. Treat the flip as a
|
||||
// release; holding ⌘ again re-arms it via `on_modifiers_changed`.
|
||||
this.set_link_modifier(false, cx);
|
||||
// Coming back is the only cue we get that the working tree may
|
||||
// have moved while the user was elsewhere: an edit in another
|
||||
// editor, a `git` command in another app, an agent in another
|
||||
// window. None of those reach a pane's poll loop, so without this
|
||||
// the sidebar's `+N −N` would keep showing pre-alt-tab numbers
|
||||
// until the user happened to run a command in the pane.
|
||||
if window.is_window_active() {
|
||||
this.refresh_git_status_all(cx);
|
||||
}
|
||||
});
|
||||
// Follow OS light/dark flips live: while "sync with system" is on, an
|
||||
// appearance change re-resolves the theme slot and repaints. While it's
|
||||
// off the appearance only ever changes because `apply_theme` pinned it
|
||||
// to the theme — skip, or the pin would re-trigger a redundant apply.
|
||||
let this = cx.weak_entity();
|
||||
let appearance_watch = window.observe_window_appearance(move |window, cx| {
|
||||
if !cx.global::<Config>().theme_follow_system {
|
||||
return;
|
||||
}
|
||||
apply_theme(Some(window), cx);
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
// The editor targets the on-screen theme, and with no global
|
||||
// override the opacity slider follows it — keep both in step.
|
||||
this.rebuild_theme_editor(window, cx);
|
||||
this.sync_window_opacity_slider(window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
// Paint the configured color theme (defaults to a light one) and build
|
||||
// the menu bar.
|
||||
@@ -685,6 +722,7 @@ impl Tty7App {
|
||||
_keystroke_watch: keystroke_watch,
|
||||
_activation_watch: activation_watch,
|
||||
_git_status_watch: git_status_watch,
|
||||
_appearance_watch: appearance_watch,
|
||||
palette: None,
|
||||
palette_sub: None,
|
||||
closed: Vec::new(),
|
||||
@@ -716,6 +754,7 @@ impl Tty7App {
|
||||
right_panel_width: Rc::new(Cell::new(right_panel_width)),
|
||||
right_panel_dragging: Rc::new(Cell::new(false)),
|
||||
sidebar_scroll: gpui::ScrollHandle::new(),
|
||||
reorder: Rc::new(RefCell::new(None)),
|
||||
sidebar_search,
|
||||
_sidebar_search_sub: sidebar_search_sub,
|
||||
file_search,
|
||||
@@ -789,11 +828,20 @@ impl Tty7App {
|
||||
return true;
|
||||
}
|
||||
// From the home page (zero tabs) there are no running sessions to
|
||||
// reassure about — prompting would be pure friction. Close directly.
|
||||
// reassure about — prompting would be pure friction. Close directly,
|
||||
// but still quit with the window: closing our only window without
|
||||
// quitting leaves a windowless process sitting in the Dock that no
|
||||
// longer responds to being clicked (#147). Deferred onto the next
|
||||
// tick so the close itself completes first, same as the confirmed
|
||||
// path below.
|
||||
if weak_app
|
||||
.upgrade()
|
||||
.is_some_and(|app| app.read(cx).tabs.is_empty())
|
||||
{
|
||||
cx.spawn(async move |cx| {
|
||||
let _ = cx.update(|cx| cx.quit());
|
||||
})
|
||||
.detach();
|
||||
return true;
|
||||
}
|
||||
let answer = window.prompt(
|
||||
@@ -826,7 +874,7 @@ impl Tty7App {
|
||||
/// Snapshot the current tabs/active index into a `Session` and persist it.
|
||||
/// Called after every structural change; the write is a small synchronous
|
||||
/// JSON dump and any error is swallowed inside `Session::save`.
|
||||
fn save_session(&self, cx: &App) {
|
||||
pub(crate) fn save_session(&self, cx: &App) {
|
||||
let tabs: Vec<SessionTab> = self
|
||||
.tabs
|
||||
.iter()
|
||||
@@ -1195,8 +1243,90 @@ impl Tty7App {
|
||||
|
||||
/// Switch the active color theme by id, repaint, and persist the choice so
|
||||
/// it survives a restart. The theme carries its own dark/light brightness.
|
||||
/// While the system is being followed, the choice lands in the slot for the
|
||||
/// *current* OS appearance (the theme visibly on screen changes either way).
|
||||
pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.global_mut::<Config>().theme_preset = id.to_string();
|
||||
let dark_now = crate::ui::theme::system_dark(cx);
|
||||
let cfg = cx.global_mut::<Config>();
|
||||
if !cfg.theme_follow_system {
|
||||
cfg.theme_preset = id.to_string();
|
||||
} else if dark_now {
|
||||
cfg.theme_preset_dark = id.to_string();
|
||||
} else {
|
||||
cfg.theme_preset_light = id.to_string();
|
||||
}
|
||||
self.after_theme_change(window, cx);
|
||||
}
|
||||
|
||||
/// Set the theme for one follow-system slot explicitly (the Light / Dark
|
||||
/// cards in Settings). Only visibly changes anything when that slot is the
|
||||
/// one currently on screen; either way the choice is persisted.
|
||||
pub(crate) fn set_slot_preset(
|
||||
&mut self,
|
||||
dark_slot: bool,
|
||||
id: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let cfg = cx.global_mut::<Config>();
|
||||
if dark_slot {
|
||||
cfg.theme_preset_dark = id.to_string();
|
||||
} else {
|
||||
cfg.theme_preset_light = id.to_string();
|
||||
}
|
||||
self.after_theme_change(window, cx);
|
||||
}
|
||||
|
||||
/// Turn "sync with system appearance" on/off (the Appearance switch).
|
||||
/// Turning it off never visibly changes the theme: whatever is on screen
|
||||
/// is adopted as the manual choice. Turning it on seeds the slot matching
|
||||
/// the manual theme's own brightness with that theme — so the look only
|
||||
/// changes when the OS is currently in the *other* mode, where switching
|
||||
/// to that mode's slot is exactly what the feature promises.
|
||||
pub(crate) fn set_theme_follow_system(
|
||||
&mut self,
|
||||
on: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if on {
|
||||
let manual = cx.global::<Config>().theme_preset.clone();
|
||||
let manual_dark = crate::ui::presets::by_id(cx, &manual).dark;
|
||||
let cfg = cx.global_mut::<Config>();
|
||||
cfg.theme_follow_system = true;
|
||||
if manual_dark {
|
||||
cfg.theme_preset_dark = manual;
|
||||
} else {
|
||||
cfg.theme_preset_light = manual;
|
||||
}
|
||||
} else {
|
||||
// Resolve while following is still on (the pin is released, so
|
||||
// this reads the real OS appearance).
|
||||
let effective = crate::ui::theme::effective_preset_id(cx);
|
||||
let cfg = cx.global_mut::<Config>();
|
||||
cfg.theme_follow_system = false;
|
||||
cfg.theme_preset = effective;
|
||||
}
|
||||
self.after_theme_change(window, cx);
|
||||
// Re-aim an open picker panel at a slot that exists in the new mode —
|
||||
// after the apply above, so `system_dark` reads the unpinned OS value.
|
||||
let slot = if on {
|
||||
if crate::ui::theme::system_dark(cx) {
|
||||
crate::ui::settings::ThemeSlot::Dark
|
||||
} else {
|
||||
crate::ui::settings::ThemeSlot::Light
|
||||
}
|
||||
} else {
|
||||
crate::ui::settings::ThemeSlot::Manual
|
||||
};
|
||||
if let Some(s) = self.active_settings_mut() {
|
||||
s.theme_panel_slot = slot;
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared tail of every theme-selection change: repaint, persist, and
|
||||
/// keep the dependent Settings widgets in step.
|
||||
fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
apply_theme(Some(window), cx);
|
||||
set_menus(cx);
|
||||
cx.global::<Config>().save();
|
||||
@@ -1208,10 +1338,21 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Show/hide the theme picker panel beside the Appearance page.
|
||||
pub(crate) fn toggle_theme_panel(&mut self, cx: &mut Context<Self>) {
|
||||
/// Show/hide the theme picker panel beside the Appearance page. Clicking
|
||||
/// the card whose slot the open panel already targets closes it; clicking
|
||||
/// another card re-aims the open panel at that slot.
|
||||
pub(crate) fn toggle_theme_panel(
|
||||
&mut self,
|
||||
slot: crate::ui::settings::ThemeSlot,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(s) = self.active_settings_mut() {
|
||||
s.theme_panel_open = !s.theme_panel_open;
|
||||
if s.theme_panel_open && s.theme_panel_slot == slot {
|
||||
s.theme_panel_open = false;
|
||||
} else {
|
||||
s.theme_panel_open = true;
|
||||
s.theme_panel_slot = slot;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -1237,7 +1378,7 @@ impl Tty7App {
|
||||
/// and open the color editor on it. This is the entry point for customizing a
|
||||
/// read-only built-in (or an imported iTerm scheme).
|
||||
pub(crate) fn fork_active_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let id = cx.global::<Config>().theme_preset.clone();
|
||||
let id = crate::ui::theme::effective_preset_id(cx);
|
||||
let theme = crate::ui::presets::by_id(cx, &id);
|
||||
match crate::ui::presets::fork_to_file(&theme) {
|
||||
Ok(new_id) => {
|
||||
@@ -1258,7 +1399,7 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = cx.global::<Config>().theme_preset.clone();
|
||||
let id = crate::ui::theme::effective_preset_id(cx);
|
||||
let mut theme = crate::ui::presets::by_id(cx, &id);
|
||||
if !theme.editable() {
|
||||
return;
|
||||
@@ -1300,7 +1441,7 @@ impl Tty7App {
|
||||
/// set, else the active theme's own value, else fully opaque.
|
||||
pub(crate) fn effective_window_opacity(cx: &App) -> f32 {
|
||||
let config = cx.global::<Config>();
|
||||
let theme = crate::ui::presets::by_id(cx, &config.theme_preset);
|
||||
let theme = crate::ui::presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
|
||||
config.window_opacity.or(theme.opacity).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
@@ -1427,7 +1568,7 @@ impl Tty7App {
|
||||
if self.settings.is_none() {
|
||||
return;
|
||||
}
|
||||
let id = cx.global::<Config>().theme_preset.clone();
|
||||
let id = crate::ui::theme::effective_preset_id(cx);
|
||||
let theme = crate::ui::presets::by_id(cx, &id);
|
||||
if !theme.editable() {
|
||||
if let Some(s) = self.settings.as_mut() {
|
||||
@@ -2016,6 +2157,14 @@ impl Tty7App {
|
||||
self.update_config(cx, |cfg| cfg.copy_on_select = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.smart_select = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_tab_completion(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.tab_completion = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_startup_mode(
|
||||
&mut self,
|
||||
mode: crate::core::config::StartupMode,
|
||||
@@ -2074,6 +2223,24 @@ impl Tty7App {
|
||||
window.focus(&handle, cx);
|
||||
}
|
||||
|
||||
/// Ask every pane in the window to re-probe its git status. Called when the
|
||||
/// window regains focus: the sidebar shows a git line for *every* tab, not
|
||||
/// just the active one, so refreshing only the focused pane would leave the
|
||||
/// rest of the list stale — which is exactly the list the user is scanning
|
||||
/// right after switching back.
|
||||
///
|
||||
/// Panes sharing a cwd fold into one probe in the shared cache, and the
|
||||
/// throttle there counts per repo rather than per cwd, so once the cache
|
||||
/// knows where each pane lives the cost of a window with many panes is
|
||||
/// bounded by the number of distinct repos — not by the number of
|
||||
/// subdirectories they happen to sit in, which would be the same full-repo
|
||||
/// `git diff` asked several times over.
|
||||
fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) {
|
||||
for leaf in self.tabs.iter().flat_map(|tab| tab.pane.leaves()) {
|
||||
leaf.update(cx, |view, cx| view.refresh_git_status_now(cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a freshly opened tab should be inserted, per `new_tab_position`:
|
||||
/// right after the active tab, or appended at the end. Clamped to the tab
|
||||
/// count so the zero-tab home state (active 0, no tabs) inserts at 0.
|
||||
@@ -2097,11 +2264,14 @@ impl Tty7App {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Inherit the cwd of the active tab's focused terminal so the new tab
|
||||
// opens in the same directory the user is currently working in.
|
||||
// opens in the same directory the user is currently working in. Local
|
||||
// cwds only: the new tab is a local shell, and an inherited cwd wins
|
||||
// over every fallback in `pane::initial_working_directory`, so a remote
|
||||
// path would be handed straight to the spawn as a working directory.
|
||||
let cwd = self.tabs.get(self.active).and_then(|t| {
|
||||
t.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).cwd())
|
||||
.and_then(|leaf| leaf.read(cx).local_cwd())
|
||||
});
|
||||
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
|
||||
// Leaving the current tab for the new one; snapshot its focused pane
|
||||
@@ -2196,8 +2366,10 @@ impl Tty7App {
|
||||
};
|
||||
// The new pane inherits the cwd — and the shell, when the pane being
|
||||
// split was opened with an explicit pick (a WSL/fish tab splits into
|
||||
// more WSL/fish, not back to the default).
|
||||
let cwd = target.read(cx).cwd();
|
||||
// more WSL/fish, not back to the default). Local cwds only: the
|
||||
// native-SSH branch below has the daemon discard it regardless, and the
|
||||
// local branch would otherwise spawn against a remote path.
|
||||
let cwd = target.read(cx).local_cwd();
|
||||
// Splitting a native-SSH pane opens another SSH pane on the same
|
||||
// connection rather than dropping back to a local shell. Re-resolve the
|
||||
// persisted (secret-free) spec from its saved profile so keychain
|
||||
@@ -2501,7 +2673,7 @@ impl Tty7App {
|
||||
// Capture the tab's cwd *before* its panes are killed (the daemon can't
|
||||
// report it afterwards): if it sat in a tty7-managed worktree, the
|
||||
// cleanup offer below needs it.
|
||||
let worktree_cwd = self.tab_cwd(index, window, cx);
|
||||
let worktree_cwd = self.tab_local_cwd(index, window, cx);
|
||||
// Snapshot the tab (layout + each pane's current cwd + name) onto the
|
||||
// recently-closed stack so Cmd+Shift+T can bring it back.
|
||||
let snapshot = tab_to_session(&self.tabs[index], cx);
|
||||
@@ -2548,11 +2720,14 @@ impl Tty7App {
|
||||
let Some(cwd) = cwd else { return };
|
||||
// Every leaf of every surviving tab, not just focused panes — a shell
|
||||
// tucked away in a split occupies the worktree all the same.
|
||||
// Local paths only: this list is what stops a worktree being removed
|
||||
// out from under a live shell, and a remote cwd can neither occupy a
|
||||
// local worktree nor be compared against one meaningfully.
|
||||
let open_cwds: Vec<std::path::PathBuf> = self
|
||||
.tabs
|
||||
.iter()
|
||||
.flat_map(|tab| tab.pane.leaves())
|
||||
.filter_map(|leaf| leaf.read(cx).cwd())
|
||||
.filter_map(|leaf| leaf.read(cx).local_cwd())
|
||||
.collect();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Some(wt) = cx
|
||||
@@ -2646,6 +2821,30 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Mark as Unread" (tab context menu): re-flag every finished (`Done`)
|
||||
/// agent turn in the tab as unread, so the avatar's green dot swells back
|
||||
/// into its count badge until the user next looks at those panes. The
|
||||
/// active tab's focus target is told the dismissed menu is about to hand
|
||||
/// focus back to it, so that focus-in doesn't immediately re-read the mark
|
||||
/// (see `TerminalView::mark_agent_result_unread`).
|
||||
pub(crate) fn mark_tab_unread(&mut self, index: usize, cx: &mut Context<Self>) {
|
||||
use crate::core::cli_agent::AgentStatus;
|
||||
let Some(tab) = self.tabs.get(index) else {
|
||||
return;
|
||||
};
|
||||
let refocus = (index == self.active).then(|| tab.focus_target()).flatten();
|
||||
for leaf in tab.pane.leaves() {
|
||||
let refocus_incoming = refocus.as_ref() == Some(&leaf);
|
||||
leaf.update(cx, |view, cx| {
|
||||
if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) {
|
||||
view.mark_agent_result_unread(refocus_incoming);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The cwd of the tab's label-driving terminal (focused leaf, else first) —
|
||||
/// what the tab context menu's "Copy Working Directory" copies and "New
|
||||
/// Worktree Tab" derives the repo from.
|
||||
@@ -2662,6 +2861,18 @@ impl Tty7App {
|
||||
.and_then(|leaf| leaf.read(cx).cwd())
|
||||
}
|
||||
|
||||
/// [`tab_cwd`](Self::tab_cwd) restricted to a directory on this machine —
|
||||
/// for the worktree operations, which shell out to a local `git`. "Copy
|
||||
/// Working Directory" deliberately keeps using `tab_cwd`: copying a remote
|
||||
/// pane's remote path is exactly what the user wants there.
|
||||
fn tab_local_cwd(&self, index: usize, window: &Window, cx: &App) -> Option<std::path::PathBuf> {
|
||||
self.tabs
|
||||
.get(index)?
|
||||
.pane
|
||||
.focused_or_first(window, cx)
|
||||
.and_then(|leaf| leaf.read(cx).local_cwd())
|
||||
}
|
||||
|
||||
/// "New Worktree Tab": probe the repository containing the tab's cwd for
|
||||
/// defaults (a fresh generated name, the current branch as start point) on
|
||||
/// the background executor, then open the confirmation sheet
|
||||
@@ -2673,7 +2884,7 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(cwd) = self.tab_cwd(index, window, cx) else {
|
||||
let Some(cwd) = self.tab_local_cwd(index, window, cx) else {
|
||||
window.push_notification("This tab has no working directory yet", cx);
|
||||
return;
|
||||
};
|
||||
@@ -2713,33 +2924,25 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop).
|
||||
/// Keeps the same tab active across the move and re-persists the session.
|
||||
pub(crate) fn move_tab(&mut self, from: usize, to: usize, cx: &mut Context<Self>) {
|
||||
if from == to || from >= self.tabs.len() || to >= self.tabs.len() {
|
||||
/// Rearrange the whole tab vector into `order` (old indices in their new
|
||||
/// order) — the single path by which a drag-reorder lands, used by the
|
||||
/// sidebar where a single visual move can imply a larger permutation
|
||||
/// (relocating a tab inside its group without disturbing the group order,
|
||||
/// or moving an entire group). Keeps the same tab active and re-persists.
|
||||
pub(crate) fn apply_tab_order(&mut self, order: &[usize], cx: &mut Context<Self>) {
|
||||
if order.len() != self.tabs.len() || order.iter().enumerate().all(|(i, &o)| i == o) {
|
||||
return;
|
||||
}
|
||||
// Reordering shifts indices; a pending rename keyed on a fixed index would
|
||||
// commit onto the wrong tab. Drop it.
|
||||
// Reordering shifts indices: a rename pending on a fixed one would
|
||||
// commit onto the wrong tab.
|
||||
self.renaming = None;
|
||||
let was_active = self.active;
|
||||
let tab = self.tabs.remove(from);
|
||||
self.tabs.insert(to, tab);
|
||||
// Re-derive the active index so the same logical tab stays selected:
|
||||
// removal shifts indices after `from` left, insertion shifts indices at
|
||||
// or after `to` right.
|
||||
self.active = if was_active == from {
|
||||
to
|
||||
} else {
|
||||
let mut a = was_active;
|
||||
if from < a {
|
||||
a -= 1;
|
||||
}
|
||||
if to <= a {
|
||||
a += 1;
|
||||
}
|
||||
a
|
||||
};
|
||||
let mut slots: Vec<Option<Tab>> = std::mem::take(&mut self.tabs)
|
||||
.into_iter()
|
||||
.map(Some)
|
||||
.collect();
|
||||
self.tabs = order.iter().filter_map(|&i| slots[i].take()).collect();
|
||||
self.active = order.iter().position(|&i| i == was_active).unwrap_or(0);
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -3050,7 +3253,10 @@ impl Tty7App {
|
||||
.tabs
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
.and_then(|view| view.read(cx).cwd());
|
||||
// Local `git` shell-out, so a remote pane's cwd is not usable —
|
||||
// it reports "no known directory" rather than silently diffing
|
||||
// whatever the path collides with locally.
|
||||
.and_then(|view| view.read(cx).local_cwd());
|
||||
let Some(cwd) = cwd else {
|
||||
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
|
||||
return;
|
||||
@@ -3059,9 +3265,9 @@ impl Tty7App {
|
||||
// which is what a review pass wants. Both invocations are quick; the
|
||||
// prompt builder caps runaway diffs.
|
||||
let run = |args: &[&str]| {
|
||||
std::process::Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(&cwd)
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(args).current_dir(&cwd);
|
||||
crate::core::proc::hide_console(&mut cmd)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
@@ -3098,6 +3304,7 @@ impl Tty7App {
|
||||
self.build_font_selects(&mut subs, window, cx);
|
||||
let (shell_program_input, shell_args_input, wd_path_input) =
|
||||
self.build_shell_inputs(&mut subs, window, cx);
|
||||
let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx);
|
||||
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
|
||||
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
|
||||
// Live filter for the theme picker panel; each keystroke re-renders the
|
||||
@@ -3133,10 +3340,12 @@ impl Tty7App {
|
||||
shell_program_input,
|
||||
shell_args_input,
|
||||
wd_path_input,
|
||||
link_file_command_input,
|
||||
scroll_slider,
|
||||
window_opacity_slider,
|
||||
theme_editor: None,
|
||||
theme_panel_open: false,
|
||||
theme_panel_slot: crate::ui::settings::ThemeSlot::Manual,
|
||||
theme_search,
|
||||
recording: None,
|
||||
rebinding_note: None,
|
||||
@@ -3318,6 +3527,59 @@ impl Tty7App {
|
||||
(shell_program_input, shell_args_input, wd_path_input)
|
||||
}
|
||||
|
||||
/// File-open command template input (Links section), committing on Enter/blur.
|
||||
fn build_link_file_command_input(
|
||||
&mut self,
|
||||
subs: &mut Vec<Subscription>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<InputState> {
|
||||
let value = cx
|
||||
.global::<Config>()
|
||||
.link_file_command
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("open in default app")
|
||||
.default_value(value)
|
||||
});
|
||||
subs.push(
|
||||
cx.subscribe_in(&input, window, move |this, _i, ev, _w, cx| {
|
||||
if matches!(ev, InputEvent::PressEnter { .. } | InputEvent::Blur) {
|
||||
this.commit_link_file_command(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
input
|
||||
}
|
||||
|
||||
/// Persist the file-open command template from the Links settings input. An
|
||||
/// empty value clears the override (falls back to the built-in open).
|
||||
fn commit_link_file_command(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(command) = self.active_settings().map(|s| {
|
||||
s.link_file_command_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.trim()
|
||||
.to_string()
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let command = if command.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(command)
|
||||
};
|
||||
let cfg = cx.global_mut::<Config>();
|
||||
if cfg.link_file_command == command {
|
||||
return; // no change — avoid a redundant disk write on every Blur
|
||||
}
|
||||
cfg.link_file_command = command;
|
||||
cfg.save();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Window-opacity slider for the Appearance page (20%–100%). Emits `Change`
|
||||
/// continuously as the user drags; each tick sets the global override and
|
||||
/// repaints, so the translucency is live under the thumb.
|
||||
@@ -3718,8 +3980,14 @@ impl Tty7App {
|
||||
}
|
||||
};
|
||||
Some(rgb)
|
||||
} else if v.remote_context().is_some() {
|
||||
// A foreground `ssh` typed into a shell: a plain neutral dot.
|
||||
} else if v
|
||||
.remote_context()
|
||||
.is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl)
|
||||
{
|
||||
// A foreground `ssh` typed into a shell: a plain neutral dot. The
|
||||
// kind check matters: a WSL pane also carries a `RemoteContext` (so
|
||||
// its cwd is treated as foreign — see `local_cwd`), but it is not an
|
||||
// SSH session and this dot means "SSH".
|
||||
Some(0x9CA3AF)
|
||||
} else {
|
||||
None
|
||||
@@ -3777,6 +4045,11 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The focused pane when it is an SSH session of either kind.
|
||||
///
|
||||
/// Not every pane carrying a `RemoteContext` is one: a WSL pane has one too,
|
||||
/// so that its cwd is treated as foreign (see `TerminalView::local_cwd`),
|
||||
/// and it must not reach anything SSH-shaped from here.
|
||||
pub(crate) fn active_ssh_pane(
|
||||
&self,
|
||||
window: &Window,
|
||||
@@ -3788,7 +4061,8 @@ impl Tty7App {
|
||||
.pane
|
||||
.focused_or_first(window, cx)?;
|
||||
let pane = pane.read(cx);
|
||||
Some((pane.pane_id, pane.remote_context()?))
|
||||
let remote = pane.remote_context()?;
|
||||
(remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote))
|
||||
}
|
||||
|
||||
/// The focused pane when it is a *connected native* SSH session — the gate for
|
||||
@@ -4160,6 +4434,32 @@ impl Tty7App {
|
||||
|
||||
impl Render for Tty7App {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// A live drag-reorder commits when the drag *ends*, which in gpui means
|
||||
// the mouse was released (nothing else clears an active drag): the first
|
||||
// frame without one retires the preview and applies the order it was
|
||||
// last showing. Deliberately not an `on_drop` handler — those only fire
|
||||
// when the pointer is over that particular element at release, so a
|
||||
// release a hair outside the rail or the strip would silently lose the
|
||||
// move. What you were looking at is what you get, wherever you let go.
|
||||
// One place at the root covers every surface that can start a drag.
|
||||
if cx.has_active_drag() {
|
||||
// Still dragging: forget last frame's answer so only what this
|
||||
// frame actually draws can be committed (see `clear_pending`).
|
||||
crate::ui::reorder::clear_pending(&self.reorder);
|
||||
} else if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) {
|
||||
self.apply_tab_order(&order, cx);
|
||||
}
|
||||
// While a tab or group is in hand, the cursor is a closed hand for the
|
||||
// whole window. It has to be set on the *drag* rather than styled on
|
||||
// the element: gpui overrides every hovered element's cursor with the
|
||||
// active drag's for the duration, and that override is `None` — a plain
|
||||
// arrow — unless something fills it in. Set once per drag (it forces a
|
||||
// refresh, so re-setting it every frame would spin).
|
||||
if self.reorder.borrow().is_some()
|
||||
&& cx.active_drag_cursor_style() != Some(gpui::CursorStyle::ClosedHand)
|
||||
{
|
||||
cx.set_active_drag_cursor_style(gpui::CursorStyle::ClosedHand, window);
|
||||
}
|
||||
// Vertical-tab mode: the sidebar owns the tab list, so the title-bar strip
|
||||
// drops its chips (keeping only "+"/"⋯"). Gated on having tabs — the
|
||||
// zero-tab home page keeps the full-width horizontal layout, so an empty
|
||||
@@ -4627,7 +4927,13 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
||||
Pane::Leaf(view) => {
|
||||
let view = view.read(cx);
|
||||
SessionPane::Leaf {
|
||||
cwd: view.cwd(),
|
||||
// Local cwd only. A restored pane whose daemon pane is gone
|
||||
// respawns on the *default local shell* (a shell pick isn't
|
||||
// persisted), so a remote cwd would come back paired with a
|
||||
// local shell that cannot chdir into it. Native-SSH panes
|
||||
// reconnect from `ssh_spec` and the daemon discards the cwd
|
||||
// for them anyway (`server::SpawnNativeSsh`).
|
||||
cwd: view.local_cwd(),
|
||||
pane_id: Some(view.pane_id),
|
||||
// Persist the secret-free native-SSH spec so a *dead* pane can be
|
||||
// reconnected on restore (FR-E4/C2); `None` for local panes. A
|
||||
@@ -4635,9 +4941,12 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
||||
ssh_spec: view.ssh_spec(),
|
||||
// The running agent + its native session id (when its hooks
|
||||
// reported one), so a pane the daemon loses can resume the
|
||||
// agent conversation instead of just reopening a shell.
|
||||
// agent conversation instead of just reopening a shell. The
|
||||
// observed launch argv rides along so the resume command keeps
|
||||
// the user's flags (`--dangerously-skip-permissions`, …).
|
||||
agent: view.agent(),
|
||||
agent_session_id: view.agent_session().and_then(|s| s.session_id),
|
||||
agent_launch_argv: view.agent_session().and_then(|s| s.launch_argv),
|
||||
}
|
||||
}
|
||||
Pane::Split {
|
||||
@@ -4659,6 +4968,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -4730,6 +5040,7 @@ fn session_to_pane(
|
||||
ssh_spec,
|
||||
agent,
|
||||
agent_session_id,
|
||||
agent_launch_argv,
|
||||
} => {
|
||||
// Only restore the pane id when the daemon confirms it's still live;
|
||||
// a stale id (daemon restarted, pane killed) falls back to a spawn.
|
||||
@@ -4761,7 +5072,7 @@ fn session_to_pane(
|
||||
if restore.is_none()
|
||||
&& cx.global::<Config>().restore_agent_sessions
|
||||
&& let (Some(agent), Some(id)) = (agent, agent_session_id)
|
||||
&& let Some(cmd) = agent.resume_command(id)
|
||||
&& let Some(cmd) = agent.resume_command(id, agent_launch_argv.as_deref())
|
||||
{
|
||||
view.read(cx).run_command_line(&cmd);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ mod tests {
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod palette;
|
||||
pub mod pane;
|
||||
pub mod perf;
|
||||
pub mod presets;
|
||||
pub mod reorder;
|
||||
pub mod right_panel;
|
||||
pub mod settings;
|
||||
pub mod sftp;
|
||||
|
||||
@@ -21,7 +21,6 @@ use gpui_component::{
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::config::Config;
|
||||
use crate::core::ssh_profile::parse_quick_connect;
|
||||
|
||||
/// What a command actually does. Most variants map to an existing `Tty7App`
|
||||
@@ -272,7 +271,7 @@ impl Command {
|
||||
/// preset. The active theme is marked with a check so the list doubles as a
|
||||
/// "which theme am I on?" indicator.
|
||||
pub fn theme_commands(cx: &App) -> Vec<Command> {
|
||||
let active = cx.global::<Config>().theme_preset.clone();
|
||||
let active = crate::ui::theme::effective_preset_id(cx);
|
||||
crate::ui::presets::all(cx)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
//! Live drag-to-reorder — the "the list gets out of your way while you drag"
|
||||
//! behaviour behind the tab strip's chips, the sidebar's rows and the sidebar's
|
||||
//! group headers.
|
||||
//!
|
||||
//! Nothing floats above the window: the item you're dragging stays in the
|
||||
//! list, dimmed, and travels by the list rearranging around it. gpui's drag
|
||||
//! system is used only for what it's good at here — knowing a drag is live and
|
||||
//! redrawing every mouse move — while its floating preview renders nothing.
|
||||
//! What it doesn't offer at all is any way to know *where* the cursor is
|
||||
//! mid-drag from inside a drop target: only a `drag_over` style and a final
|
||||
//! `on_drop`, which is the "one tile swaps with another on release" model this
|
||||
//! module replaces. Here the surface reads [`Window::mouse_position`] every
|
||||
//! frame, asks [`Reorder`] where the dragged slot belongs *right now*, and
|
||||
//! renders the list in that order, so what you see is already the result.
|
||||
//!
|
||||
//! The shape of it:
|
||||
//!
|
||||
//! | Step | Who | What |
|
||||
//! |---|---|---|
|
||||
//! | Measure | every slot, every frame | its own bounds into a per-frame cell |
|
||||
//! | Freeze | `on_drag` | that cell becomes [`Reorder::rects`] for the whole drag |
|
||||
//! | Preview | the surface, per frame | [`Reorder::target`] → [`Reorder::order`] |
|
||||
//! | Track | the held slot | [`Reorder::held_offset`] → follows the cursor |
|
||||
//! | Slide | each displaced slot | [`Reorder::flip_offset`] → animate to zero |
|
||||
//! | Record | the surface, per frame | [`set_pending`] — the order a release would give |
|
||||
//! | Commit | the root, on drag end | [`take_pending`] → `Tty7App::apply_tab_order` |
|
||||
//!
|
||||
//! **Geometry is frozen at drag start on purpose.** The preview reflow moves
|
||||
//! the very slots the hit-testing reads, so measuring live would let the list
|
||||
//! feed back into its own input and oscillate under a still cursor. Freezing
|
||||
//! also means a mid-drag scroll isn't tracked — a deliberate trade for a rail
|
||||
//! whose rows are a few dozen pixels tall.
|
||||
|
||||
use gpui::{Axis, Bounds, Pixels, Point, px};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The app-wide slot for the one drag that can be live at a time. Shared by
|
||||
/// `Rc` because the `on_drag` that opens it only gets `&mut App`.
|
||||
pub(crate) type ReorderState = Rc<RefCell<Option<Reorder>>>;
|
||||
|
||||
/// Everything a surface needs to draw one frame of a live reorder.
|
||||
pub(crate) struct Preview {
|
||||
/// Slot indices in the order to render them.
|
||||
pub(crate) order: Vec<usize>,
|
||||
/// The slot the held item currently occupies — where a release right now
|
||||
/// would land it.
|
||||
pub(crate) target: usize,
|
||||
/// The slot being dragged. It stays in the list like any other — the drag
|
||||
/// is drawn by the list rearranging, not by a card floating over it — and
|
||||
/// only wears a "picked up" dimming so you can see which one you have.
|
||||
pub(crate) from: usize,
|
||||
/// Bumped whenever the preview order changes; part of each slot's
|
||||
/// animation id so a slide restarts rather than resuming.
|
||||
pub(crate) generation: usize,
|
||||
/// Per slot (indexed by its *frozen* index), the offset to start this
|
||||
/// frame at so it slides into place. Zero for everything the last change
|
||||
/// didn't touch, and unused for [`Self::from`] — the held item doesn't
|
||||
/// slide, it tracks.
|
||||
pub(crate) offsets: Vec<Pixels>,
|
||||
/// Where to draw the held item relative to the slot it's laid out in, so
|
||||
/// it follows the cursor continuously instead of hopping slot to slot.
|
||||
pub(crate) held: Pixels,
|
||||
}
|
||||
|
||||
/// This frame's preview of `surface`, if that's where the live drag started
|
||||
/// and its frozen geometry still describes a list of `len` slots.
|
||||
pub(crate) fn preview(
|
||||
state: &ReorderState,
|
||||
surface: &Surface,
|
||||
len: usize,
|
||||
pointer: Point<Pixels>,
|
||||
) -> Option<Preview> {
|
||||
let state = state.borrow();
|
||||
let r = state.as_ref().filter(|r| r.covers(surface, len))?;
|
||||
let target = r.target(pointer);
|
||||
let (generation, prev) = r.begin_frame(target);
|
||||
Some(Preview {
|
||||
order: r.order(target),
|
||||
target,
|
||||
from: r.from,
|
||||
generation,
|
||||
offsets: (0..len)
|
||||
.map(|slot| r.flip_offset(slot, prev, target))
|
||||
.collect(),
|
||||
held: r.held_offset(pointer, target),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the tab order the current preview implies, so releasing the mouse
|
||||
/// applies exactly what the user was looking at.
|
||||
///
|
||||
/// The surface computes this every frame while it draws the preview, rather
|
||||
/// than a drop handler working it out on release, because a drop handler only
|
||||
/// fires when the pointer is over *that element* at release — release a hair
|
||||
/// above the rail (easy when dragging a row upward) and the move would be
|
||||
/// silently lost, the list snapping back. The drag ending is the commit, and
|
||||
/// where the cursor happens to be at that moment doesn't enter into it.
|
||||
pub(crate) fn set_pending(state: &ReorderState, surface: &Surface, order: Vec<usize>) {
|
||||
if let Some(r) = state.borrow().as_ref().filter(|r| r.surface == *surface) {
|
||||
*r.pending.borrow_mut() = Some(order);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the order recorded by the previous frame, at the start of every
|
||||
/// frame a drag is live — the surfaces re-record it as they draw.
|
||||
///
|
||||
/// Without this the recording is a high-water mark rather than a snapshot: drag
|
||||
/// a row down and back to its own slot and the surface stops recording (the
|
||||
/// move is a no-op), so a stale "moved" order would survive to be committed by
|
||||
/// a release the user made after visibly putting the row back. Same for a frame
|
||||
/// where the drag's frozen geometry no longer matches the list ([`Reorder::covers`]
|
||||
/// — a tab closed, or a git probe moved one to another group): nothing is drawn
|
||||
/// and so nothing may commit.
|
||||
pub(crate) fn clear_pending(state: &ReorderState) {
|
||||
if let Some(r) = state.borrow().as_ref() {
|
||||
r.pending.borrow_mut().take();
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the recorded order out of a finished drag — see [`set_pending`].
|
||||
pub(crate) fn take_pending(state: &ReorderState) -> Option<Vec<usize>> {
|
||||
state.borrow_mut().take()?.pending.into_inner()
|
||||
}
|
||||
|
||||
/// Which of the app's reorderable lists a drag belongs to. The sidebar rail
|
||||
/// holds two at once — the rows inside a group, and the group blocks
|
||||
/// themselves — so a surface asking "is this drag mine?" needs more than
|
||||
/// "am I the sidebar". Rows carry their group's repo root (`None` = Scratch)
|
||||
/// because a row drag must never reflow a sibling group: a tab's group comes
|
||||
/// from its cwd, not from where it sits in the list.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum Surface {
|
||||
/// The horizontal title-bar strip. Display order is plain tab order.
|
||||
Strip,
|
||||
/// The rows of one sidebar group.
|
||||
SidebarRows(Option<PathBuf>),
|
||||
/// The sidebar's group blocks (header + its rows), dragged by the header.
|
||||
SidebarGroups,
|
||||
}
|
||||
|
||||
/// One live drag-reorder. Created when gpui starts a drag, read by the surface
|
||||
/// on every frame until the drop, then dropped.
|
||||
pub(crate) struct Reorder {
|
||||
/// The list this drag belongs to; a surface ignores state that isn't its own.
|
||||
pub(crate) surface: Surface,
|
||||
/// Index of the dragged slot in the frozen order.
|
||||
pub(crate) from: usize,
|
||||
/// Every slot's bounds in display order, as measured on the last frame
|
||||
/// before the drag started (see the module docs on why they're frozen).
|
||||
rects: Vec<Bounds<Pixels>>,
|
||||
/// The axis the list runs along — vertical for the rail, horizontal for the strip.
|
||||
axis: Axis,
|
||||
/// The list's gap between slots, so a displaced slot's shift matches what
|
||||
/// the layout will actually do.
|
||||
gap: Pixels,
|
||||
/// Where inside the dragged slot the pointer grabbed it, so the slot's
|
||||
/// position is derived from the cursor exactly as gpui's floating preview is.
|
||||
grab: Point<Pixels>,
|
||||
/// The target the previous frame drew, and a counter bumped whenever it
|
||||
/// changes. The slide-in animation keys its element id off the counter, so
|
||||
/// a slot that has just been displaced restarts its slide instead of
|
||||
/// resuming a finished one.
|
||||
prev: Cell<usize>,
|
||||
generation: Cell<usize>,
|
||||
/// The tab order releasing right now would produce, refreshed every frame
|
||||
/// by the surface drawing the preview. See [`set_pending`].
|
||||
pending: RefCell<Option<Vec<usize>>>,
|
||||
}
|
||||
|
||||
impl Reorder {
|
||||
pub(crate) fn new(
|
||||
surface: Surface,
|
||||
from: usize,
|
||||
rects: Vec<Bounds<Pixels>>,
|
||||
axis: Axis,
|
||||
gap: Pixels,
|
||||
grab: Point<Pixels>,
|
||||
) -> Self {
|
||||
Self {
|
||||
surface,
|
||||
from,
|
||||
rects,
|
||||
axis,
|
||||
gap,
|
||||
grab,
|
||||
prev: Cell::new(from),
|
||||
generation: Cell::new(0),
|
||||
pending: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this state belongs to `surface` and its frozen geometry still
|
||||
/// describes a list of `len` slots — a tab closing mid-drag, or the git
|
||||
/// probe moving a tab to another group, invalidates it rather than letting
|
||||
/// stale indices reorder the wrong thing.
|
||||
pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool {
|
||||
self.surface == *surface && self.rects.len() == len && self.from < len
|
||||
}
|
||||
|
||||
/// The scalar component along the list's axis.
|
||||
fn along(&self, p: Point<Pixels>) -> Pixels {
|
||||
match self.axis {
|
||||
Axis::Vertical => p.y,
|
||||
Axis::Horizontal => p.x,
|
||||
}
|
||||
}
|
||||
|
||||
/// A slot's extent along the list's axis.
|
||||
fn extent(&self, b: &Bounds<Pixels>) -> Pixels {
|
||||
match self.axis {
|
||||
Axis::Vertical => b.size.height,
|
||||
Axis::Horizontal => b.size.width,
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a slot moves when the dragged one passes it: the dragged slot's
|
||||
/// extent plus the gap it also takes with it.
|
||||
fn shift(&self) -> Pixels {
|
||||
self.extent(&self.rects[self.from]) + self.gap
|
||||
}
|
||||
|
||||
/// Where the dragged slot belongs for a cursor at `pointer`: how many of
|
||||
/// the other slots would sit before it.
|
||||
///
|
||||
/// A neighbour yields once the held item covers half of it — its *trailing*
|
||||
/// edge past that neighbour's centre going forward, its *leading* edge past
|
||||
/// it going back. Edges rather than the held item's own centre, because the
|
||||
/// two are only equivalent when everything is the same size: in the rail a
|
||||
/// three-row group block is twice a one-row block, and a centre-to-centre
|
||||
/// test would demand the tall block's middle reach the short one's middle —
|
||||
/// pointer travel that runs off the top of the list, which is exactly the
|
||||
/// "big group won't move up" case. Half-overlap asks the same of both
|
||||
/// directions and of any pair of sizes.
|
||||
///
|
||||
/// The comparison is against the *frozen* centres, never the reflowed ones,
|
||||
/// so the reflow can't move the number it's being compared to and the
|
||||
/// crossing can't chase itself under a still cursor.
|
||||
pub(crate) fn target(&self, pointer: Point<Pixels>) -> usize {
|
||||
let leading = self.free_origin(pointer);
|
||||
let trailing = leading + self.extent(&self.rects[self.from]);
|
||||
self.rects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != self.from)
|
||||
.filter(|(i, r)| {
|
||||
let centre = self.along(r.origin) + self.extent(r) / 2.;
|
||||
if *i < self.from {
|
||||
// Still above the held item: it hasn't reached back this far.
|
||||
leading >= centre
|
||||
} else {
|
||||
trailing > centre
|
||||
}
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Where the dragged slot's leading edge is, following the pointer without
|
||||
/// limit: the cursor less where inside the slot it was grabbed, so the item
|
||||
/// sits under the cursor exactly where you picked it up.
|
||||
fn free_origin(&self, pointer: Point<Pixels>) -> Pixels {
|
||||
self.along(pointer) - self.along(self.grab)
|
||||
}
|
||||
|
||||
/// [`Self::free_origin`] confined to the list's own span, so dragging far
|
||||
/// past either end parks the item against that end instead of sending it
|
||||
/// off across the window. Only the *drawing* is clamped — [`Self::target`]
|
||||
/// reads the free position, so pushing past the last slot still selects it.
|
||||
fn held_origin(&self, pointer: Point<Pixels>) -> Pixels {
|
||||
let first = self.along(self.rects[0].origin);
|
||||
let last = self.rects.last().expect("non-empty");
|
||||
let end = self.along(last.origin) + self.extent(last);
|
||||
self.free_origin(pointer)
|
||||
.clamp(first, end - self.extent(&self.rects[self.from]))
|
||||
}
|
||||
|
||||
/// The offset to draw the dragged slot at so it tracks the pointer: the
|
||||
/// distance from where the list has *laid it out* this frame (its slot
|
||||
/// under `target`) to where the cursor is actually holding it.
|
||||
///
|
||||
/// This is what makes the drag feel attached rather than stepwise — the
|
||||
/// held item moves pixel-for-pixel with the mouse, and the reflow of the
|
||||
/// others is the only thing that snaps.
|
||||
pub(crate) fn held_offset(&self, pointer: Point<Pixels>, target: usize) -> Pixels {
|
||||
let home = self.along(self.rects[self.from].origin);
|
||||
self.held_origin(pointer) - (home + self.displacement(self.from, target))
|
||||
}
|
||||
|
||||
/// Slot indices in preview order: the dragged one lifted out of `from` and
|
||||
/// dropped back in at `target`.
|
||||
pub(crate) fn order(&self, target: usize) -> Vec<usize> {
|
||||
let mut order: Vec<usize> = (0..self.rects.len()).collect();
|
||||
let dragged = order.remove(self.from);
|
||||
order.insert(target.min(order.len()), dragged);
|
||||
order
|
||||
}
|
||||
|
||||
/// Open a frame previewing `target`: returns the animation generation to
|
||||
/// key slide-ins on, and the target the previous frame drew — which
|
||||
/// [`Self::flip_offset`] measures the slide from. Call once per frame,
|
||||
/// before laying the slots out.
|
||||
pub(crate) fn begin_frame(&self, target: usize) -> (usize, usize) {
|
||||
let prev = self.prev.get();
|
||||
if prev != target {
|
||||
self.generation.set(self.generation.get() + 1);
|
||||
self.prev.set(target);
|
||||
}
|
||||
(self.generation.get(), prev)
|
||||
}
|
||||
|
||||
/// Where the slot frozen at index `slot` sits under a given preview,
|
||||
/// relative to its resting place.
|
||||
///
|
||||
/// The displaced slots each close up by one dragged-slot pitch, in the
|
||||
/// direction the drag came from. The dragged slot itself moves the other
|
||||
/// way by everything it has jumped over — it stays in the list rather than
|
||||
/// floating above it, so it has a position to be displaced to like anyone
|
||||
/// else, and the two sides always add up to a swap.
|
||||
fn displacement(&self, slot: usize, target: usize) -> Pixels {
|
||||
if slot == self.from {
|
||||
// Sum the pitches of the slots crossed, since rows differ in height.
|
||||
let crossed = if target > self.from {
|
||||
self.from + 1..=target
|
||||
} else {
|
||||
target..=self.from.saturating_sub(1)
|
||||
};
|
||||
let span: Pixels = crossed
|
||||
.filter(|&i| i != self.from && i < self.rects.len())
|
||||
.map(|i| self.extent(&self.rects[i]) + self.gap)
|
||||
.fold(px(0.), |a, b| a + b);
|
||||
if target > self.from { span } else { -span }
|
||||
} else if self.from < slot && slot <= target {
|
||||
-self.shift()
|
||||
} else if target <= slot && slot < self.from {
|
||||
self.shift()
|
||||
} else {
|
||||
px(0.)
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset a slot should *start* this frame at so it slides into its new
|
||||
/// place instead of teleporting: where the last frame drew it, minus where
|
||||
/// this frame puts it. Zero for every slot the new target didn't disturb —
|
||||
/// which is all but one on a typical frame, so the list only animates the
|
||||
/// row you just crossed.
|
||||
pub(crate) fn flip_offset(&self, slot: usize, prev: usize, target: usize) -> Pixels {
|
||||
self.displacement(slot, prev) - self.displacement(slot, target)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{point, size};
|
||||
|
||||
/// A vertical list of `n` slots, each `h` tall with a `gap` between them,
|
||||
/// starting at y = 0 — the sidebar's shape.
|
||||
fn column(n: usize, h: f32, gap: f32, from: usize) -> Reorder {
|
||||
let rects = (0..n)
|
||||
.map(|i| Bounds {
|
||||
origin: point(px(0.), px(i as f32 * (h + gap))),
|
||||
size: size(px(200.), px(h)),
|
||||
})
|
||||
.collect();
|
||||
Reorder::new(
|
||||
Surface::Strip,
|
||||
from,
|
||||
rects,
|
||||
Axis::Vertical,
|
||||
px(gap),
|
||||
// Grabbed dead centre of the slot.
|
||||
point(px(100.), px(h / 2.)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The dragged slot claims a new index the moment its centre reaches where
|
||||
/// the neighbour would sit without it, and holds its own index until then.
|
||||
#[test]
|
||||
fn target_follows_the_pointer_across_neighbours() {
|
||||
// 4 rows of 30px + 2px gaps: centres at 15, 47, 79, 111.
|
||||
let r = column(4, 30., 2., 0);
|
||||
// Dragging row 0, held by its centre. Row 1 yields once row 0's bottom
|
||||
// edge covers half of it — pointer 32, i.e. bottom edge at 47.
|
||||
assert_eq!(r.target(point(px(100.), px(32.))), 0);
|
||||
assert_eq!(r.target(point(px(100.), px(34.))), 1);
|
||||
// Then row 2's centre (79) at pointer 64, row 3's (111) at 96.
|
||||
assert_eq!(r.target(point(px(100.), px(66.))), 2);
|
||||
assert_eq!(r.target(point(px(100.), px(200.))), 3);
|
||||
}
|
||||
|
||||
/// Dragging upward is the mirror image, and the order it previews is the
|
||||
/// dragged slot lifted out and re-inserted.
|
||||
#[test]
|
||||
fn order_lifts_the_dragged_slot_into_the_target() {
|
||||
let r = column(4, 30., 2., 3);
|
||||
assert_eq!(r.target(point(px(100.), px(2.))), 0);
|
||||
assert_eq!(r.order(0), vec![3, 0, 1, 2]);
|
||||
assert_eq!(r.order(1), vec![0, 3, 1, 2]);
|
||||
assert_eq!(r.order(3), vec![0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
/// Only the slot the drag just crossed gets a slide offset, and it's the
|
||||
/// full row pitch (row height + gap) in the direction it came from.
|
||||
#[test]
|
||||
fn flip_offset_animates_only_the_slot_just_crossed() {
|
||||
let r = column(4, 30., 2., 0);
|
||||
// Preview moved from "row 0 stays" to "row 0 sits after row 1":
|
||||
// row 1 closed up by one pitch, so it starts one pitch lower.
|
||||
assert_eq!(r.flip_offset(1, 0, 1), px(32.));
|
||||
// Rows the crossing didn't touch don't move at all.
|
||||
assert_eq!(r.flip_offset(2, 0, 1), px(0.));
|
||||
assert_eq!(r.flip_offset(3, 0, 1), px(0.));
|
||||
// The dragged slot slides too, now that it rides in the list rather
|
||||
// than floating over it: three rows crossed, three pitches to travel.
|
||||
assert_eq!(r.flip_offset(0, 0, 3), px(-96.));
|
||||
assert_eq!(r.flip_offset(0, 3, 0), px(96.));
|
||||
// Backing out again slides row 1 the other way.
|
||||
assert_eq!(r.flip_offset(1, 1, 0), px(-32.));
|
||||
}
|
||||
|
||||
/// A tall block and a short one swap in *both* directions, with the same
|
||||
/// half-overlap threshold. The regression this pins: under a
|
||||
/// centre-to-centre test a three-row group could never move above a
|
||||
/// one-row group, because reaching its centre meant dragging the pointer
|
||||
/// off the top of the list.
|
||||
#[test]
|
||||
fn unequal_sizes_swap_in_both_directions() {
|
||||
// A 60px block at y=0 and a 140px block at y=62 (2px gap).
|
||||
let rects = vec![
|
||||
Bounds {
|
||||
origin: point(px(0.), px(0.)),
|
||||
size: size(px(200.), px(60.)),
|
||||
},
|
||||
Bounds {
|
||||
origin: point(px(0.), px(62.)),
|
||||
size: size(px(200.), px(140.)),
|
||||
},
|
||||
];
|
||||
let grab = point(px(100.), px(10.));
|
||||
let tall = Reorder::new(
|
||||
Surface::SidebarGroups,
|
||||
1,
|
||||
rects.clone(),
|
||||
Axis::Vertical,
|
||||
px(2.),
|
||||
grab,
|
||||
);
|
||||
// Dragging the tall block up: it takes the top slot once its leading
|
||||
// edge passes the short block's centre (30) — pointer 40, i.e. ~30px
|
||||
// of travel from rest, all of it well inside the list.
|
||||
assert_eq!(tall.target(point(px(100.), px(41.))), 1);
|
||||
assert_eq!(tall.target(point(px(100.), px(39.))), 0);
|
||||
// Held at rest, it keeps its own slot.
|
||||
assert_eq!(tall.target(point(px(100.), px(72.))), 1);
|
||||
|
||||
// And the short block still goes down past the tall one, at the same
|
||||
// half-overlap rule: trailing edge (pointer + 50) past centre 132.
|
||||
let short = Reorder::new(
|
||||
Surface::SidebarGroups,
|
||||
0,
|
||||
rects,
|
||||
Axis::Vertical,
|
||||
px(2.),
|
||||
grab,
|
||||
);
|
||||
assert_eq!(short.target(point(px(100.), px(80.))), 0);
|
||||
assert_eq!(short.target(point(px(100.), px(84.))), 1);
|
||||
}
|
||||
|
||||
/// The held item tracks the pointer pixel for pixel, measured from
|
||||
/// whichever slot the list has currently laid it out in — so it sits under
|
||||
/// the cursor both before and after a crossing re-slots it.
|
||||
#[test]
|
||||
fn held_offset_tracks_the_pointer_across_a_crossing() {
|
||||
// 4 rows of 30px + 2px gaps (pitch 32), grabbed dead centre of row 0.
|
||||
let r = column(4, 30., 2., 0);
|
||||
// Nudged 10px down, still target 0: the row is 10px off its home slot.
|
||||
assert_eq!(r.held_offset(point(px(100.), px(25.)), 0), px(10.));
|
||||
// Just past row 1's centre the target flips to 1, and the row is now
|
||||
// laid out one pitch lower — so the same pointer reads 32px less.
|
||||
assert_eq!(r.held_offset(point(px(100.), px(48.)), 1), px(1.));
|
||||
// Dragging far past the end parks it against the last slot instead of
|
||||
// running off: row 3 starts at 96, so that's the furthest it goes.
|
||||
assert_eq!(r.held_offset(point(px(100.), px(900.)), 3), px(0.));
|
||||
}
|
||||
|
||||
/// Only what the last drawn frame recorded may commit. The regression this
|
||||
/// pins: dragging an item away and then back to its own slot stops the
|
||||
/// surface recording (the move became a no-op), so without the per-frame
|
||||
/// clear the earlier "moved" order would survive and be applied on release,
|
||||
/// moving an item the user had visibly put back.
|
||||
#[test]
|
||||
fn pending_only_survives_the_frame_that_recorded_it() {
|
||||
let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0))));
|
||||
let mine = Surface::Strip;
|
||||
|
||||
// A frame that previews a move records it.
|
||||
clear_pending(&state);
|
||||
set_pending(&state, &mine, vec![1, 0, 2]);
|
||||
// Another surface's recording is ignored — one drag, one owner.
|
||||
set_pending(&state, &Surface::SidebarGroups, vec![2, 1, 0]);
|
||||
|
||||
// The next frame draws the item back in its own slot and records
|
||||
// nothing; the earlier order must not outlive it.
|
||||
clear_pending(&state);
|
||||
assert_eq!(take_pending(&state), None);
|
||||
// Taking also retires the drag.
|
||||
assert!(state.borrow().is_none());
|
||||
|
||||
// And the ordinary path: recorded, then released.
|
||||
*state.borrow_mut() = Some(column(3, 30., 2., 0));
|
||||
clear_pending(&state);
|
||||
set_pending(&state, &mine, vec![1, 0, 2]);
|
||||
assert_eq!(take_pending(&state), Some(vec![1, 0, 2]));
|
||||
}
|
||||
|
||||
/// The generation only advances when the preview actually changes, so a
|
||||
/// jittering cursor inside one slot doesn't restart the slide every frame.
|
||||
#[test]
|
||||
fn begin_frame_bumps_the_generation_only_on_change() {
|
||||
let r = column(3, 30., 2., 0);
|
||||
assert_eq!(r.begin_frame(0), (0, 0));
|
||||
assert_eq!(r.begin_frame(1), (1, 0));
|
||||
assert_eq!(r.begin_frame(1), (1, 1));
|
||||
assert_eq!(r.begin_frame(2), (2, 1));
|
||||
}
|
||||
|
||||
/// A horizontal list measures along x — the strip's chips, which are wider
|
||||
/// than they are tall and vary in width.
|
||||
#[test]
|
||||
fn horizontal_lists_measure_along_x() {
|
||||
let widths = [100., 160., 120.];
|
||||
let mut x = 0.;
|
||||
let rects = widths
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let b = Bounds {
|
||||
origin: point(px(x), px(0.)),
|
||||
size: size(px(*w), px(30.)),
|
||||
};
|
||||
x += w + 6.;
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
let r = Reorder::new(
|
||||
Surface::Strip,
|
||||
0,
|
||||
rects,
|
||||
Axis::Horizontal,
|
||||
px(6.),
|
||||
point(px(50.), px(15.)),
|
||||
);
|
||||
// Chip 1's centre is at x=186; chip 0 takes its slot once its trailing
|
||||
// edge (pointer + 50) covers half of it.
|
||||
assert_eq!(r.target(point(px(135.), px(15.))), 0);
|
||||
assert_eq!(r.target(point(px(137.), px(15.))), 1);
|
||||
assert_eq!(r.order(2), vec![1, 2, 0]);
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Theme",
|
||||
keywords: "appearance color colours scheme dark light palette background foreground accent",
|
||||
keywords: "appearance color colours scheme dark light palette background foreground accent sync system os auto follow",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
@@ -177,6 +177,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "Forward SSH loopback links",
|
||||
keywords: "ssh remote port tunnel localhost forward",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Smart selection",
|
||||
keywords: "double click word url path select semantic",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Copy on select",
|
||||
@@ -355,17 +360,23 @@ pub(crate) struct SettingsState {
|
||||
pub(crate) shell_args_input: Entity<InputState>,
|
||||
/// Custom working-directory path (used when the strategy is `Custom`).
|
||||
pub(crate) wd_path_input: Entity<InputState>,
|
||||
/// Command template run when ⌘-clicking a file link (Links section). Empty
|
||||
/// clears the override, restoring the built-in "open in default app".
|
||||
pub(crate) link_file_command_input: Entity<InputState>,
|
||||
/// Mouse-scroll multiplier slider (Terminal section).
|
||||
pub(crate) scroll_slider: Entity<SliderState>,
|
||||
/// Global window-opacity slider (Appearance's Window section). Shows the
|
||||
/// effective value; dragging sets the config override.
|
||||
pub(crate) window_opacity_slider: Entity<SliderState>,
|
||||
/// The color editor for the active editable theme, or `None` when the active
|
||||
/// theme is read-only (a built-in / import) or the system is being followed.
|
||||
/// The color editor for the effective (on-screen) theme, or `None` when
|
||||
/// that theme is read-only (a built-in / import).
|
||||
pub(crate) theme_editor: Option<ThemeEditor>,
|
||||
/// Whether the theme picker panel is open beside the content pane
|
||||
/// (Appearance section only). Toggled from the "Current theme" card.
|
||||
/// (Appearance section only). Toggled from the theme card(s).
|
||||
pub(crate) theme_panel_open: bool,
|
||||
/// Which theme choice the open picker panel writes to (see [`ThemeSlot`]).
|
||||
/// Set by the card that opened the panel.
|
||||
pub(crate) theme_panel_slot: ThemeSlot,
|
||||
/// Live filter for the theme picker panel's list.
|
||||
pub(crate) theme_search: Entity<InputState>,
|
||||
/// `Some` while a Keybindings row is capturing a new shortcut: the action
|
||||
@@ -403,6 +414,16 @@ pub(crate) struct SettingsState {
|
||||
pub(crate) _subs: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// The theme choice a picker card / the picker panel targets. `Manual` is the
|
||||
/// single `Config::theme_preset` (sync-with-system off); `Light` / `Dark` are
|
||||
/// the two follow-system slots (`Config::theme_preset_light` / `_dark`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ThemeSlot {
|
||||
Manual,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// The SSH section's right-pane selection (see [`SettingsState::ssh_detail`]).
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SshDetail {
|
||||
@@ -465,6 +486,7 @@ pub(crate) struct SshProfileForm {
|
||||
agent_forward: bool,
|
||||
x11: bool,
|
||||
skip_banner: bool,
|
||||
shell_integration: bool,
|
||||
verify_host_keys: Option<bool>,
|
||||
warn_on_close: Option<bool>,
|
||||
|
||||
@@ -1191,7 +1213,7 @@ impl Tty7App {
|
||||
"Pick a color theme. Each one sets its own light or dark look.",
|
||||
cx,
|
||||
))
|
||||
.child(self.render_current_theme(cx))
|
||||
.child(self.render_theme_selection(cx))
|
||||
// Custom-theme management (duplicate / edit colors / open folder) is
|
||||
// *about* themes, so it lives with the picker rather than stranded at
|
||||
// the foot of the page after Cursor.
|
||||
@@ -1266,7 +1288,7 @@ impl Tty7App {
|
||||
};
|
||||
let config = cx.global::<Config>();
|
||||
let overridden = config.window_opacity.is_some() || config.window_blur.is_some();
|
||||
let theme = presets::by_id(cx, &config.theme_preset.clone());
|
||||
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
|
||||
let opacity = Tty7App::effective_window_opacity(cx);
|
||||
let blur = cx.global::<Config>().window_blur.unwrap_or(theme.blur);
|
||||
|
||||
@@ -1349,7 +1371,7 @@ impl Tty7App {
|
||||
|
||||
// The theme's current image, for the filename label and the
|
||||
// opacity readout (the slider owns its own thumb position).
|
||||
let theme = presets::by_id(cx, &cx.global::<Config>().theme_preset.clone());
|
||||
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
|
||||
let image = theme.image.clone();
|
||||
let image_name = image.as_ref().map(|i| {
|
||||
i.path
|
||||
@@ -1936,6 +1958,7 @@ impl Tty7App {
|
||||
agent_forward: profile.agent_forward,
|
||||
x11: profile.x11,
|
||||
skip_banner: profile.skip_banner,
|
||||
shell_integration: profile.shell_integration,
|
||||
verify_host_keys: profile.verify_host_keys,
|
||||
warn_on_close: profile.warn_on_close,
|
||||
_subs: subs,
|
||||
@@ -1989,6 +2012,7 @@ impl Tty7App {
|
||||
connect_timeout_s: val(&form.connect_timeout).parse().ok(),
|
||||
warn_on_close: form.warn_on_close,
|
||||
skip_banner: form.skip_banner,
|
||||
shell_integration: form.shell_integration,
|
||||
login_scripts: split_lines(&form.login_scripts.read(cx).value()),
|
||||
x11: form.x11,
|
||||
algorithms: Algorithms {
|
||||
@@ -2555,6 +2579,22 @@ impl Tty7App {
|
||||
cx,
|
||||
),
|
||||
)
|
||||
.child(
|
||||
self.settings_row(
|
||||
"Shell integration",
|
||||
"Let the remote shell report prompts, exit codes and directory.",
|
||||
Switch::new("ssh-form-shell-integration")
|
||||
.checked(form.shell_integration)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| {
|
||||
if let Some(f) = this.ssh_form_mut() {
|
||||
f.shell_integration = *on;
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
),
|
||||
)
|
||||
.child(text_row(
|
||||
self,
|
||||
"Login scripts",
|
||||
@@ -2750,6 +2790,8 @@ impl Tty7App {
|
||||
let clip_trim = cfg.clipboard_trim_trailing_spaces;
|
||||
let copy_on_select = cfg.copy_on_select;
|
||||
let mouse_reporting = cfg.mouse_reporting;
|
||||
let smart_select = cfg.smart_select;
|
||||
let tab_completion = cfg.tab_completion;
|
||||
let bell = cfg.bell;
|
||||
// Map the persisted threshold onto its preset radio index (nearest slot
|
||||
// for any off-preset value a hand-edit might leave).
|
||||
@@ -2775,6 +2817,10 @@ impl Tty7App {
|
||||
Some(s) => s.scroll_slider.clone(),
|
||||
None => return div().into_any_element(),
|
||||
};
|
||||
let link_file_command_input = match self.active_settings() {
|
||||
Some(s) => s.link_file_command_input.clone(),
|
||||
None => return div().into_any_element(),
|
||||
};
|
||||
|
||||
let link_switch = Switch::new("term-link-url")
|
||||
.checked(link_url)
|
||||
@@ -2784,6 +2830,10 @@ impl Tty7App {
|
||||
.checked(ssh_loopback_forward)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_loopback_forward(*on, cx)))
|
||||
.into_any_element();
|
||||
let link_file_command_control = div()
|
||||
.w(px(300.))
|
||||
.child(Input::new(&link_file_command_input).small())
|
||||
.into_any_element();
|
||||
let scrollback_radio = self.segmented(
|
||||
"term-scrollback",
|
||||
&["1,000", "10,000", "100,000"],
|
||||
@@ -2835,6 +2885,14 @@ impl Tty7App {
|
||||
.checked(mouse_reporting)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx)))
|
||||
.into_any_element();
|
||||
let smart_select_switch = Switch::new("term-smart-select")
|
||||
.checked(smart_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let tab_completion_switch = Switch::new("term-tab-completion")
|
||||
.checked(tab_completion)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx)))
|
||||
.into_any_element();
|
||||
let bell_idx = match bell {
|
||||
BellMode::None => 0,
|
||||
BellMode::Visual => 1,
|
||||
@@ -2935,11 +2993,22 @@ impl Tty7App {
|
||||
mouse_report_switch,
|
||||
cx,
|
||||
))
|
||||
.when_some(option_alt_row, |v, row| {
|
||||
v.child(self.section_rule(cx))
|
||||
.child(self.section_header("Keyboard", cx))
|
||||
.child(row)
|
||||
})
|
||||
.child(self.settings_row(
|
||||
"Smart selection",
|
||||
"Double-click selects the whole URL, file path, email, or bracket pair under the cursor.",
|
||||
smart_select_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Keyboard", cx))
|
||||
.child(self.settings_row(
|
||||
"Tab completion",
|
||||
"Tab at the prompt opens tty7's completion menu. When off, Tab goes to the \
|
||||
shell's own completion instead.",
|
||||
tab_completion_switch,
|
||||
cx,
|
||||
))
|
||||
.when_some(option_alt_row, |v, row| v.child(row))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Links", cx))
|
||||
.child(self.settings_row(
|
||||
@@ -2954,6 +3023,14 @@ impl Tty7App {
|
||||
ssh_loopback_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Open files with",
|
||||
"Command run when ⌘-clicking a file link, instead of the default app. \
|
||||
Use {path}, {line}, {column}; a flag whose value is absent is dropped \
|
||||
(e.g. herdr edit {path} --line={line}). Empty uses the default app.",
|
||||
link_file_command_control,
|
||||
cx,
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Clipboard", cx))
|
||||
.child(self.settings_row(
|
||||
@@ -3018,7 +3095,7 @@ impl Tty7App {
|
||||
let (dot_color, status_text) = match state {
|
||||
HooksState::NotInstalled => (muted_fg, "Not installed"),
|
||||
HooksState::Installed => (success, "Installed"),
|
||||
HooksState::Outdated => (warning, "Outdated — installed by another tty7 version"),
|
||||
HooksState::Outdated => (warning, "Outdated"),
|
||||
};
|
||||
// The primary action reads as what it will *do* from this state.
|
||||
let primary_label = match state {
|
||||
@@ -3031,8 +3108,11 @@ impl Tty7App {
|
||||
.filter(|(for_agent, _)| *for_agent == agent)
|
||||
.map(|(_, text)| text.clone());
|
||||
|
||||
// items_end: the whole stack shares the row's right edge, so
|
||||
// status, buttons, and note line up across every agent row.
|
||||
let control = v_flex()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -3062,8 +3142,18 @@ impl Tty7App {
|
||||
)
|
||||
}),
|
||||
)
|
||||
// Width-capped so a long note (error text) wraps instead of
|
||||
// inflating the shrink-proof control column and crushing the
|
||||
// label to zero width.
|
||||
.when_some(row_note, |col, text| {
|
||||
col.child(div().text_xs().text_color(muted_fg).child(text))
|
||||
col.child(
|
||||
div()
|
||||
.max_w_80()
|
||||
.text_xs()
|
||||
.text_right()
|
||||
.text_color(muted_fg)
|
||||
.child(text),
|
||||
)
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
@@ -3277,11 +3367,39 @@ impl Tty7App {
|
||||
)
|
||||
}
|
||||
|
||||
/// The compact "Current theme" card on the Appearance page: a preview of the
|
||||
/// active theme beside its kind (built-in vs custom) and light/dark mode,
|
||||
/// its name, and its six chromatic ANSI swatches; the whole row a click
|
||||
/// target that opens the picker panel on the right.
|
||||
fn render_current_theme(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
/// The theme choice block on the Appearance page: the "Sync with system"
|
||||
/// switch, then either the single manual-theme card or — while following
|
||||
/// the OS — one card per light/dark slot.
|
||||
fn render_theme_selection(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let follow = cx.global::<Config>().theme_follow_system;
|
||||
let follow_switch = Switch::new("theme-follow-system")
|
||||
.checked(follow)
|
||||
.on_click(cx.listener(|this, on: &bool, window, cx| {
|
||||
this.set_theme_follow_system(*on, window, cx)
|
||||
}))
|
||||
.into_any_element();
|
||||
let root = v_flex().child(self.settings_row(
|
||||
"Sync with system",
|
||||
"Follow the OS appearance with separate light and dark themes.",
|
||||
follow_switch,
|
||||
cx,
|
||||
));
|
||||
if follow {
|
||||
root.child(self.render_theme_card(ThemeSlot::Light, cx))
|
||||
.child(self.render_theme_card(ThemeSlot::Dark, cx))
|
||||
.into_any_element()
|
||||
} else {
|
||||
root.child(self.render_theme_card(ThemeSlot::Manual, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
/// One compact theme card: a preview of the slot's theme beside its caption
|
||||
/// (kind + light/dark mode for the manual card, the slot's role for the
|
||||
/// follow-system cards), its name, and its six chromatic ANSI swatches; the
|
||||
/// whole row a click target that opens the picker panel on the right,
|
||||
/// aimed at this slot.
|
||||
fn render_theme_card(&self, slot: ThemeSlot, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let border = theme.border;
|
||||
let foreground = theme.foreground;
|
||||
@@ -3289,16 +3407,36 @@ impl Tty7App {
|
||||
let hover_bg = theme.secondary.opacity(0.5);
|
||||
let surface = theme.secondary.opacity(0.28);
|
||||
|
||||
let active_id = cx.global::<Config>().theme_preset.clone();
|
||||
let config = cx.global::<Config>();
|
||||
let (card_id, active_id) = match slot {
|
||||
ThemeSlot::Manual => ("theme-card-manual", config.theme_preset.clone()),
|
||||
ThemeSlot::Light => ("theme-card-light", config.theme_preset_light.clone()),
|
||||
ThemeSlot::Dark => ("theme-card-dark", config.theme_preset_dark.clone()),
|
||||
};
|
||||
let active = presets::by_id(cx, &active_id);
|
||||
let name = active.name.clone();
|
||||
let mode = if active.dark { "Dark" } else { "Light" };
|
||||
// A user file (duplicated or dropped in the themes folder) vs a built-in.
|
||||
let kind = if active.path.is_some() {
|
||||
"Custom"
|
||||
} else {
|
||||
"Built-in"
|
||||
};
|
||||
let caption = match slot {
|
||||
ThemeSlot::Manual => {
|
||||
let mode = if active.dark { "Dark" } else { "Light" };
|
||||
format!("{kind} · {mode}")
|
||||
}
|
||||
// The slot cards are captioned by their role; the one matching the
|
||||
// current OS appearance is the theme actually on screen.
|
||||
ThemeSlot::Light if !crate::ui::theme::system_dark(cx) => {
|
||||
format!("Light mode · {kind} · Active")
|
||||
}
|
||||
ThemeSlot::Light => format!("Light mode · {kind}"),
|
||||
ThemeSlot::Dark if crate::ui::theme::system_dark(cx) => {
|
||||
format!("Dark mode · {kind} · Active")
|
||||
}
|
||||
ThemeSlot::Dark => format!("Dark mode · {kind}"),
|
||||
};
|
||||
// The six chromatic ANSI slots (red…cyan) as tiny swatches — the part of
|
||||
// a theme the mini preview's few bars can't show, and what actually
|
||||
// distinguishes two same-background themes at a glance.
|
||||
@@ -3311,15 +3449,17 @@ impl Tty7App {
|
||||
.bg(rgb(to_u32(active.ansi16[i])))
|
||||
}));
|
||||
let preview = self.theme_preview(&active);
|
||||
let open = self.active_settings().is_some_and(|s| s.theme_panel_open);
|
||||
let open = self
|
||||
.active_settings()
|
||||
.is_some_and(|s| s.theme_panel_open && s.theme_panel_slot == slot);
|
||||
|
||||
div()
|
||||
.id("current-theme")
|
||||
.id(card_id)
|
||||
.mt_1()
|
||||
.mb_2()
|
||||
.w_full()
|
||||
.cursor_pointer()
|
||||
.on_click(cx.listener(|this, _, _w, cx| this.toggle_theme_panel(cx)))
|
||||
.on_click(cx.listener(move |this, _, _w, cx| this.toggle_theme_panel(slot, cx)))
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
@@ -3338,12 +3478,7 @@ impl Tty7App {
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(muted_fg)
|
||||
.child(format!("{kind} · {mode}")),
|
||||
)
|
||||
.child(div().text_xs().text_color(muted_fg).child(caption))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
@@ -3379,14 +3514,34 @@ impl Tty7App {
|
||||
// as its own surface rather than an extension of the page.
|
||||
let bg = theme.sidebar;
|
||||
|
||||
let active_id = cx.global::<Config>().theme_preset.clone();
|
||||
let (search, query) = match self.active_settings() {
|
||||
let (search, query, slot) = match self.active_settings() {
|
||||
Some(s) => (
|
||||
s.theme_search.clone(),
|
||||
s.theme_search.read(cx).value().trim().to_lowercase(),
|
||||
s.theme_panel_slot,
|
||||
),
|
||||
None => return div().into_any_element(),
|
||||
};
|
||||
let config = cx.global::<Config>();
|
||||
// Guard against a slot that no longer exists in the current mode (the
|
||||
// sync switch flipped while the panel was open re-aims it, but stale
|
||||
// state must still render something sensible).
|
||||
let slot = match (config.theme_follow_system, slot) {
|
||||
(false, _) => ThemeSlot::Manual,
|
||||
(true, ThemeSlot::Manual) => {
|
||||
if crate::ui::theme::system_dark(cx) {
|
||||
ThemeSlot::Dark
|
||||
} else {
|
||||
ThemeSlot::Light
|
||||
}
|
||||
}
|
||||
(true, s) => s,
|
||||
};
|
||||
let active_id = match slot {
|
||||
ThemeSlot::Manual => config.theme_preset.clone(),
|
||||
ThemeSlot::Light => config.theme_preset_light.clone(),
|
||||
ThemeSlot::Dark => config.theme_preset_dark.clone(),
|
||||
};
|
||||
|
||||
let header = h_flex()
|
||||
.items_center()
|
||||
@@ -3414,7 +3569,11 @@ impl Tty7App {
|
||||
.pb_3()
|
||||
.text_xs()
|
||||
.text_color(muted_fg)
|
||||
.child("Change your current theme.");
|
||||
.child(match slot {
|
||||
ThemeSlot::Manual => "Change your current theme.",
|
||||
ThemeSlot::Light => "Choose the theme for light mode.",
|
||||
ThemeSlot::Dark => "Choose the theme for dark mode.",
|
||||
});
|
||||
|
||||
// Plain text input, the same shape the Shell section uses — our own
|
||||
// field, not a bespoke pill. The Input fills its parent, but a percent
|
||||
@@ -3483,8 +3642,10 @@ impl Tty7App {
|
||||
s.child(Icon::new(IconName::Check).small().text_color(foreground))
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.set_preset(&click_id, window, cx)
|
||||
.on_click(cx.listener(move |this, _, window, cx| match slot {
|
||||
ThemeSlot::Manual => this.set_preset(&click_id, window, cx),
|
||||
ThemeSlot::Light => this.set_slot_preset(false, &click_id, window, cx),
|
||||
ThemeSlot::Dark => this.set_slot_preset(true, &click_id, window, cx),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ fn build_spec_inner(
|
||||
term: "xterm-256color".to_string(),
|
||||
verify_host_keys: profile.verify_host_keys.unwrap_or(global_verify_host_keys),
|
||||
skip_banner: profile.skip_banner,
|
||||
shell_integration: profile.shell_integration,
|
||||
login_script: profile.login_scripts.clone(),
|
||||
display_name: (!profile.name.is_empty()).then(|| profile.name.clone()),
|
||||
profile_id: Some(profile.id.to_string()),
|
||||
|
||||
@@ -5,29 +5,35 @@
|
||||
//!
|
||||
//! Split out of `app.rs` as an `impl Tty7App` block, exactly like `tab_strip`.
|
||||
//! It shares the model wholesale: the same `self.tabs`/`self.active` state, the
|
||||
//! same `tab_label`, the same `activate`/`close_tab`/`move_tab`/`start_rename`
|
||||
//! operations, the same `DragTab` payload, and the same theme tokens the chips
|
||||
//! use — so the vertical list stays pixel-consistent with the strip and adds no
|
||||
//! new state or business logic, only a new set of click targets in a new shape.
|
||||
//! same `tab_label`, the same `activate`/`close_tab`/`start_rename` operations,
|
||||
//! the same `DragTab` payload and reorder machinery, and the same theme tokens
|
||||
//! the chips use — so the vertical list stays pixel-consistent with the strip
|
||||
//! and adds no new state or business logic, only a new set of click targets in
|
||||
//! a new shape.
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Bounds, Context, FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, Pixels, SharedString, Window, canvas, div, prelude::*, px,
|
||||
Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window,
|
||||
WindowControlArea, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient,
|
||||
prelude::*, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::menu::ContextMenuExt as _;
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
use std::cell::Cell;
|
||||
use gpui_component::menu::{ContextMenu, ContextMenuExt as _};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::core::config::{Config, SidebarGrouping};
|
||||
use crate::terminal::git_status::GitStatusCache;
|
||||
use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App};
|
||||
use crate::ui::hints::tab_badge_label;
|
||||
use crate::ui::tab_strip::DragTab;
|
||||
use crate::ui::reorder::{self, Reorder, Surface};
|
||||
use crate::ui::tab_strip::{DragTab, REORDER_SLIDE_MS};
|
||||
|
||||
/// Minimum sidebar width, and the maximum as a fraction of the window width, so
|
||||
/// a resize drag can't collapse the rail or let it swallow the terminal.
|
||||
@@ -39,6 +45,27 @@ const MAX_SIDEBAR_WIDTH_RATIO: f32 = 0.5;
|
||||
/// drag. Centered (half overhangs the body) so it clears the row close buttons.
|
||||
const RESIZE_HANDLE_WIDTH: f32 = 8.;
|
||||
|
||||
/// Gap between rows in the rail, and between the group blocks — the distance a
|
||||
/// row or block travels on top of its own height when a drag passes it.
|
||||
const ROW_GAP: f32 = 2.;
|
||||
|
||||
/// Marks a live drag as a *group* drag — the sidebar counterpart to
|
||||
/// [`DragTab`], and like it a stateless marker that renders nothing: the block
|
||||
/// being dragged never leaves the rail, so there is no card floating over the
|
||||
/// window. Its type is what tells the rail's drop handlers "this is a group,
|
||||
/// not a tab". Scratch never starts one: it's pinned last by
|
||||
/// [`sidebar_sections`], so it has no slot to move to.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DragGroup;
|
||||
|
||||
impl Render for DragGroup {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// gpui always paints *something* at the cursor for an active drag;
|
||||
// an empty, zero-sized element is how this drag paints nothing.
|
||||
div()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tty7App {
|
||||
/// The vertical tab sidebar rendered down the left edge of the body in
|
||||
/// `tab_bar_position: left` mode. Only reached when at least one tab is open
|
||||
@@ -92,24 +119,105 @@ impl Tty7App {
|
||||
let keys: Rc<Vec<Option<PathBuf>>> = Rc::new(self.sidebar_group_keys(cx));
|
||||
let sections = sidebar_sections(&keys);
|
||||
|
||||
// Each row's position in display order — the digit its ⌘N badge
|
||||
// shows. Advanced for every tab, filtered-out ones included, so the
|
||||
// digits (and what ⌘N targets) don't shift while the search box
|
||||
// narrows the list.
|
||||
let mut visual_pos = 0usize;
|
||||
for (group_name, idxs) in sections {
|
||||
// Rows first: the search filter may empty a group, in which case
|
||||
// its header is skipped too (and the header's count reflects the
|
||||
// *visible* rows while a filter narrows the list).
|
||||
let mut rows: Vec<AnyElement> = Vec::new();
|
||||
for i in idxs {
|
||||
// This row's display-order digit; claimed before the search
|
||||
// filter can skip the row (see `visual_pos` above).
|
||||
let badge_pos = visual_pos;
|
||||
visual_pos += 1;
|
||||
// Each row's position in display order — the digit its ⌘N badge shows.
|
||||
// Claimed for every tab, filtered-out ones included, and read off this
|
||||
// map rather than counted as rows are emitted, so neither the search
|
||||
// box nor a drag's live reflow can renumber the shortcuts under you.
|
||||
let badge_pos: Vec<usize> = {
|
||||
let mut pos = vec![0usize; self.tabs.len()];
|
||||
for (n, i) in sections.iter().flat_map(|s| s.tabs.iter()).enumerate() {
|
||||
pos[*i] = n;
|
||||
}
|
||||
pos
|
||||
};
|
||||
|
||||
// Which tabs each section actually lists, and their labels. Settled
|
||||
// before anything is laid out, because both drag surfaces are keyed to
|
||||
// what is *visible*: a group the search box has emptied isn't rendered,
|
||||
// so it must not claim a slot in the group geometry either — a phantom
|
||||
// zero-sized slot would sit at the origin and swallow every crossing.
|
||||
// (Matching is on the visible label; a row keeps its real tab index, so
|
||||
// activate/close/reorder still hit the right tab when the list is
|
||||
// narrowed.)
|
||||
let visible_by_section: Vec<Vec<(usize, String)>> = sections
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.tabs
|
||||
.iter()
|
||||
.map(|&i| (i, self.tab_label(&self.tabs[i], i, Some(window), cx)))
|
||||
.filter(|(_, label)| query.is_empty() || label.to_lowercase().contains(&query))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Live drag-reorder, part 1: the group blocks ───────────────────────
|
||||
// Repo groups can be dragged by their header to reorder the whole
|
||||
// block; Scratch can't (it's pinned last, so it has neither a slot to
|
||||
// move to nor one to give up), so the draggable slots are exactly the
|
||||
// rendered repo groups. See [`crate::ui::reorder`] for the machinery.
|
||||
let pointer = window.mouse_position();
|
||||
let rendered = |ix: &usize| !visible_by_section[*ix].is_empty();
|
||||
let repo_slots: Vec<usize> = (0..sections.len())
|
||||
.filter(|&ix| sections[ix].key.is_some())
|
||||
.filter(rendered)
|
||||
.collect();
|
||||
let repo_groups = repo_slots.len();
|
||||
let group_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
|
||||
Rc::new(RefCell::new(vec![Bounds::default(); repo_groups]));
|
||||
let group_preview =
|
||||
reorder::preview(&self.reorder, &Surface::SidebarGroups, repo_groups, pointer);
|
||||
let repo_roots: Vec<PathBuf> = repo_slots
|
||||
.iter()
|
||||
.filter_map(|&ix| sections[ix].key.clone())
|
||||
.collect();
|
||||
let slot_display: Vec<usize> = match &group_preview {
|
||||
Some(p) => {
|
||||
// Same as the rows below: record what releasing right now would
|
||||
// produce, so the commit doesn't depend on where the cursor is.
|
||||
if let (Some(from), Some(to)) = (repo_roots.get(p.from), repo_roots.get(p.target))
|
||||
&& let Some(order) = regrouped_order(&keys, from, to)
|
||||
{
|
||||
reorder::set_pending(&self.reorder, &Surface::SidebarGroups, order);
|
||||
}
|
||||
p.order.clone()
|
||||
}
|
||||
None => (0..repo_groups).collect(),
|
||||
};
|
||||
// The blocks to lay out, as `(drag slot, section)`. Repo groups lead in
|
||||
// the previewed slot order; Scratch trails them with no slot of its own.
|
||||
let mut blocks: Vec<(Option<usize>, usize)> = slot_display
|
||||
.into_iter()
|
||||
.map(|slot| (Some(slot), repo_slots[slot]))
|
||||
.collect();
|
||||
blocks.extend(
|
||||
(0..sections.len())
|
||||
.filter(|&ix| sections[ix].key.is_none())
|
||||
.filter(rendered)
|
||||
.map(|ix| (None, ix)),
|
||||
);
|
||||
|
||||
for (group_slot, group_ix) in blocks {
|
||||
let section = §ions[group_ix];
|
||||
let group_key = section.key.clone();
|
||||
// Kept as concrete elements, not `AnyElement`s: a live drag
|
||||
// restyles them (the slide-in offset), which can only be applied
|
||||
// once every row of the group has been built.
|
||||
let mut rows: Vec<ContextMenu<Stateful<Div>>> = Vec::new();
|
||||
let visible = visible_by_section[group_ix].clone();
|
||||
let visible_tabs: Vec<usize> = visible.iter().map(|(i, _)| *i).collect();
|
||||
let row_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
|
||||
Rc::new(RefCell::new(vec![Bounds::default(); visible.len()]));
|
||||
// ── Live drag-reorder, part 2: the rows of this group ─────────────
|
||||
let row_preview = reorder::preview(
|
||||
&self.reorder,
|
||||
&Surface::SidebarRows(group_key.clone()),
|
||||
visible.len(),
|
||||
pointer,
|
||||
);
|
||||
for (slot, (i, label)) in visible.into_iter().enumerate() {
|
||||
let badge_pos = badge_pos[i];
|
||||
let tab = &self.tabs[i];
|
||||
let is_active = i == active;
|
||||
let label = self.tab_label(tab, i, Some(window), cx);
|
||||
// No status/cwd text under the title: the avatar's status dot
|
||||
// already carries working/waiting/done, and the group header + the
|
||||
// trailing branch tag carry the location — a "Working…" or cwd
|
||||
@@ -192,14 +300,6 @@ impl Tty7App {
|
||||
}
|
||||
line
|
||||
});
|
||||
// Filter by the search box; matching is on the visible label. The row
|
||||
// keeps its real index `i`, so activate/close/move still hit the right
|
||||
// tab even when the list is narrowed.
|
||||
if !query.is_empty() && !label.to_lowercase().contains(&query) {
|
||||
continue;
|
||||
}
|
||||
let drag_label: SharedString = label.clone().into();
|
||||
|
||||
// Inline rename input for this tab, if it's the one being renamed —
|
||||
// the same `self.renaming` branch the strip uses, so a context-menu
|
||||
// rename works identically in either layout.
|
||||
@@ -238,26 +338,12 @@ impl Tty7App {
|
||||
)
|
||||
// Branch + diff line, when the pane sits in a git repo.
|
||||
.children(git_line)
|
||||
// Click activates. (Renaming lives in the context menu,
|
||||
// matching the strip — no double-click rename.)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _: &MouseDownEvent, window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.activate(i, window, cx);
|
||||
}),
|
||||
)
|
||||
// Drag the row by its label to reorder it (shared `DragTab`).
|
||||
.on_drag(
|
||||
DragTab {
|
||||
index: i,
|
||||
label: drag_label.clone(),
|
||||
},
|
||||
|drag, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new(|_| drag.clone())
|
||||
},
|
||||
)
|
||||
// No mouse handler of its own: activation *and* the
|
||||
// reorder drag both live on the row, and a child that
|
||||
// swallowed the press would take the label — the
|
||||
// largest part of the row — out of both. (Only the
|
||||
// diff counts inside the branch line stop the press,
|
||||
// deliberately: they're their own click target.)
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
@@ -266,6 +352,38 @@ impl Tty7App {
|
||||
// A per-row group so this row's close affordance reveals on its own
|
||||
// hover without touching siblings (same trick as the chip).
|
||||
.group(SharedString::from(format!("tab-row-{i}")))
|
||||
// A row is first of all a switch target, so the hover
|
||||
// cursor says "click me"; picking it up swaps in the
|
||||
// closed hand (see `Tty7App::render`).
|
||||
.cursor_pointer()
|
||||
// Drag anywhere on the row to reorder it (shared `DragTab`).
|
||||
// On the row, not on its label: the drag's frame of
|
||||
// reference is where the *row* was grabbed, which is what
|
||||
// the frozen geometry below measures — hang it off the
|
||||
// label and the held row rides a few pixels off the cursor,
|
||||
// skewing every crossing by that much. `slot` is the row's
|
||||
// position among the *visible* rows of its group; a drag
|
||||
// never leaves the group, so that's the whole world it
|
||||
// needs. The builder runs once, when gpui promotes the
|
||||
// press into a drag, freezing the geometry as of the last
|
||||
// painted frame.
|
||||
.on_drag(DragTab, {
|
||||
let state = self.reorder.clone();
|
||||
let slots = row_slots.clone();
|
||||
let group_key = group_key.clone();
|
||||
move |_drag, grab, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
*state.borrow_mut() = Some(Reorder::new(
|
||||
Surface::SidebarRows(group_key.clone()),
|
||||
slot,
|
||||
slots.borrow().clone(),
|
||||
Axis::Vertical,
|
||||
px(ROW_GAP),
|
||||
grab,
|
||||
));
|
||||
cx.new(|_| DragTab)
|
||||
}
|
||||
})
|
||||
.w_full()
|
||||
// Size to content with a small, uniform vertical padding: a
|
||||
// one-line shell tab is a short row, a two-line git tab
|
||||
@@ -292,20 +410,31 @@ impl Tty7App {
|
||||
s.text_color(cx.theme().sidebar_foreground)
|
||||
.hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.5)))
|
||||
})
|
||||
// Drop target: dropping a dragged row here moves it to this
|
||||
// slot — but only within the same group; a cross-group drop is
|
||||
// a no-op, since a tab's group comes from its cwd's repo, not
|
||||
// from where it sits in the list. (With grouping off all keys
|
||||
// are `None`, so the check never blocks anything.)
|
||||
.drag_over::<DragTab>(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.2)))
|
||||
.on_drop(cx.listener({
|
||||
let keys = keys.clone();
|
||||
move |this, drag: &DragTab, _window, cx| {
|
||||
if keys.get(drag.index) == keys.get(i) {
|
||||
this.move_tab(drag.index, i, cx);
|
||||
}
|
||||
}
|
||||
}))
|
||||
// Held: a light dimming so the row under your cursor reads
|
||||
// as picked up. Not a lift — it stays in the rail's plane.
|
||||
.when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| {
|
||||
s.opacity(0.75)
|
||||
})
|
||||
// Measures this row into its group's slot table — the
|
||||
// geometry a drag starting on a later frame freezes.
|
||||
// Absolute and empty, so it costs the layout nothing.
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let slots = row_slots.clone();
|
||||
move |bounds, _window, _cx| {
|
||||
if let Some(s) = slots.borrow_mut().get_mut(slot) {
|
||||
*s = bounds;
|
||||
}
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
// `inset_0`, not `size_full` — see the strip's copy of
|
||||
// this canvas for why the distinction matters.
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
// A click anywhere on the row (padding, gaps) activates it; the
|
||||
// label and close children stop propagation for their own actions.
|
||||
.on_mouse_down(
|
||||
@@ -318,101 +447,287 @@ impl Tty7App {
|
||||
// Leading avatar: agent brand mark, SSH status, or shell glyph.
|
||||
.child(self.tab_avatar(agent, agent_status, agent_unread, ssh_dot, 22., cx))
|
||||
.child(label_region)
|
||||
// Trailing slot: while the shortcut hints are armed it shows the
|
||||
// row's ⌘N switch digit; otherwise the close affordance —
|
||||
// opacity-0-until-hover on every row, active or not, so a column
|
||||
// of tabs reads clean. Space is reserved either way. The digit
|
||||
// Trailing ⌘N badge: while the shortcut hints are armed the
|
||||
// row shows its switch digit in an in-flow 20px slot (an
|
||||
// all-rows-at-once modal reflow, same as the strip). The digit
|
||||
// is the row's *display* position (`activate_visual` speaks the
|
||||
// same order), so under grouping the rail still reads 1…9 top
|
||||
// to bottom instead of scattering the tab-vector indices.
|
||||
.child(if show_badges && badge_pos < 9 {
|
||||
.when(show_badges && badge_pos < 9, |row| {
|
||||
// Bare digit, no keycap box — matches the chip badge exactly.
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(20.))
|
||||
.text_xs()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(if is_active {
|
||||
cx.theme().sidebar_accent_foreground
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(tab_badge_label(badge_pos))
|
||||
.into_any_element()
|
||||
} else {
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.opacity(0.)
|
||||
.group_hover(SharedString::from(format!("tab-row-{i}")), |s| {
|
||||
s.opacity(1.)
|
||||
})
|
||||
.child(
|
||||
Button::new(("sidebar-close", i))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.close_tab(i, window, cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
row.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(20.))
|
||||
.text_xs()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(if is_active {
|
||||
cx.theme().sidebar_accent_foreground
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(tab_badge_label(badge_pos)),
|
||||
)
|
||||
})
|
||||
// Close affordance: out of flow, so the label runs the full
|
||||
// rail width instead of always reserving a slot for a button
|
||||
// that's invisible until hover (same Safari-style float as
|
||||
// the strip's chips). On hover the ✕ sits over the *title
|
||||
// line's* right end — pinned to the row top, not centered:
|
||||
// on a two-line row a centered ✕ would straddle both lines
|
||||
// and cover the branch line's `+n −n` counts, which are a
|
||||
// click target of their own (the diff-overlay toggle). A
|
||||
// solid backing in the row's hover fill plus a short
|
||||
// gradient run-in fades covered title text out instead of
|
||||
// hard-cutting mid-glyph. Nothing reflows on hover.
|
||||
.when(!(show_badges && badge_pos < 9), |row| {
|
||||
// The row fills are composited over the rail (the accent
|
||||
// carries alpha; the inactive hover is a half-strength
|
||||
// wash), so flatten them against `sidebar` to get the
|
||||
// opaque colour the float must match.
|
||||
let backing = if is_active {
|
||||
cx.theme().sidebar.blend(cx.theme().sidebar_accent)
|
||||
} else {
|
||||
cx.theme()
|
||||
.sidebar
|
||||
.blend(cx.theme().sidebar_accent.opacity(0.5))
|
||||
};
|
||||
let mut fade_from = backing;
|
||||
fade_from.a = 0.;
|
||||
row.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
// `py_1` row padding: the 20px button covers the
|
||||
// title line exactly.
|
||||
.top(px(4.))
|
||||
.right(px(6.))
|
||||
.opacity(0.)
|
||||
.group_hover(SharedString::from(format!("tab-row-{i}")), |s| {
|
||||
s.opacity(1.)
|
||||
})
|
||||
.child(div().w(px(10.)).h(px(20.)).bg(linear_gradient(
|
||||
90.,
|
||||
linear_color_stop(fade_from, 0.),
|
||||
linear_color_stop(backing, 1.),
|
||||
)))
|
||||
.child(
|
||||
div().bg(backing).child(
|
||||
Button::new(("sidebar-close", i))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.close_tab(i, window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
// Per-tab right-click menu, shared with the strip's chips;
|
||||
// `below_wording` flips the trailing close to "Close Tabs Below"
|
||||
// to match the vertical layout.
|
||||
let menu_app = cx.entity().downgrade();
|
||||
rows.push(
|
||||
row.context_menu(move |menu, window, cx| {
|
||||
Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx)
|
||||
})
|
||||
.into_any_element(),
|
||||
);
|
||||
rows.push(row.context_menu(move |menu, window, cx| {
|
||||
Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx)
|
||||
}));
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let row_display: Vec<usize> = match &row_preview {
|
||||
Some(p) => {
|
||||
// Record the tab order releasing right now would produce, so
|
||||
// letting go applies exactly what's on screen no matter where
|
||||
// the cursor ended up (see `reorder::set_pending`).
|
||||
if let Some(order) =
|
||||
reordered_rows(&keys, &group_key, &visible_tabs, p.from, p.target)
|
||||
{
|
||||
reorder::set_pending(
|
||||
&self.reorder,
|
||||
&Surface::SidebarRows(group_key.clone()),
|
||||
order,
|
||||
);
|
||||
}
|
||||
p.order.clone()
|
||||
}
|
||||
None => (0..rows.len()).collect(),
|
||||
};
|
||||
let row_count = rows.len();
|
||||
let mut rows: Vec<Option<ContextMenu<Stateful<Div>>>> =
|
||||
rows.into_iter().map(Some).collect();
|
||||
let rows: Vec<AnyElement> = row_display
|
||||
.into_iter()
|
||||
.map(|slot| match &row_preview {
|
||||
// The row in hand: drawn wherever the cursor is holding it,
|
||||
// pixel for pixel, with no animation in the way. `deferred`
|
||||
// keeps its slot in the layout but paints it after its
|
||||
// siblings, so it passes *over* the rows it's crossing
|
||||
// instead of being clipped behind them.
|
||||
Some(p) if p.from == slot => deferred(
|
||||
rows[slot]
|
||||
.take()
|
||||
.expect("each slot emitted once")
|
||||
.relative()
|
||||
.top(p.held),
|
||||
)
|
||||
.into_any_element(),
|
||||
// Slide into place rather than teleporting. `offset` is
|
||||
// zero for every row the last crossing left alone, so one
|
||||
// row moves at a time instead of the group re-animating.
|
||||
Some(p) => {
|
||||
let offset = p.offsets[slot].as_f32();
|
||||
rows[slot]
|
||||
.take()
|
||||
.expect("each slot emitted once")
|
||||
.with_animation(
|
||||
(
|
||||
SharedString::from(format!("row-slide-{}", p.generation)),
|
||||
slot,
|
||||
),
|
||||
Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS))
|
||||
.with_easing(ease_out_quint()),
|
||||
move |el, delta| el.top(px(offset * (1. - delta))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
None => rows[slot]
|
||||
.take()
|
||||
.expect("each slot emitted once")
|
||||
.into_any_element(),
|
||||
})
|
||||
.collect();
|
||||
// Group header: the repo's directory name (or "Scratch"), small
|
||||
// and muted so it labels without competing with the rows, plus the
|
||||
// visible-row count. Not a click target — rows do the activating.
|
||||
if let Some(name) = group_name {
|
||||
list = list.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.pl_2()
|
||||
.pr_2()
|
||||
.pt_1p5()
|
||||
.pb_0p5()
|
||||
.text_size(px(11.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
// Count sits right next to the name (not pushed to the
|
||||
// rail's right edge): the name shrinks and truncates if
|
||||
// long, the count trails it as a quiet tally.
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink(1.)
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(name.to_uppercase()),
|
||||
// visible-row count. Not a click target — rows do the activating —
|
||||
// but it *is* the whole group's drag handle: drag one project name
|
||||
// and the block moves, tabs and all, with the other groups sliding
|
||||
// around it exactly as rows do inside one. Scratch (`group_key ==
|
||||
// None`) sits out: it's pinned last, so it has nowhere to go.
|
||||
let header = section.name.clone().map(|name| {
|
||||
let label: SharedString = name.to_uppercase().into();
|
||||
h_flex()
|
||||
.id(("sidebar-group", group_ix))
|
||||
.w_full()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.pl_2()
|
||||
.pr_1p5()
|
||||
.pt_1p5()
|
||||
.pb_0p5()
|
||||
.text_size(px(11.))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.when_some(group_slot, |header, slot| {
|
||||
// Unlike a row, a header does nothing on click — its
|
||||
// only affordance is the drag, so the open hand is the
|
||||
// honest hover cursor (it closes once you pick it up).
|
||||
header.cursor_grab().on_drag(DragGroup, {
|
||||
let state = self.reorder.clone();
|
||||
let slots = group_slots.clone();
|
||||
move |_drag, grab, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
*state.borrow_mut() = Some(Reorder::new(
|
||||
Surface::SidebarGroups,
|
||||
slot,
|
||||
slots.borrow().clone(),
|
||||
Axis::Vertical,
|
||||
px(ROW_GAP),
|
||||
// The header is the handle, but the *block*
|
||||
// is what moves. The header leads the block,
|
||||
// so the grab point inside the header is
|
||||
// also the grab point inside the block —
|
||||
// it passes through unchanged.
|
||||
grab,
|
||||
));
|
||||
cx.new(|_| DragGroup)
|
||||
}
|
||||
})
|
||||
})
|
||||
// Count sits right next to the name (not pushed to the
|
||||
// rail's right edge): the name shrinks and truncates if
|
||||
// long, the count trails it as a quiet tally.
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink(1.)
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(label),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().muted_foreground.opacity(0.7))
|
||||
.child(row_count.to_string()),
|
||||
)
|
||||
});
|
||||
|
||||
// One block per group — header plus its rows — so a header drag can
|
||||
// move the whole thing as a unit and measure it as one slot.
|
||||
let block = v_flex()
|
||||
.w_full()
|
||||
.gap(px(ROW_GAP))
|
||||
// Held: the block you're dragging dims, exactly as a held row
|
||||
// does — nothing lifts off the rail.
|
||||
.when(
|
||||
group_preview
|
||||
.as_ref()
|
||||
.is_some_and(|p| Some(p.from) == group_slot),
|
||||
|b| b.opacity(0.75),
|
||||
)
|
||||
.children(header)
|
||||
.children(rows)
|
||||
// Measures the block for the group-drag geometry. Only the
|
||||
// rendered repo groups hold a slot — Scratch is pinned last and
|
||||
// never moves, and a group the search box emptied isn't here at
|
||||
// all — so `group_slot` indexes that list, not `sections`.
|
||||
.when_some(group_slot, |block, slot| {
|
||||
block.child(
|
||||
canvas(
|
||||
{
|
||||
let slots = group_slots.clone();
|
||||
move |bounds, _window, _cx| {
|
||||
if let Some(s) = slots.borrow_mut().get_mut(slot) {
|
||||
*s = bounds;
|
||||
}
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().muted_foreground.opacity(0.7))
|
||||
.child(rows.len().to_string()),
|
||||
),
|
||||
);
|
||||
}
|
||||
for row in rows {
|
||||
list = list.child(row);
|
||||
}
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
});
|
||||
|
||||
list = list.child(match (&group_preview, group_slot) {
|
||||
// The block in hand tracks the cursor, painted over the ones it
|
||||
// crosses (same treatment a held row gets inside a group).
|
||||
(Some(p), Some(slot)) if p.from == slot => {
|
||||
deferred(block.relative().top(p.held)).into_any_element()
|
||||
}
|
||||
// Everything else slides; a slotless block (Scratch) never
|
||||
// moves, so it falls through to the plain block below.
|
||||
(Some(p), Some(slot)) => {
|
||||
let offset = p.offsets[slot].as_f32();
|
||||
block
|
||||
.with_animation(
|
||||
(
|
||||
SharedString::from(format!("group-slide-{}", p.generation)),
|
||||
slot,
|
||||
),
|
||||
Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS))
|
||||
.with_easing(ease_out_quint()),
|
||||
move |el, delta| el.top(px(offset * (1. - delta))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
_ => block.into_any_element(),
|
||||
});
|
||||
}
|
||||
|
||||
// The rail's own controls — new tab, and collapse — live in the top zone
|
||||
@@ -592,7 +907,37 @@ impl Tty7App {
|
||||
// so the rail reads as one panel from the very top edge. The
|
||||
// rail's controls ride its right end, on the title bar's own
|
||||
// center line — same row as the "⋯" across the window.
|
||||
.child(controls)
|
||||
//
|
||||
// The real `TitleBar` — which carries the window's drag region
|
||||
// — only spans the *right* column in this layout, so this strip
|
||||
// would be dead space you can't grab the window by. Make the
|
||||
// controls' own row act like the title bar it sits level with:
|
||||
// drag to move, double-click to zoom. Driven exactly like
|
||||
// `TitleBar` does it (and the settings overlay's stand-in
|
||||
// strip): a press arms a flag and the first *move* starts the
|
||||
// window move, so a plain click — and a double-click — still
|
||||
// lands intact, while the buttons on the right keep taking
|
||||
// their own clicks.
|
||||
.child({
|
||||
let should_move = Rc::new(Cell::new(false));
|
||||
controls
|
||||
.id("sidebar-titlebar-drag")
|
||||
.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let should_move = should_move.clone();
|
||||
move |_, _, _| should_move.set(true)
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let should_move = should_move.clone();
|
||||
move |_, _, _| should_move.set(false)
|
||||
})
|
||||
.on_mouse_move(move |_, window, _| {
|
||||
if should_move.replace(false) {
|
||||
window.start_window_move();
|
||||
}
|
||||
})
|
||||
.on_double_click(|_, window, _| window.titlebar_double_click())
|
||||
})
|
||||
.child(top_bar)
|
||||
.child(list),
|
||||
)
|
||||
@@ -643,7 +988,7 @@ impl Tty7App {
|
||||
let keys = self.sidebar_group_keys(cx);
|
||||
sidebar_sections(&keys)
|
||||
.into_iter()
|
||||
.flat_map(|(_, idxs)| idxs)
|
||||
.flat_map(|s| s.tabs)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -662,12 +1007,25 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Partition per-tab group keys into the sidebar's sections: `(header,
|
||||
/// indices)` with groups in first-appearance order (a new repo appends, the
|
||||
/// existing ones never reshuffle) and the Scratch group pinned last. A `None`
|
||||
/// header means "render flat, no headers" — used when no tab is in any repo,
|
||||
/// where a lone Scratch header over everything would be noise.
|
||||
fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<(Option<String>, Vec<usize>)> {
|
||||
/// One block of the sidebar: a header and the tabs under it.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Section {
|
||||
/// The repo root this group is keyed on, `None` for Scratch. Sections with
|
||||
/// a key are the draggable ones (Scratch is pinned last), and it doubles as
|
||||
/// the group's identity in a drag.
|
||||
key: Option<PathBuf>,
|
||||
/// The header text, or `None` for "render flat, no header".
|
||||
name: Option<String>,
|
||||
/// The group's tabs, in tab order.
|
||||
tabs: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Partition per-tab group keys into the sidebar's sections: groups in
|
||||
/// first-appearance order (a new repo appends, the existing ones never
|
||||
/// reshuffle) with the Scratch group pinned last. A nameless single section
|
||||
/// means "render flat, no headers" — used when no tab is in any repo, where a
|
||||
/// lone Scratch header over everything would be noise.
|
||||
fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<Section> {
|
||||
let mut group_order: Vec<&PathBuf> = Vec::new();
|
||||
for k in keys.iter().flatten() {
|
||||
if !group_order.iter().any(|g| *g == k) {
|
||||
@@ -675,26 +1033,109 @@ fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<(Option<String>, Vec<usize>
|
||||
}
|
||||
}
|
||||
if group_order.is_empty() {
|
||||
return vec![(None, (0..keys.len()).collect())];
|
||||
return vec![Section {
|
||||
key: None,
|
||||
name: None,
|
||||
tabs: (0..keys.len()).collect(),
|
||||
}];
|
||||
}
|
||||
let names = group_names(&group_order);
|
||||
let mut sections: Vec<(Option<String>, Vec<usize>)> = group_order
|
||||
let mut sections: Vec<Section> = group_order
|
||||
.iter()
|
||||
.zip(names)
|
||||
.map(|(root, name)| {
|
||||
let idxs = (0..keys.len())
|
||||
.map(|(root, name)| Section {
|
||||
key: Some((*root).clone()),
|
||||
name: Some(name),
|
||||
tabs: (0..keys.len())
|
||||
.filter(|&i| keys[i].as_ref() == Some(*root))
|
||||
.collect();
|
||||
(Some(name), idxs)
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
let scratch: Vec<usize> = (0..keys.len()).filter(|&i| keys[i].is_none()).collect();
|
||||
if !scratch.is_empty() {
|
||||
sections.push((Some("Scratch".into()), scratch));
|
||||
sections.push(Section {
|
||||
key: None,
|
||||
name: Some("Scratch".into()),
|
||||
tabs: scratch,
|
||||
});
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
/// The tab permutation for a row dropped at a new place inside its own group:
|
||||
/// `visible` are the group's rows as the rail currently lists them (the search
|
||||
/// box may be hiding others), and the row at `from` lands where the row at `to`
|
||||
/// is now.
|
||||
///
|
||||
/// Like [`regrouped_order`] this returns a whole-vector permutation rather than
|
||||
/// a single move, because "third row in this group" only means something once
|
||||
/// the vector is laid out the way the rail draws it: groups in their existing
|
||||
/// order, each one contiguous, Scratch last. Rows the filter is hiding keep
|
||||
/// their place in the group, and no other group is disturbed.
|
||||
fn reordered_rows(
|
||||
keys: &[Option<PathBuf>],
|
||||
group: &Option<PathBuf>,
|
||||
visible: &[usize],
|
||||
from: usize,
|
||||
to: usize,
|
||||
) -> Option<Vec<usize>> {
|
||||
let (&moved, &anchor) = (visible.get(from)?, visible.get(to)?);
|
||||
if moved == anchor {
|
||||
return None;
|
||||
}
|
||||
let mut members: Vec<usize> = (0..keys.len()).filter(|&i| keys[i] == *group).collect();
|
||||
members.retain(|&i| i != moved);
|
||||
// Land it on the far side of the row it was dropped onto, so dragging down
|
||||
// ends up below that row and dragging up above it.
|
||||
let at = members.iter().position(|&i| i == anchor)? + usize::from(to > from);
|
||||
members.insert(at, moved);
|
||||
|
||||
let mut out: Vec<usize> = Vec::with_capacity(keys.len());
|
||||
for g in sidebar_sections(keys).iter().map(|s| &s.key) {
|
||||
if g == group {
|
||||
out.extend_from_slice(&members);
|
||||
} else {
|
||||
out.extend((0..keys.len()).filter(|&i| keys[i] == *g));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The tab permutation that moves the group rooted at `from` into `to`'s slot,
|
||||
/// as old indices in their new order — or `None` when the move is a no-op (same
|
||||
/// group, or either root no longer has any tab).
|
||||
///
|
||||
/// Groups are ordered by first appearance in the tab vector, so the move is
|
||||
/// "reorder the group list, then lay the tabs back out group by group". Each
|
||||
/// group therefore comes out *contiguous*, with Scratch last, matching exactly
|
||||
/// what the sidebar renders — which also settles the old caveat that a tab drag
|
||||
/// inside an interleaved group could shuffle other groups' headers: after any
|
||||
/// header drag the vector is compacted and interleaving is gone. Relative order
|
||||
/// within a group is preserved.
|
||||
fn regrouped_order(keys: &[Option<PathBuf>], from: &Path, to: &Path) -> Option<Vec<usize>> {
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let mut order: Vec<&PathBuf> = Vec::new();
|
||||
for k in keys.iter().flatten() {
|
||||
if !order.iter().any(|g| *g == k) {
|
||||
order.push(k);
|
||||
}
|
||||
}
|
||||
let fi = order.iter().position(|g| g.as_path() == from)?;
|
||||
let ti = order.iter().position(|g| g.as_path() == to)?;
|
||||
let moved = order.remove(fi);
|
||||
order.insert(ti, moved);
|
||||
|
||||
let mut out: Vec<usize> = Vec::with_capacity(keys.len());
|
||||
for g in &order {
|
||||
out.extend((0..keys.len()).filter(|&i| keys[i].as_ref() == Some(*g)));
|
||||
}
|
||||
// Scratch tabs trail the repo groups, which is where the sidebar draws them.
|
||||
out.extend((0..keys.len()).filter(|&i| keys[i].is_none()));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Display names for the group roots: each root's directory name, extended
|
||||
/// upward by parent components only while it collides with another root's
|
||||
/// (`app` stays `app` on its own; two checkouts both named `app` become
|
||||
@@ -764,17 +1205,100 @@ mod tests {
|
||||
Some(p("/w/beta")),
|
||||
];
|
||||
let sections = sidebar_sections(&keys);
|
||||
let shape: Vec<(Option<PathBuf>, Option<String>, Vec<usize>)> = sections
|
||||
.into_iter()
|
||||
.map(|s| (s.key, s.name, s.tabs))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sections,
|
||||
shape,
|
||||
vec![
|
||||
(Some("beta".into()), vec![0, 3]),
|
||||
(Some("alpha".into()), vec![2]),
|
||||
(Some("Scratch".into()), vec![1]),
|
||||
(Some(p("/w/beta")), Some("beta".into()), vec![0, 3]),
|
||||
(Some(p("/w/alpha")), Some("alpha".into()), vec![2]),
|
||||
(None, Some("Scratch".into()), vec![1]),
|
||||
]
|
||||
);
|
||||
|
||||
// No tab in any repo: one headerless section over everything.
|
||||
let flat = sidebar_sections(&[None, None]);
|
||||
assert_eq!(flat, vec![(None, vec![0, 1])]);
|
||||
assert_eq!(flat.len(), 1);
|
||||
assert_eq!(flat[0].name, None);
|
||||
assert_eq!(flat[0].tabs, vec![0, 1]);
|
||||
}
|
||||
|
||||
/// A row dropped inside its group lands on the far side of the row it was
|
||||
/// dropped onto, leaves every other group alone, and comes out with the
|
||||
/// groups laid out contiguously the way the rail draws them.
|
||||
#[test]
|
||||
fn reordered_rows_moves_within_the_group_only() {
|
||||
// alpha owns 0 and 2, interleaved with beta's 1; scratch is 3.
|
||||
let keys = vec![
|
||||
Some(p("/w/alpha")),
|
||||
Some(p("/w/beta")),
|
||||
Some(p("/w/alpha")),
|
||||
None,
|
||||
];
|
||||
let alpha = Some(p("/w/alpha"));
|
||||
// alpha's first row dragged onto its second: alpha reads [2, 0], and
|
||||
// the vector comes out grouped — alpha, beta, scratch.
|
||||
assert_eq!(
|
||||
reordered_rows(&keys, &alpha, &[0, 2], 0, 1),
|
||||
Some(vec![2, 0, 1, 3])
|
||||
);
|
||||
// Back the other way.
|
||||
assert_eq!(
|
||||
reordered_rows(&keys, &alpha, &[0, 2], 1, 0),
|
||||
Some(vec![2, 0, 1, 3])
|
||||
);
|
||||
// Dropping a row on itself changes nothing.
|
||||
assert_eq!(reordered_rows(&keys, &alpha, &[0, 2], 1, 1), None);
|
||||
}
|
||||
|
||||
/// Rows the search box is hiding aren't dragged along: the visible rows
|
||||
/// reorder among themselves and the hidden one keeps its place in the group.
|
||||
#[test]
|
||||
fn reordered_rows_leaves_filtered_out_rows_alone() {
|
||||
let keys = vec![Some(p("/w/a")), Some(p("/w/a")), Some(p("/w/a"))];
|
||||
let a = Some(p("/w/a"));
|
||||
// Only rows 0 and 2 are listed; dragging 0 past 2 puts it after row 2,
|
||||
// and row 1 stays between… where it was relative to the others.
|
||||
assert_eq!(
|
||||
reordered_rows(&keys, &a, &[0, 2], 0, 1),
|
||||
Some(vec![1, 2, 0])
|
||||
);
|
||||
}
|
||||
|
||||
/// A header drag moves the whole group into the target's slot and lays
|
||||
/// every group out contiguously, Scratch last, keeping intra-group order.
|
||||
#[test]
|
||||
fn regrouped_order_moves_the_group_into_the_target_slot() {
|
||||
// Groups by first appearance: alpha (0, 3), beta (2), gamma (4).
|
||||
let keys = vec![
|
||||
Some(p("/w/alpha")),
|
||||
None,
|
||||
Some(p("/w/beta")),
|
||||
Some(p("/w/alpha")),
|
||||
Some(p("/w/gamma")),
|
||||
];
|
||||
// gamma dropped on alpha → gamma, alpha, beta, then Scratch.
|
||||
assert_eq!(
|
||||
regrouped_order(&keys, &p("/w/gamma"), &p("/w/alpha")),
|
||||
Some(vec![4, 0, 3, 2, 1])
|
||||
);
|
||||
// alpha dropped on gamma (a move down) → beta, gamma, alpha.
|
||||
assert_eq!(
|
||||
regrouped_order(&keys, &p("/w/alpha"), &p("/w/gamma")),
|
||||
Some(vec![2, 4, 0, 3, 1])
|
||||
);
|
||||
}
|
||||
|
||||
/// Dropping a group on itself, or naming a root no tab lives in, is a
|
||||
/// no-op rather than a re-shuffle.
|
||||
#[test]
|
||||
fn regrouped_order_ignores_self_and_unknown_roots() {
|
||||
let keys = vec![Some(p("/w/alpha")), Some(p("/w/beta"))];
|
||||
assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/alpha")), None);
|
||||
assert_eq!(regrouped_order(&keys, &p("/w/gone"), &p("/w/beta")), None);
|
||||
assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/gone")), None);
|
||||
}
|
||||
|
||||
/// Same-named roots grow a parent prefix until distinct; unrelated names
|
||||
|
||||
@@ -5,19 +5,30 @@
|
||||
//! orchestration rather than chrome rendering.
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window,
|
||||
div, prelude::*, px,
|
||||
Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton,
|
||||
MouseDownEvent, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint,
|
||||
linear_color_stop, linear_gradient, prelude::*, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::actions::{OpenSettings, TogglePalette};
|
||||
use crate::core::config::{Config, RightPanelTab};
|
||||
use crate::daemon::protocol::ShellSpec;
|
||||
use crate::ui::app::{Tab, Tty7App};
|
||||
use crate::ui::hints::tab_badge_label;
|
||||
use crate::ui::reorder::{self, Reorder, Surface};
|
||||
|
||||
/// How long a slot takes to slide out of the way of a dragged tab, and the
|
||||
/// gap between chips it has to travel. Short and hard-decelerating: long
|
||||
/// enough to read as motion, short enough that a fast drag across the strip
|
||||
/// never queues up a backlog of sliding tabs.
|
||||
pub(crate) const REORDER_SLIDE_MS: u64 = 140;
|
||||
const CHIP_GAP: f32 = 6.;
|
||||
|
||||
/// How many trailing path components a deep tab label keeps, mirroring
|
||||
/// ghostty's zsh integration title `%(4~|…/%3~|%~)`: a path deeper than this
|
||||
@@ -121,28 +132,21 @@ fn short_title(raw: &str) -> String {
|
||||
label
|
||||
}
|
||||
|
||||
/// Drag payload for reordering tabs. Carries the source index and a label so the
|
||||
/// drag preview can show the tab being moved. `pub(crate)` so the vertical
|
||||
/// [`tab_sidebar`](crate::ui::tab_sidebar) reuses the same payload (and could one
|
||||
/// day support strip ↔ sidebar cross-drops via the shared `move_tab`).
|
||||
/// Marks a live drag as a *tab* drag: its type is what the rail's and strip's
|
||||
/// drop handlers match on, and its presence is what keeps gpui redrawing while
|
||||
/// the pointer moves. It deliberately carries no state and renders nothing —
|
||||
/// the tab being dragged never leaves the list, so there is no card floating
|
||||
/// over the window; the reorder is drawn entirely by the list itself (see
|
||||
/// [`crate::ui::reorder`]). `pub(crate)` so the vertical
|
||||
/// [`tab_sidebar`](crate::ui::tab_sidebar) shares the same payload.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DragTab {
|
||||
pub(crate) index: usize,
|
||||
pub(crate) label: SharedString,
|
||||
}
|
||||
pub(crate) struct DragTab;
|
||||
|
||||
impl Render for DragTab {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// gpui always paints *something* at the cursor for an active drag;
|
||||
// an empty, zero-sized element is how this drag paints nothing.
|
||||
div()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_lg()
|
||||
.bg(cx.theme().secondary)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().foreground)
|
||||
.child(self.label.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +467,9 @@ impl Tty7App {
|
||||
let spec = ShellSpec {
|
||||
program: shell.program.clone(),
|
||||
args: shell.args.clone(),
|
||||
// Every arg on a dropdown row was written by
|
||||
// `core::shells::detect_shells`, not the user.
|
||||
args_are_tty7_defaults: true,
|
||||
};
|
||||
let open = app.clone();
|
||||
let item = if shell.label == default_name {
|
||||
@@ -540,6 +547,27 @@ impl Tty7App {
|
||||
}
|
||||
}));
|
||||
|
||||
// Mark as Unread — re-arm the avatar's green Done badge so a result
|
||||
// you want to revisit nags again. Only agent tabs get the entry, and
|
||||
// only a settled (`Done`) tab has a finished turn to mark; a busier
|
||||
// status (working/waiting) owns the dot anyway, so the entry disables
|
||||
// rather than promising a badge that can't show yet.
|
||||
let tab = this.tabs.get(index);
|
||||
if tab.is_some_and(|t| t.agent(cx).is_some()) {
|
||||
let done = tab.and_then(|t| t.agent_status(cx))
|
||||
== Some(crate::core::cli_agent::AgentStatus::Done);
|
||||
menu = menu.item(
|
||||
PopupMenuItem::new("Mark as Unread")
|
||||
.disabled(!done)
|
||||
.on_click({
|
||||
let app = app.clone();
|
||||
move |_, _window, cx| {
|
||||
let _ = app.update(cx, |this, cx| this.mark_tab_unread(index, cx));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Worktree: an isolated checkout of this tab's repo on a fresh branch,
|
||||
// opened as a new tab — parallel-agent fuel. Only offered when the
|
||||
// tab's cwd actually sits in a git repository (a filesystem-only
|
||||
@@ -675,17 +703,47 @@ impl Tty7App {
|
||||
// `min_w`) and truncates their labels rather than pushing the "+" away.
|
||||
let mut chips = h_flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.gap(px(CHIP_GAP))
|
||||
.min_w_0()
|
||||
.max_w(chips_avail)
|
||||
.overflow_hidden();
|
||||
|
||||
for (i, tab) in self.tabs.iter().enumerate() {
|
||||
// ── Live drag-reorder ─────────────────────────────────────────────────
|
||||
// While a chip is being dragged the strip renders in the order a drop
|
||||
// would produce (see [`crate::ui::reorder`]): the dragged chip travels
|
||||
// along the row itself — nothing floats over the window — and every
|
||||
// chip it passes slides over to meet it. `slots` collects this frame's
|
||||
// chip geometry: the reference a drag starting on a later frame freezes.
|
||||
let slots: Rc<RefCell<Vec<Bounds<Pixels>>>> =
|
||||
Rc::new(RefCell::new(vec![Bounds::default(); self.tabs.len()]));
|
||||
let preview = reorder::preview(
|
||||
&self.reorder,
|
||||
&Surface::Strip,
|
||||
self.tabs.len(),
|
||||
window.mouse_position(),
|
||||
);
|
||||
// Display order: plain tab order, or the previewed one mid-drag. In the
|
||||
// strip a slot *is* a tab index, so the previewed order doubles as the
|
||||
// tab permutation a release would commit — recorded every frame so
|
||||
// letting go applies exactly what's on screen (see `reorder`).
|
||||
let display: Vec<usize> = match &preview {
|
||||
Some(p) => {
|
||||
reorder::set_pending(&self.reorder, &Surface::Strip, p.order.clone());
|
||||
p.order.clone()
|
||||
}
|
||||
None => (0..self.tabs.len()).collect(),
|
||||
};
|
||||
|
||||
for i in display {
|
||||
// In sidebar mode the vertical rail carries the tab list; the strip
|
||||
// keeps only its "+"/"⋯" chrome, so skip the chip row entirely.
|
||||
if !show_chips {
|
||||
break;
|
||||
}
|
||||
// The chip you're holding: still a chip in the row, just dimmed so
|
||||
// it reads as picked up while it slides between slots.
|
||||
let dragged = preview.as_ref().is_some_and(|p| p.from == i);
|
||||
let tab = &self.tabs[i];
|
||||
let is_active = i == active;
|
||||
let label = self.tab_label(tab, i, Some(window), cx);
|
||||
// SSH status dot (PRD FR-E2): coloured by the pane's connection phase.
|
||||
@@ -703,9 +761,6 @@ impl Tty7App {
|
||||
.as_ref()
|
||||
.filter(|r| r.index == i)
|
||||
.map(|r| r.input.clone());
|
||||
// Clean label (no pane-count suffix) for the rename prefill / drag preview.
|
||||
let drag_label: SharedString = label.clone().into();
|
||||
|
||||
// Either the editable input (while renaming) or the clickable,
|
||||
// draggable label.
|
||||
let label_region = match rename_input {
|
||||
@@ -731,42 +786,38 @@ impl Tty7App {
|
||||
// from the type, not from colour alone.
|
||||
.when(is_active, |d| d.font_weight(FontWeight::MEDIUM))
|
||||
.child(label)
|
||||
// Single click activates; double click zooms the window,
|
||||
// same as the rest of the titlebar. (Renaming lives in the
|
||||
// context menu.)
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
|
||||
// Swallow the event — on Windows the chip's `occlude()`
|
||||
// means it would never reach the TitleBar anyway, so we
|
||||
// forward the double-click zoom explicitly instead.
|
||||
// Caveat: gpui only implements `titlebar_double_click`
|
||||
// on macOS; on Windows/Linux it's a no-op, so the chip
|
||||
// doesn't zoom there until upstream adds support.
|
||||
cx.stop_propagation();
|
||||
if ev.click_count >= 2 {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
this.activate(i, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
// Drag the tab by its label to reorder it.
|
||||
.on_drag(
|
||||
DragTab {
|
||||
index: i,
|
||||
label: drag_label.clone(),
|
||||
},
|
||||
|drag, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.new(|_| drag.clone())
|
||||
},
|
||||
)
|
||||
// No mouse handler of its own: click and drag both live on
|
||||
// the chip, and a child that swallowed the press would take
|
||||
// the label — most of the chip — out of both.
|
||||
.into_any_element(),
|
||||
};
|
||||
|
||||
let chip = h_flex()
|
||||
.id(("tab-chip", i))
|
||||
// Drag anywhere on the chip to reorder it. On the chip, not on
|
||||
// its label: the drag's frame of reference is where the *chip*
|
||||
// was grabbed, which is what the frozen geometry below
|
||||
// measures — hang it off the label and the held chip rides
|
||||
// offset from the cursor, skewing every crossing by that much.
|
||||
// The builder runs once, when gpui promotes the press into a
|
||||
// drag, freezing the strip's geometry as of the last painted
|
||||
// frame.
|
||||
.on_drag(DragTab, {
|
||||
let state = self.reorder.clone();
|
||||
let slots = slots.clone();
|
||||
move |_drag, grab, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
*state.borrow_mut() = Some(Reorder::new(
|
||||
Surface::Strip,
|
||||
i,
|
||||
slots.borrow().clone(),
|
||||
Axis::Horizontal,
|
||||
px(CHIP_GAP),
|
||||
grab,
|
||||
));
|
||||
cx.new(|_| DragTab)
|
||||
}
|
||||
})
|
||||
// The strip lives inside gpui-component's `TitleBar`, which marks
|
||||
// its whole area as `WindowControlArea::Drag`. On Windows that maps
|
||||
// to `HTCAPTION`, so unless an element on top registers a
|
||||
@@ -779,6 +830,10 @@ impl Tty7App {
|
||||
// A group so this chip's close affordance can reveal on hover
|
||||
// (progressive disclosure) without affecting sibling tabs.
|
||||
.group(SharedString::from(format!("tab-chip-{i}")))
|
||||
// Same as the sidebar rows: a chip is a switch target first, so
|
||||
// hover says "click me" and the drag swaps in the closed hand
|
||||
// (see `Tty7App::render`).
|
||||
.cursor_pointer()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_1p5()
|
||||
@@ -809,20 +864,53 @@ impl Tty7App {
|
||||
s.text_color(cx.theme().muted_foreground)
|
||||
.hover(|s| s.bg(cx.theme().muted))
|
||||
})
|
||||
// Drop target: dropping a dragged tab here moves it to this slot.
|
||||
.drag_over::<DragTab>(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.2)))
|
||||
.on_drop(cx.listener(move |this, drag: &DragTab, _window, cx| {
|
||||
this.move_tab(drag.index, i, cx);
|
||||
}))
|
||||
// A click anywhere on the chip activates the tab. Clicks on the
|
||||
// label or close button are handled by those children (which stop
|
||||
// propagation), so this fires for the rest — icon, padding, the
|
||||
// bare chip — making the whole tab a switch target, not just text.
|
||||
// Held: a light dimming so the chip under your cursor reads as
|
||||
// picked up. Not a lift — it stays in the row's own plane.
|
||||
.when(dragged, |s| s.opacity(0.75))
|
||||
// Measures this chip into the frame's slot table. Absolute and
|
||||
// empty, so it costs the layout nothing.
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let slots = slots.clone();
|
||||
move |bounds, _window, _cx| {
|
||||
if let Some(slot) = slots.borrow_mut().get_mut(i) {
|
||||
*slot = bounds;
|
||||
}
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
// `inset_0`, not `size_full`: an absolutely-positioned
|
||||
// child with no insets is laid out at its parent's
|
||||
// *content* box, so a measuring canvas inside a padded
|
||||
// element would report an origin shifted right by the
|
||||
// left padding — and the held chip would ride that far
|
||||
// off the cursor. Pinning all four insets to 0 anchors it
|
||||
// to the padding box, which is the chip itself.
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
// A click anywhere on the chip activates the tab; a double click
|
||||
// zooms the window, as the rest of the title bar does. Both live
|
||||
// here rather than on the label so the whole chip — label, icon,
|
||||
// padding — is one switch target and one drag handle. (The close
|
||||
// button and the rename input stop the press for their own use.)
|
||||
//
|
||||
// The event is swallowed: on Windows the chip's `occlude()` means
|
||||
// it would never reach the TitleBar anyway, so the zoom is
|
||||
// forwarded explicitly. Caveat: gpui only implements
|
||||
// `titlebar_double_click` on macOS; elsewhere it's a no-op until
|
||||
// upstream adds support.
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _: &MouseDownEvent, window, cx| {
|
||||
cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
|
||||
cx.stop_propagation();
|
||||
this.activate(i, window, cx);
|
||||
if ev.click_count >= 2 {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
this.activate(i, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
// Leading SSH status dot when this tab hosts an SSH session.
|
||||
@@ -851,58 +939,110 @@ impl Tty7App {
|
||||
})
|
||||
// Clickable / editable label region.
|
||||
.child(label_region)
|
||||
// Trailing slot: normally the close affordance — kept out of the
|
||||
// way (opacity 0) on every chip, active or not, and fades in on
|
||||
// chip hover, so a row of tabs reads clean instead of
|
||||
// three-icons-per-chip busy. Space is reserved either way, so
|
||||
// nothing shifts on hover. While the shortcut hints are armed,
|
||||
// the same slot shows the tab's ⌘N badge instead.
|
||||
.child(if show_badges && i < 9 {
|
||||
// Trailing ⌘N badge: while the shortcut hints are armed the
|
||||
// badge takes an in-flow 20px slot (the strip reflows once as
|
||||
// the hints arm/disarm — a deliberate, all-chips-at-once modal
|
||||
// moment). It can't float like the close button below: badges
|
||||
// also show on unhovered inactive chips, which are transparent
|
||||
// over the window background (possibly a gradient or image), so
|
||||
// there's no solid colour to back an overlay with.
|
||||
.when(show_badges && i < 9, |chip| {
|
||||
// Bare digit, no keycap box — the hint blends into the chip
|
||||
// rather than reading as another button. Sized to the exact
|
||||
// 20px square of the close button it stands in for, so the
|
||||
// swap can never change the chip's width (an ellipsized
|
||||
// label would otherwise reflow and the strip would jitter).
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(20.))
|
||||
.text_xs()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(if is_active {
|
||||
cx.theme().foreground
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(tab_badge_label(i))
|
||||
.into_any_element()
|
||||
} else {
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.opacity(0.)
|
||||
.group_hover(SharedString::from(format!("tab-chip-{i}")), |s| {
|
||||
s.opacity(1.)
|
||||
})
|
||||
.child(
|
||||
Button::new(("tab-close", i))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.close_tab(i, window, cx);
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
// rather than reading as another button.
|
||||
chip.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(20.))
|
||||
.text_xs()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(if is_active {
|
||||
cx.theme().foreground
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(tab_badge_label(i)),
|
||||
)
|
||||
})
|
||||
// Close affordance: out of flow, so the label runs the full
|
||||
// chip width instead of always reserving a 20px slot for a
|
||||
// button that's invisible until hover. On hover the ✕ floats
|
||||
// over the label's right edge (Safari-style) on a solid backing
|
||||
// in the chip's current fill — `secondary` on the active chip,
|
||||
// `muted` on an inactive one, whose hover fill is exactly what
|
||||
// the ✕'s visibility implies — with a short gradient run-in so
|
||||
// covered text fades out instead of hard-cutting mid-glyph.
|
||||
// Nothing reflows on hover.
|
||||
.when(!(show_badges && i < 9), |chip| {
|
||||
let backing = if is_active {
|
||||
cx.theme().secondary
|
||||
} else {
|
||||
cx.theme().muted
|
||||
};
|
||||
let mut fade_from = backing;
|
||||
fade_from.a = 0.;
|
||||
chip.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.top(px(5.))
|
||||
.right(px(6.))
|
||||
.opacity(0.)
|
||||
.group_hover(SharedString::from(format!("tab-chip-{i}")), |s| {
|
||||
s.opacity(1.)
|
||||
})
|
||||
.child(div().w(px(10.)).h(px(20.)).bg(linear_gradient(
|
||||
90.,
|
||||
linear_color_stop(fade_from, 0.),
|
||||
linear_color_stop(backing, 1.),
|
||||
)))
|
||||
.child(
|
||||
div().bg(backing).child(
|
||||
Button::new(("tab-close", i))
|
||||
.icon(IconName::Close)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.close_tab(i, window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
});
|
||||
|
||||
// Per-tab right-click menu (rename / worktree / split / copy cwd /
|
||||
// close group) — the same builder the sidebar rows use.
|
||||
let menu_app = cx.entity().downgrade();
|
||||
chips = chips.child(chip.context_menu(move |menu, window, cx| {
|
||||
let chip = chip.context_menu(move |menu, window, cx| {
|
||||
Self::tab_context_menu(menu, i, false, &menu_app, window, cx)
|
||||
}));
|
||||
});
|
||||
chips = chips.child(match &preview {
|
||||
// The chip in hand: drawn wherever the cursor is holding it,
|
||||
// pixel for pixel, with no animation in the way. `deferred`
|
||||
// keeps its slot in the layout but paints it after its
|
||||
// siblings, so it passes *over* the chips it's crossing
|
||||
// instead of being clipped behind them.
|
||||
Some(p) if p.from == i => deferred(chip.relative().left(p.held)).into_any_element(),
|
||||
// A chip the drag just crossed starts the frame where it used
|
||||
// to be and eases to its new slot. `offset` is zero for every
|
||||
// chip the last crossing left alone, so this is one moving
|
||||
// chip at a time, not the whole row re-animating every frame.
|
||||
Some(p) => {
|
||||
let offset = p.offsets[i].as_f32();
|
||||
chip.with_animation(
|
||||
(
|
||||
SharedString::from(format!("chip-slide-{}", p.generation)),
|
||||
i,
|
||||
),
|
||||
Animation::new(std::time::Duration::from_millis(REORDER_SLIDE_MS))
|
||||
.with_easing(ease_out_quint()),
|
||||
move |el, delta| el.left(px(offset * (1. - delta))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
None => chip.into_any_element(),
|
||||
});
|
||||
}
|
||||
|
||||
// "+" new-tab button — click opens the shell picker. The default shell
|
||||
@@ -993,6 +1133,7 @@ impl Tty7App {
|
||||
// Only `chips` is width-capped and `overflow_hidden`, so neither button is
|
||||
// pushed off-screen no matter how many tabs are open.
|
||||
h_flex()
|
||||
.id("tab-strip")
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
// Chip mode: viewport-derived width (see `strip_w`) so the right edge —
|
||||
|
||||
@@ -87,12 +87,37 @@ pub(crate) fn window_background(bg: &presets::ActiveBackground) -> Background {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the OS is currently in dark mode, as gpui reports it. Only
|
||||
/// meaningful while the native appearance isn't pinned (see
|
||||
/// [`sync_native_appearance`]) — a pinned appearance reports the pin, not the
|
||||
/// OS setting, which is why callers gate on `Config::theme_follow_system`.
|
||||
pub(crate) fn system_dark(cx: &App) -> bool {
|
||||
matches!(
|
||||
cx.window_appearance(),
|
||||
gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark
|
||||
)
|
||||
}
|
||||
|
||||
/// The id of the theme that should be on screen right now: the light/dark
|
||||
/// slot matching the OS appearance while `Config::theme_follow_system` is on,
|
||||
/// otherwise the manual `Config::theme_preset`.
|
||||
pub(crate) fn effective_preset_id(cx: &App) -> String {
|
||||
let config = cx.global::<Config>();
|
||||
if !config.theme_follow_system {
|
||||
config.theme_preset.clone()
|
||||
} else if system_dark(cx) {
|
||||
config.theme_preset_dark.clone()
|
||||
} else {
|
||||
config.theme_preset_light.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The background appearance the window should be *created* with: Blurred when
|
||||
/// the effective theme wants blur, otherwise Transparent — never Opaque, so the
|
||||
/// opacity slider works live (see the comment in [`apply_theme`]).
|
||||
pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance {
|
||||
let config = cx.global::<Config>();
|
||||
let theme = presets::by_id(cx, &config.theme_preset);
|
||||
let theme = presets::by_id(cx, &effective_preset_id(cx));
|
||||
if config.window_blur.unwrap_or(theme.blur) {
|
||||
WindowBackgroundAppearance::Blurred
|
||||
} else {
|
||||
@@ -100,15 +125,23 @@ pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint gpui-component's `Theme` from the active color theme (selected by
|
||||
/// `Config::theme_preset`). The theme's inferred `dark` brightness picks the
|
||||
/// Paint gpui-component's `Theme` from the active color theme (resolved by
|
||||
/// [`effective_preset_id`]). The theme's inferred `dark` brightness picks the
|
||||
/// component `ThemeMode`; every shell surface is then derived from the theme's
|
||||
/// background/foreground (see `Theme::neutrals`). Also publishes the
|
||||
/// terminal-facing palette as the `ActivePalette` global so the renderer matches,
|
||||
/// and applies the theme's window opacity/blur.
|
||||
pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
let follow = cx.global::<Config>().theme_follow_system;
|
||||
// While following the OS the native pin must be released *before* the
|
||||
// theme is resolved: `effective_preset_id` reads the system appearance
|
||||
// through `effectiveAppearance`, which keeps reporting the pinned value
|
||||
// until the pin is cleared.
|
||||
if follow {
|
||||
sync_native_appearance(None);
|
||||
}
|
||||
let theme = presets::by_id(cx, &effective_preset_id(cx));
|
||||
let config = cx.global::<Config>();
|
||||
let theme = presets::by_id(cx, &config.theme_preset.clone());
|
||||
let mode = if theme.dark {
|
||||
ThemeMode::Dark
|
||||
} else {
|
||||
@@ -120,8 +153,12 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
let opacity = config.window_opacity.or(theme.opacity).filter(|o| *o < 1.0);
|
||||
let blur = config.window_blur.unwrap_or(theme.blur);
|
||||
// Force the native macOS chrome (traffic lights, system menus, scrollbars)
|
||||
// into the theme's own light/dark mode regardless of the OS setting.
|
||||
sync_native_appearance(theme.dark);
|
||||
// into the theme's own light/dark mode regardless of the OS setting —
|
||||
// only while *not* following the OS, where the chrome should track the
|
||||
// system (and pinning would blind `system_dark` to OS flips).
|
||||
if !follow {
|
||||
sync_native_appearance(Some(theme.dark));
|
||||
}
|
||||
let m = theme.neutrals();
|
||||
let active = theme.active_palette();
|
||||
|
||||
@@ -278,8 +315,9 @@ pub(crate) fn apply_cursor_hide_mode(cx: &mut App) {
|
||||
cx.set_cursor_hide_mode(mode);
|
||||
}
|
||||
|
||||
/// Force the macOS app appearance to match the active theme's light/dark mode
|
||||
/// instead of following the OS `Appearance` setting.
|
||||
/// Pin the macOS app appearance to the active theme's light/dark mode
|
||||
/// (`Some(dark)`), or release the pin so it follows the OS `Appearance`
|
||||
/// setting again (`None`, used while `Config::theme_follow_system` is on).
|
||||
///
|
||||
/// macOS draws the native traffic-light buttons according to the window's
|
||||
/// effective appearance. With a dark tty7 theme on a light-mode macOS, the
|
||||
@@ -289,7 +327,7 @@ pub(crate) fn apply_cursor_hide_mode(cx: &mut App) {
|
||||
/// no setter, so we pin `NSApplication.appearance` ourselves via AppKit. This
|
||||
/// also keeps system menus, context menus and scrollbars in the right mode.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn sync_native_appearance(dark: bool) {
|
||||
fn sync_native_appearance(dark: Option<bool>) {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{
|
||||
NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication,
|
||||
@@ -300,18 +338,20 @@ fn sync_native_appearance(dark: bool) {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
// SAFETY: reading the framework-provided appearance-name statics.
|
||||
let name = unsafe {
|
||||
if dark {
|
||||
NSAppearanceNameDarkAqua
|
||||
} else {
|
||||
NSAppearanceNameAqua
|
||||
}
|
||||
};
|
||||
if let Some(appearance) = NSAppearance::appearanceNamed(name) {
|
||||
NSApplication::sharedApplication(mtm).setAppearance(Some(&appearance));
|
||||
}
|
||||
let appearance = dark.and_then(|dark| {
|
||||
// SAFETY: reading the framework-provided appearance-name statics.
|
||||
let name = unsafe {
|
||||
if dark {
|
||||
NSAppearanceNameDarkAqua
|
||||
} else {
|
||||
NSAppearanceNameAqua
|
||||
}
|
||||
};
|
||||
NSAppearance::appearanceNamed(name)
|
||||
});
|
||||
// `None` here means "inherit from the system" — the AppKit way to unpin.
|
||||
NSApplication::sharedApplication(mtm).setAppearance(appearance.as_deref());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn sync_native_appearance(_dark: bool) {}
|
||||
fn sync_native_appearance(_dark: Option<bool>) {}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Tray bitmap rendering: the bundled SVGs rasterized with `resvg` (gpui's
|
||||
//! own SVG path only yields a tinted alpha mask, so the tray draws its own).
|
||||
//!
|
||||
//! Two states per platform:
|
||||
//! Per platform:
|
||||
//! - macOS: the outline terminal glyph (`tray.svg`) as a *template* image —
|
||||
//! the system recolors its alpha for light/dark menu bars. Attention swaps
|
||||
//! to a non-template variant: the glyph recolored to a mid-grey that reads
|
||||
//! on both bar appearances, plus an amber badge (template images can't
|
||||
//! carry color, so attention opts out of templating).
|
||||
//! the system recolors its alpha for light/dark menu bars, permanently.
|
||||
//! Attention never touches the bitmap (template images can't carry color,
|
||||
//! and leaving template mode made the glyph illegible); the tooltip and
|
||||
//! menu carry agent status instead (see `native.rs`).
|
||||
//! - Windows / Linux: the colored app icon (`app-icon.svg`); attention
|
||||
//! punches a transparent ring into the corner and fills an amber badge, so
|
||||
//! the badge separates from the orange tile behind it.
|
||||
@@ -27,9 +27,9 @@ const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/tray.svg");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/app-icon.svg");
|
||||
|
||||
/// Physical pixel size. macOS forces the NSImage to 18 pt in the status bar
|
||||
/// regardless of pixel size (see tray-icon's macOS backend), so 36 px renders
|
||||
/// crisp on retina. Windows tray slots are 16–32 px; 32 downsamples cleanly.
|
||||
/// Physical pixel size. On macOS the bitmap is 36 px (retina-crisp at 18 pt);
|
||||
/// native.rs then overrides the NSImage to 22 pt so the glyph fills the menu
|
||||
/// bar. Windows tray slots are 16–32 px; 32 downsamples cleanly.
|
||||
#[cfg(target_os = "macos")]
|
||||
const SIZE: u32 = 36;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -37,22 +37,32 @@ const SIZE: u32 = 32;
|
||||
|
||||
/// The `Waiting` amber, same hue as the in-window status dot
|
||||
/// (`AgentStatus::dot_rgb`).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B);
|
||||
|
||||
/// Render the tray icon. `attention` = some agent is blocked on the user.
|
||||
/// `None` only on a malformed bundled SVG, i.e. never in practice — callers
|
||||
/// treat it as "no icon change".
|
||||
/// Render the tray icon: the template outline glyph, always — attention
|
||||
/// never touches the bitmap, so the icon stays a template image the system
|
||||
/// keeps legible on any bar. `None` only on a malformed bundled SVG, i.e.
|
||||
/// never in practice — callers treat it as "no icon change".
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn render() -> Option<RgbaImage> {
|
||||
let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?;
|
||||
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
|
||||
resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut());
|
||||
Some(to_rgba(&pixmap))
|
||||
}
|
||||
|
||||
/// Render the tray icon. `attention` = some agent is blocked on the user —
|
||||
/// stamps the amber badge into the colored app icon. `None` only on a
|
||||
/// malformed bundled SVG, i.e. never in practice — callers treat it as "no
|
||||
/// icon change".
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(super) fn render(attention: bool) -> Option<RgbaImage> {
|
||||
let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?;
|
||||
let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?;
|
||||
resvg::render(&tree, fit_center(&tree, SIZE), &mut pixmap.as_mut());
|
||||
|
||||
if attention {
|
||||
// macOS attention leaves template mode (color needs real RGB), so the
|
||||
// glyph must carry its own color: a mid-grey legible on both light
|
||||
// and dark menu bars. Colored platforms keep the icon's own colors.
|
||||
#[cfg(target_os = "macos")]
|
||||
recolor(&mut pixmap, (0x8E, 0x8E, 0x93));
|
||||
badge(&mut pixmap);
|
||||
}
|
||||
|
||||
@@ -202,6 +212,7 @@ fn recolor(pixmap: &mut tiny_skia::Pixmap, rgb: (u8, u8, u8)) {
|
||||
/// Stamp the amber attention badge in the top-right corner: first clear a
|
||||
/// slightly larger disc so the badge is ringed by transparency (separating it
|
||||
/// from whatever the glyph or a colored tile puts behind it), then fill.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn badge(pixmap: &mut tiny_skia::Pixmap) {
|
||||
let s = SIZE as f32;
|
||||
let (cx, cy) = (s * 0.78, s * 0.22);
|
||||
@@ -301,8 +312,20 @@ mod tests {
|
||||
assert_eq!((px[1], px[2]), (0, 0));
|
||||
}
|
||||
|
||||
/// The template glyph renders at the declared size with visible coverage.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn render_produces_template_glyph() {
|
||||
let img = render().unwrap();
|
||||
assert_eq!((img.width, img.height), (SIZE, SIZE));
|
||||
assert_eq!(img.data.len(), (SIZE * SIZE * 4) as usize);
|
||||
let covered = img.data.chunks_exact(4).filter(|p| p[3] > 0).count();
|
||||
assert!(covered > 0, "icon rendered fully transparent");
|
||||
}
|
||||
|
||||
/// Both tray states render at the declared size with visible coverage,
|
||||
/// and the attention badge actually changes the bitmap.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn render_produces_both_states() {
|
||||
let normal = render(false).unwrap();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! System tray / menu bar status item.
|
||||
//!
|
||||
//! The tray is the app's face outside the window: the icon flips to an
|
||||
//! attention state the moment any pane's coding agent blocks on the user
|
||||
//! (amber `Waiting`), and the menu lists every agent pane — click one to
|
||||
//! reveal it — plus window/notification/quit controls. Menu labels are
|
||||
//! English, matching the native app menus (`ui::theme::set_menus`).
|
||||
//! The tray is the app's face outside the window: on Windows/Linux the icon
|
||||
//! flips to an amber-badged attention state the moment any pane's coding
|
||||
//! agent blocks on the user (`Waiting`); on macOS the icon is a template
|
||||
//! image that stays calm in every state (legible on any bar) — agent status
|
||||
//! lives in the tooltip and menu instead. The menu lists every agent pane —
|
||||
//! click one to reveal it — plus window/notification/quit controls. Menu
|
||||
//! labels are English, matching the native app menus (`ui::theme::set_menus`).
|
||||
//!
|
||||
//! Platform split (see Cargo.toml for the why):
|
||||
//! - macOS / Windows: tauri's `tray-icon` (NSStatusItem / Shell_NotifyIcon),
|
||||
@@ -90,7 +92,10 @@ pub(crate) struct TraySnapshot {
|
||||
}
|
||||
|
||||
impl TraySnapshot {
|
||||
/// Whether any agent is blocked on the user — drives the attention icon.
|
||||
/// Whether any agent is blocked on the user — drives the attention badge
|
||||
/// on Windows/Linux (the macOS icon stays calm; the tooltip and menu
|
||||
/// carry agent status there).
|
||||
#[cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
pub(crate) fn attention(&self) -> bool {
|
||||
self.agents.iter().any(|a| a.status == AgentStatus::Waiting)
|
||||
}
|
||||
@@ -266,6 +271,7 @@ mod tests {
|
||||
fn attention_follows_waiting_and_tooltip_counts() {
|
||||
assert!(snapshot_with_agent(AgentStatus::Waiting).attention());
|
||||
assert!(!snapshot_with_agent(AgentStatus::Working).attention());
|
||||
assert!(!snapshot_with_agent(AgentStatus::Done).attention());
|
||||
assert_eq!(
|
||||
snapshot_with_agent(AgentStatus::Waiting).tooltip(),
|
||||
"tty7 — 1 waiting"
|
||||
|
||||
@@ -14,8 +14,10 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
pub(super) struct Backend {
|
||||
tray: TrayIcon,
|
||||
/// The icon state currently shown, so a snapshot diff that doesn't flip
|
||||
/// attention skips the bitmap rebuild.
|
||||
/// Whether the amber badge is currently stamped, so a snapshot diff that
|
||||
/// doesn't flip attention skips the bitmap rebuild. macOS tracks nothing:
|
||||
/// its template glyph never changes (see `icon.rs`).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
attention: bool,
|
||||
}
|
||||
|
||||
@@ -37,12 +39,15 @@ impl Backend {
|
||||
}
|
||||
}));
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let img = icon::render()?;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let img = icon::render(false)?;
|
||||
let icon = Icon::from_rgba(img.data, img.width, img.height).ok()?;
|
||||
let tray = TrayIconBuilder::new()
|
||||
.with_icon(icon)
|
||||
// The calm glyph is a template on macOS (system recolors it for
|
||||
// the bar); a no-op on Windows.
|
||||
// The glyph is a template on macOS (system recolors it for the
|
||||
// bar, in both states); a no-op on Windows.
|
||||
.with_icon_as_template(true)
|
||||
.with_tooltip("tty7")
|
||||
.with_menu(Box::new(build_menu(&TraySnapshot::default())))
|
||||
@@ -57,26 +62,50 @@ impl Backend {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// tray-icon hardcodes the NSImage height to 18 pt; override to a
|
||||
// larger size so the glyph fills more of the menu bar. The bitmap
|
||||
// itself is already rendered at `icon::SIZE` px (retina-ready).
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(status_item) = tray.ns_status_item() {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
if let Some(button) = status_item.button(mtm) {
|
||||
if let Some(nsimage) = button.image() {
|
||||
// 22 pt matches the macOS menu bar height; the glyph
|
||||
// scales proportionally from its 96×96 viewBox.
|
||||
let target_h: f64 = 22.0;
|
||||
let aspect = nsimage.size().width / nsimage.size().height;
|
||||
nsimage.setSize(objc2_foundation::NSSize::new(target_h * aspect, target_h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
tray,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
attention: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Push a changed snapshot into the native item: menu always (it's what
|
||||
/// changed), icon only across an attention flip.
|
||||
/// changed); on Windows, the badge only across an attention flip. The
|
||||
/// macOS icon is a template image in every state — the system recolors it
|
||||
/// for the bar, and it never carries an attention mark (status lives in
|
||||
/// the tooltip and menu).
|
||||
pub(super) fn update(&mut self, snap: &TraySnapshot) {
|
||||
self.tray.set_menu(Some(Box::new(build_menu(snap))));
|
||||
let _ = self.tray.set_tooltip(Some(snap.tooltip()));
|
||||
let attention = snap.attention();
|
||||
if attention != self.attention {
|
||||
self.attention = attention;
|
||||
if let Some(img) = icon::render(attention)
|
||||
&& let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height)
|
||||
{
|
||||
// Attention leaves template mode: the amber badge needs
|
||||
// its real color (macOS; the flag is a no-op on Windows).
|
||||
let _ = self.tray.set_icon_with_as_template(Some(icon), !attention);
|
||||
// Windows: flip the amber corner badge on the colored icon.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let attention = snap.attention();
|
||||
if attention != self.attention {
|
||||
self.attention = attention;
|
||||
if let Some(img) = icon::render(attention)
|
||||
&& let Ok(icon) = Icon::from_rgba(img.data, img.width, img.height)
|
||||
{
|
||||
let _ = self.tray.set_icon(Some(icon));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||