diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef7f465..ecb2c907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 reach them. The retry is now dropped only for a tab the user really did make while the pull was out, and a window waiting on a rebuild adds to its machine without pruning it until the pull lands (#579). +- **Installing an update on Windows shows a progress window.** The installer + ran `/VERYSILENT`, so from the app quitting to the new build coming up — + tens of seconds, longer under an antivirus scan — the screen held nothing + at all, and "clicked update, the app vanished" read as a crash. The + installer now runs `/SILENT`: still unattended, but Inno's own progress + window stays on screen for the gap (#600). +- **Orphan panes are visible in the GUI, and closable from it.** A shell + left running after its workspace went away — what an interrupted `tty7 + run` leaves behind — showed up nowhere in the GUI; only the CLI's `tty7 + pane ls --all` could see it, and only `pane close --orphans` could stop + it. The workspace switcher's local machine group now lists those + background panes with their owner and working directory, each with a + Close button (#596). +- **A launch that restores one of several windows says what it left behind.** + Quitting with several windows open and starting again restored only the + most recent one; the rest were marked detached — panes alive, nothing on + screen, the only trace a log line. The restored window now shows a + notification naming how many workspaces are still running in the + background and where to reopen them (#597). +- **`tty7 pane close %99` fails when no pane 99 exists.** The orphan path + hangs the pane up directly, and that kill is fire-and-forget — the daemon + never says whether it knew the pane — so a typo'd id printed + `{"closed":[99]}` and exited 0, telling a reaper script the leak it was + chasing was gone. Close now checks the id against the running-pane + registry first and reports the miss under `failed` with exit 1 (#588). +- **cd Here and Insert Path quote for the shell the pane runs.** Both used + to wrap a path with spaces in POSIX single quotes whatever the pane's + shell was, and cmd.exe — where a single quote is an ordinary character — + then split the path at its first space. The quote style now follows the + pane's shell: double quotes for cmd.exe, single quotes for PowerShell and + every POSIX shell (#593). +- **Remote path completion says what it's doing.** Tab-completing a path + on a remote workspace used to show nothing for the whole network + round-trip — a slow link read as a broken Tab key — and a listing that + failed ended in exactly the silence an empty directory ends in. A pill + over the pane's corner now says the listing is running, and a failed + listing reports its error there instead of vanishing (#585). +- **Seven hard-coded English strings moved into the language tables.** The + shell-integration notice, the pane titles a disconnected or exited pane + wears, the loopback forward's failure, the tray tooltip that lists running + agents, the cursor-shape choices, the command palette's empty-result hint + and the updater's install hint all used to render in English whatever the + UI language was; they now follow it, and the palette's hint no longer + suggests connecting over SSH in menus that have nothing to do with hosts + (#602). +- **A half-typed tab rename survives other tabs closing and the strip + reordering.** The rename box tracked its tab by index, so any unrelated + tab event forced it closed to keep the commit from landing on the wrong + tab — and even then, a reorder mid-rename left a window where the name + went to the tab that had taken the index over. The box now tracks its tab + by tree id: only closing the renaming tab itself ends the rename, and the + commit lands on the tab the box was opened on wherever it has moved + (#598). +- **A zoomed pane stays zoomed when you leave its tab and come back.** Zoom + was a window-level value that activating any tab cleared, so looking at + another tab and returning restored the split layout — while a zoom is a + tab's temporary view state, like its focused pane. It now rides with the + tab; the clears that genuinely reshape the layout (drag, split, close) + still stand, and a zoom whose pane exited while the tab was away does not + come back (#599). +- **Opening and closing the search bar no longer erases the grid + selection.** The selection that seeds the query is the thing being + searched for, yet opening the bar ran the same unconditional clear as + *changing* the query, and closing cleared it again — select text, press + Ctrl+F then Esc, and the selection was gone. The seeded selection is now + kept through the open, and closing keeps whatever selection the grid + holds; only an actual query change retires it, the discipline the output + rescan path already stated (#584). +- **Search highlights follow the text when the pane is resized.** A match + point is an absolute (line, column) against the width it was scanned at, + so narrowing a pane reflowed the text out from under every highlight until + new output happened to trigger a rescan — and a quiet local pane has none + coming. A column change now rescans immediately, with the output path's + discipline (the selection and scroll position are left alone); a + rows-only change reflows nothing and stays cheap (#586). +- **A mistyped "Start in" path is refused at save instead of silently + rerouting every new pane.** The custom path used to be stored unchecked, + and the daemon's picker then skipped it — not a directory — and started + each new shell in its own fallback directory, so "new shells don't start + in my project" read as a tty7 bug rather than a typo. Settings now marks a + non-existent directory in red and does not save it, and a hand-edited + `config.json` holding one gets a `log::warn!` naming the path at the + moment the fallback engages (#601). +- **`tty7 doctor` exits 1 when the server is unreachable.** Doctor is the + verb people run when something is not working, so an unreachable server is + *the* finding — not a row to exit 0 over while `tty7 doctor || alert` + never fires. The full table and JSON still go out, and stderr carries the + headline under `-q` (#592). +- **The `owner` field of `pane ls --all` is documented the same way in both + references** — the bundled skill reference still claimed the CLI stamps a + literal `"tty7-cli"` owner, the behaviour that was removed because an owner + names the workspace allowed to attach. Both now describe the workspace id, + or its absence while a pane is unfiled (#591). +- **A failed `tty7 wait` now says so on stderr even under `-q`.** Timeout and + "pane exited first" are structured exits, so they bypassed the anyhow path + that prints under quiet mode and left the exit code as the only evidence — + against the documented "errors still go to stderr". Both now print a + one-line headline to stderr, the discipline `pane close` already set (#590). +- **A timed-out `tty7 wait` now answers in the same JSON shape as a finished + one** — `matched`, `stale` and the agent session fields, plus + `"timed_out": true` — instead of a bare object missing the fields a + consumer's error branch was written against. The schema, including + `timed_out`, is now documented (#589). +- **Cancelling the amend confirmation no longer switches amend off.** The + toggle was cleared when Commit was pressed — before the "rewrite the last + commit?" prompt — so answering Cancel returned to a panel whose amend mode + had silently been dropped, and the next Commit created the brand-new commit + the user had just declined to risk. The toggle now switches off only when + the commit actually runs (#595). +- **The SCM panel's "discard all" confirmation no longer overstates what it + does.** The prompt asked to "discard every change in this repository" while + the operation has always left staged changes alone — it sweeps only unstaged + edits and untracked files. The prompt now says exactly that, in all three + languages (#594). - **A local daemon that dies and comes back no longer leaves a window of dead panes looking live** — from the client's side a killed daemon is indistinguishable from one whose shells all exited at once, so the window diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 40c20efa..448cfc66 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -101,6 +101,8 @@ pub mod mock { pub runs: Vec, pub run_exit: Option, pub events: Vec, + /// Set to make `hello` fail — doctor's unreachable-server branch. + pub unreachable: bool, } impl Default for MockBackend { @@ -124,6 +126,7 @@ pub mod mock { runs: Vec::new(), run_exit: Some(0), events: Vec::new(), + unreachable: false, } } } @@ -148,6 +151,9 @@ pub mod mock { } fn hello(&mut self) -> Result { + if self.unreachable { + anyhow::bail!("connect: no server is listening"); + } Ok(ControlHelloOk { control_version: CONTROL_VERSION, protocol_version: PROTOCOL_VERSION, diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index a787149e..bb38e6dd 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -852,6 +852,11 @@ fn pane_close( // the state the caller was trying to fix. let mut closed = Vec::new(); let mut failures = Vec::new(); + // The running-pane registry, read lazily on the first direct kill: the + // direct path is fire-and-forget (the daemon never says whether it knew + // the pane), so the registry is the only way `%99` can fail instead of + // reporting `{"closed":[99]}` for a pane that never existed (#588). + let mut running: Option> = None; for pane in panes { let outcome = match resolve::workspace_of_pane(&machine, pane) { Ok(ws) => { @@ -864,7 +869,24 @@ fn pane_close( // No workspace holds it, so PaneClose has nothing to route through. // Hang it up directly instead of refusing — this is exactly the // orphan `pane ls --all` points the user at. - Err(_) => backend.kill_pane(pane), + Err(_) => { + if running.is_none() { + running = Some( + backend + .list_panes()? + .iter() + .map(|info| info.pane_id) + .collect(), + ); + } + if running.as_ref().is_some_and(|ids| ids.contains(&pane)) { + // A pane that exits between the listing and the kill is + // gone either way, which is what closing it wanted. + backend.kill_pane(pane) + } else { + Err(anyhow::anyhow!("no such pane")) + } + } }; match outcome { Ok(()) => closed.push(pane), @@ -1081,6 +1103,12 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Result Result { "\nnot inside a tty7 shell — address commands need an explicit %pane/@tab/workspace\n", ); } - report( + let report = Report { human, - json!({ + json: json!({ "context": { "config_dir": ctx.config_dir.is_some(), "workspace": ctx.ws.is_some(), @@ -1405,7 +1452,16 @@ fn doctor(ctx: &Context, backend: &mut dyn Backend) -> Result { "server": server, "hooks": hooks_json(&hooks), }), - ) + }; + if report.json["server"]["reachable"] == false { + // doctor is the verb people run when something is not working, so an + // unreachable server is *the* finding — not a row to exit 0 over: + // `tty7 doctor || alert` has to fire (#592). The table and JSON go + // out all the same, and stderr carries the headline under `-q`. + eprintln!("tty7: doctor: the server is unreachable"); + return Ok(Outcome::Exit(1, report)); + } + Ok(Outcome::Report(report)) } /// Where every installable status hook stands on this machine. @@ -1594,6 +1650,9 @@ mod tests { #[test] fn closing_an_orphan_falls_back_to_hanging_the_pane_up() { let mut backend = mock(); + // The registry must know %77: a direct kill is fire-and-forget, so + // close verifies existence against it first (#588). + backend.registry = vec![pane_info(77, Some("tty7-cli"))]; run_cli( &["tty7", "pane", "close", "%77"], &Context::default(), @@ -1629,6 +1688,38 @@ mod tests { ); } + #[test] + fn closing_a_pane_that_never_existed_is_a_failure_not_a_ghost_success() { + // %99 is in no workspace and in no registry — exactly the typo a + // reaper script makes. Closing it used to print {"closed":[99]} and + // exit 0, telling the script the leak it was chasing was gone (#588). + let mut backend = mock(); + backend.registry = vec![pane_info(77, Some("tty7-cli"))]; + let out = execute( + cli(&["tty7", "pane", "close", "%99"]), + &Context::default(), + &mut backend, + ) + .expect("a ghost close is an exit code, not an error"); + let Outcome::Exit(1, r) = out else { + panic!("closing a pane that does not exist has to fail: {out:?}"); + }; + assert_eq!(r.json["closed"], serde_json::json!([])); + assert!( + r.json["failed"] + .as_array() + .expect("the failures are a list") + .iter() + .any(|f| f.as_str().is_some_and(|f| f.contains("%99"))), + "{}", + r.json + ); + assert!( + backend.killed.is_empty(), + "no kill may be sent for a pane the registry does not hold" + ); + } + #[test] fn ls_and_ws_ls_are_the_same_request() { let ctx = Context::default(); @@ -3110,10 +3201,15 @@ mod tests { &mut backend, ) .expect("a timeout is an exit code, not an error"); - assert!( - matches!(out, Outcome::Exit(124, _)), - "a pane that was free all along has not run anything" - ); + let Outcome::Exit(124, r) = out else { + panic!("a pane that was free all along has not run anything"); + }; + // A timeout answers in the success path's own shape, plus the flag — + // a consumer's error branch must not meet missing fields (#589). + assert_eq!(r.json["timed_out"], true); + assert_eq!(r.json["matched"], false); + assert_eq!(r.json["stale"], true, "nothing ran while we watched"); + assert!(r.json.get("session_id").is_some()); // Free → busy → free is the real shape, and it must wake. let mut backend = mock(); @@ -3520,4 +3616,28 @@ mod tests { )); assert!(out.contains("unknown"), "{out}"); } + + /// An unreachable server is *the* finding doctor exists for, so the verb + /// exits non-zero over it — `tty7 doctor || alert` has to fire — while + /// still printing the full report (#592). + #[test] + fn doctor_exits_nonzero_when_the_server_is_unreachable() { + let mut backend = mock(); + backend.unreachable = true; + let out = run_cli(&["tty7", "doctor"], &Context::default(), &mut backend); + let Outcome::Exit(1, r) = out else { + panic!("an unreachable server is an exit 1, not a plain report: {out:?}"); + }; + assert_eq!(r.json["server"]["reachable"], serde_json::json!(false)); + // The rest of the report still goes out — the context rows are the + // other half of what doctor is for. + assert!(r.human.contains("TTY7_CONFIG_DIR"), "{}", r.human); + assert!(r.human.contains("unreachable"), "{}", r.human); + // No Status/Routes round-trips happen once hello has failed. + assert!( + backend.control_calls.is_empty(), + "{:?}", + backend.control_calls + ); + } } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index a0c36088..2833d2c0 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -259,6 +259,20 @@ fn initial_working_directory(cwd: Option) -> Option { .filter(|d| d != std::path::Path::new("/")) .or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)); let forced = crate::core::config::working_directory_base(); + // A configured "Start in" path that is not a directory can never win the + // pick below, so every new pane silently lands on the fallback and the + // mistyped path reads as a tty7 bug (#601). Settings refuses to save one + // now, but a hand-edited config.json can still hold one — name it, with + // the reason, at the moment it actually costs something (an explicit cwd + // that resolves never consults the config, so that case stays quiet). + if let Some(dir) = &forced { + if !dir.is_dir() && cwd.as_ref().is_none_or(|d| !d.is_dir()) { + log::warn!( + "configured working directory {} is not a directory; new panes fall back", + dir.display() + ); + } + } [cwd, forced, fallback] .into_iter() .flatten() diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 123fbe63..a7cf88b5 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -20,7 +20,7 @@ Set inside every tty7 pane, inherited by anything launched from one. | Variable | Meaning | |---|---| | `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both accepted). Default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. | -| `TTY7_WS` | This pane's workspace id. Default for `run --keep`, `tab new`, `ws tree`. | +| `TTY7_WS` | This pane's workspace id. Default for `run --keep`, `tab new`, `tab ls`, `ws tree`. | | `TTY7_CONFIG_DIR` | The server's config dir — how the CLI finds the right server. You never pass a socket path. | Outside a tty7 shell, address-taking verbs fail with @@ -89,7 +89,8 @@ it the workspace still appears in the switcher; it just waits to be opened. Alias of `pane split`. Splits `%PANE` (default `$TTY7_PANE`), spawning a shell in the same cwd. Exactly one axis is required — `--v`/`--vertical` puts the new pane below, `--h`/`--horizontal` to the right. `--ratio` (default `0.5`) is the -share kept by the *existing* pane. Prints `%NN`. JSON: `{"pane"}`. +share kept by the *existing* pane, clamped to `0.05`–`0.95` — a `--ratio 70` +silently becomes `0.95`, not an error. Prints `%NN`. JSON: `{"pane"}`. ### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…` @@ -117,11 +118,14 @@ for a sequence, and it composes with `TEXT` — the text goes first. |---|---| | Named | `enter` `escape` `tab` `backtab` `space` `backspace` `delete` `up` `down` `right` `left` `home` `end` `pageup` `pagedown` | | Chords | `C-` (Ctrl, e.g. `C-c`), `M-` (Alt) | -| Aliases | `return` `cr` `esc` `del` `bs` `shift-tab` `pgup` `pgdn` | +| Aliases | `return` `cr` `esc` `del` `bs` `shift-tab` `pgup` `pgdn` `pgdown` | Names are case-insensitive, and an unknown one is a usage error (exit `2`) raised before anything is sent — half a key sequence in a live pane is worse -than none. Each keystroke is delivered as its own event, 200 ms apart, so a +than none. One case exception: Alt is a prefixed ESC, so its character goes +out exactly as written and `M-X` is not `M-x` (Ctrl is unaffected — `C-c` +and `C-C` are the same byte). Each keystroke is delivered as its own event, +200 ms apart, so a raw-mode TUI reads a sequence as a sequence rather than as a paste. JSON: `{"pane","sent","enter","keys"}`. @@ -148,7 +152,9 @@ The process tree inside the pane, indented by depth, `*` on the foreground process — then a second table of ports those processes are listening on. Prints `nothing running in this pane` when both are empty. -JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name"}]}`. +JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}]}` — +`addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`, +`[::1]`, or a specific interface). ### `tty7 agents` @@ -188,6 +194,11 @@ want directly after a `send`; a command quick enough to finish inside one The reply carries the agent's message and native session id. The JSON's `stale` flag says whether the answer might belong to the previous turn. + +JSON: `{"pane","status","matched","stale","activity","message","session_id"}`. +A timeout exits `124` with the same object plus `"timed_out": true` — +`matched` is `false` there, and `stale` still says whether the pane moved +while you watched. [Orchestration →](/agents/orchestration) ### `tty7 events` @@ -284,9 +295,9 @@ a stand-in. `--all` is the one that shows leaks. Each entry is `{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is the id -of the workspace that owns the pane, and `orphan: true` means no workspace holds -it. An interrupted `run` leaves orphans here, as does a `ws rm` that reported -panes it could not hang up. +of the workspace that may attach to the pane (absent when none may), and +`orphan: true` means no workspace holds it. An interrupted `run` leaves orphans +here, as does a `ws rm` that reported panes it could not hang up. `--orphans` is the reaper for exactly those. It closes what `pane ls --all` lists as orphaned and nothing else — panes a workspace holds are untouched — diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md index c8f83a1e..6c667524 100644 --- a/skills/tty7/references/commands.md +++ b/skills/tty7/references/commands.md @@ -31,7 +31,7 @@ Set inside every tty7 pane, inherited by anything you launch from one. | Variable | Meaning | |---|---| | `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. | -| `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `ws tree`. | +| `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `tab ls`, `ws tree`. | | `TTY7_CONFIG_DIR` | The server's config dir. How the CLI finds the right server's sockets — you never pass a socket path. | Outside a tty7 shell the address-taking verbs fail with @@ -97,7 +97,8 @@ is still listed in the GUI's switcher; it just waits there to be opened. Alias of `pane split`. Splits `%PANE` (default `$TTY7_PANE`), spawning a shell in the same cwd. Exactly one axis is required — `--v`/`--vertical` puts the new pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the -share kept by the *existing* pane. Prints `%NN`. JSON: `{"pane"}`. +share kept by the *existing* pane, clamped to 0.05–0.95 — a `--ratio 70` +silently becomes 0.95, not an error. Prints `%NN`. JSON: `{"pane"}`. ### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…` Types `TEXT` into the pane as keystrokes; `--enter` is shorthand for `--key @@ -124,9 +125,12 @@ build. Repeatable, delivered in order, and composable with `TEXT` (text first). |---|---| | Named | `enter` `escape` `tab` `backtab` `space` `backspace` `delete` `up` `down` `right` `left` `home` `end` `pageup` `pagedown` | | Chords | `C-` (Ctrl: `C-c`, `C-d`, `C-z`, also `C-@ C-[ C-\ C-] C-^ C-_ C-?`), `M-` (Alt = prefixed ESC) | -| Aliases | `return` `cr` `esc` `del` `bs` `shift-tab` `pgup` `pgdn` | +| Aliases | `return` `cr` `esc` `del` `bs` `shift-tab` `pgup` `pgdn` `pgdown` | -Case-insensitive. An unknown name is a usage error (exit 2) raised before +Case-insensitive — with one exception: Alt is a prefixed ESC, so its character +goes out exactly as written and `M-X` is not `M-x` (Ctrl is unaffected: `C-c` +and `C-C` are the same byte). An unknown name is a usage error (exit 2) raised +before anything is written, so a bad key never lands half a sequence in a live pane. Each keystroke goes out as its own event 200 ms after the last, which is what keeps a raw-mode TUI from reading the sequence as a paste; the first write is @@ -168,7 +172,9 @@ The process tree inside the pane, indented by depth, `*` on the foreground process — then a second table of ports those processes are listening on. Prints `nothing running in this pane` when both are empty. -JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name"}]}`. +JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}]}`, +where `addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`, +`[::1]`, or a specific interface). Nothing below the depth-0 shell means the foreground command has exited — but you rarely need to check that by hand, because that is exactly what @@ -317,10 +323,10 @@ still tell a real name from a stand-in. | `pane close --orphans` | close every pane no workspace holds | `{"closed":[...]}` | `--all` is the one that shows leaks. Each entry is -`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is -`tty7-cli` for panes this CLI spawned (a workspace id otherwise), and -`orphan: true` means no workspace holds it. An interrupted `tty7 run` is what -leaves them. +`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is the id +of the workspace that may attach to the pane (absent when none may — a +free-floating `tty7 run` before `--keep` files it), and `orphan: true` means +no workspace holds it. An interrupted `tty7 run` is what leaves them. `close` takes several ids at once and keeps going after a failure: the rest are still attempted, and it exits 1 with `{"closed":[...],"failed":[...]}` so you diff --git a/src/bin/tty7-updater.rs b/src/bin/tty7-updater.rs index f2a54b94..ced5572c 100644 --- a/src/bin/tty7-updater.rs +++ b/src/bin/tty7-updater.rs @@ -1372,7 +1372,14 @@ mod windows { log_argument.push(log); vec![ OsString::from("/SP-"), - OsString::from("/VERYSILENT"), + // /SILENT, not /VERYSILENT: the install runs unattended either + // way, but between the app quitting for the update and the + // watcher bringing the new build up — tens of seconds, longer + // under an antivirus scan — a very-silent install shows nothing + // at all, and "clicked update, the app vanished" reads as a crash + // (#600). /SILENT still asks no questions; it only keeps Inno's + // own progress window on screen for the gap. + OsString::from("/SILENT"), OsString::from("/SUPPRESSMSGBOXES"), OsString::from("/NORESTART"), OsString::from("/CLOSEAPPLICATIONS"), @@ -1713,7 +1720,15 @@ mod windows { fn silent_installer_arguments_keep_the_log_path_native() { let log = Path::new(r"C:\Users\测试 User\tty7 update.log"); let arguments = installer_arguments(log); - assert!(arguments.contains(&OsString::from("/VERYSILENT"))); + assert!( + arguments.contains(&OsString::from("/SILENT")), + "the install is unattended, but Inno's progress window stays \ + on screen for the gap between quit and relaunch (#600)" + ); + assert!( + !arguments.contains(&OsString::from("/VERYSILENT")), + "a very-silent install leaves the screen empty for tens of seconds" + ); let expected: OsString = OsString::from_wide( &OsStr::new(r"/LOG=C:\Users\测试 User\tty7 update.log") .encode_wide() diff --git a/src/core/session.rs b/src/core/session.rs index 47dd65c5..a8df8955 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -102,7 +102,11 @@ impl WorkspaceStore { }); } - pub fn restore_one(cx: &mut gpui::App) -> Option { + /// Restore the one window a launch reopens, detaching every other that + /// was open at quit. The count comes back with the id: those workspaces + /// are still running, and silence about them is how they get forgotten + /// (#597) — the caller is expected to say something. + pub fn restore_one(cx: &mut gpui::App) -> Option<(WorkspaceId, usize)> { let store = Self::try_store(cx)?; let keep = store.views.workspace_to_restore()?; let reattaching = store.views.get(keep).is_some_and(|view| !view.open); @@ -120,7 +124,7 @@ impl WorkspaceStore { } else if detached > 0 { log::info!("launch: restoring 1 workspace, left {detached} detached"); } - Some(keep) + Some((keep, detached)) } pub fn close_window(cx: &mut gpui::App, id: WorkspaceId) { @@ -319,6 +323,38 @@ mod tests { assert!(crosses_machines(b1, g)); } + #[gpui::test] + fn restore_one_keeps_one_window_and_reports_the_rest(cx: &mut gpui::TestAppContext) { + // `restore_one` saves, and a test has no business writing the real views. + let _ = tty7_core::core::config::set_config_dir( + std::env::temp_dir().join(format!("tty7-session-test-{}", std::process::id())), + ); + cx.update(|cx| { + WorkspaceStore::install_for_test(cx, WindowViews::default()); + let first = WorkspaceStore::claim(cx, None); + let _second = WorkspaceStore::claim(cx, None); + let third = WorkspaceStore::claim(cx, None); + + let (kept, detached) = + WorkspaceStore::restore_one(cx).expect("three open windows restore one"); + assert_eq!(kept, third, "the most recently active window wins"); + assert_eq!( + detached, 2, + "the other two are still running — the launch has to say so (#597)" + ); + let views = WorkspaceStore::all(cx); + assert!(!views.get(first).expect("first survives").open); + assert!(views.get(third).expect("third survives").open); + + // Restoring again with the other two already detached reports + // nothing: the notification is for the launch that did the + // detaching, not every launch after it. + let (_, detached) = + WorkspaceStore::restore_one(cx).expect("the open window restores again"); + assert_eq!(detached, 0); + }); + } + #[gpui::test] fn claiming_a_workspace_the_store_never_saw_keeps_the_id_it_was_given( cx: &mut gpui::TestAppContext, diff --git a/src/core/update.rs b/src/core/update.rs index f47b24f2..9db1206d 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -127,10 +127,9 @@ pub enum UpdateInstallHint { } impl UpdateInstallHint { - /// The reason in plain English, for an `anyhow` chain that ends up in a log - /// or an error string. Anything a user reads goes through - /// `localized_update_install_hint` instead. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + /// The reason in plain English, pinned by tests. Anything a user reads + /// goes through `localized_update_install_hint` instead (#602). + #[cfg_attr(not(test), allow(dead_code))] fn english(&self) -> String { match self { #[cfg(target_os = "macos")] @@ -1690,7 +1689,10 @@ fn prepare_windows_update( // an installation can be relocated, or its privileges changed, between the // update check and the user pressing the button. if let Err(hint) = windows_layout_is_updatable(&layout) { - anyhow::bail!("{}", hint.english()); + // This error surfaces in Settings as the update failure — the moment + // the user most needs to understand it — so it is the localized hint, + // not the English one meant for logs (#602). + anyhow::bail!("{}", localized_update_install_hint(&hint)); } let install_dir = layout.directory().to_path_buf(); let bundled = bundled_updater().context("tty7-updater.exe is not bundled with this app")?; diff --git a/src/main.rs b/src/main.rs index 98db58e5..7ab093b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -562,7 +562,8 @@ fn main() { crate::ui::local_link::LocalLink::install(cx); let reopen = crate::ui::windows::restore_target(cx, open_path.as_deref()); - crate::ui::windows::open_at(cx, reopen, open_path); + crate::ui::windows::open_at(cx, reopen.map(|(id, _)| id), open_path); + crate::ui::windows::announce_detached_at_launch(cx, reopen); if config_outcome.failed() { notify_config_load_failed(cx, config_outcome, true); } diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 845010d5..da6ac6ce 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1624,8 +1624,15 @@ impl Element for TerminalElement { .floor() .max(1.0) as usize; - self.view.update(cx, |view, _cx| { - view.set_grid_size(cols, rows, cell_width, line_height, window.scale_factor()); + self.view.update(cx, |view, cx| { + view.set_grid_size( + cols, + rows, + cell_width, + line_height, + window.scale_factor(), + cx, + ); }); let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); diff --git a/src/terminal/git_data.rs b/src/terminal/git_data.rs index 53f241ed..42e4c25c 100644 --- a/src/terminal/git_data.rs +++ b/src/terminal/git_data.rs @@ -1041,10 +1041,13 @@ impl Tty7App { // Armed at dispatch, not when the button was pressed: a confirmation // the user cancels must leave nothing armed, or the next unrelated - // HEAD move would clear a message that was never committed. See - // `scm_commit_landed`. + // HEAD move would clear a message that was never committed. The amend + // toggle follows the same rule — it switches off when the commit + // actually runs, so cancelling the amend prompt leaves the user in + // the mode they had chosen. See `scm_commit_landed`. let was_commit = matches!(op, GitOp::Commit { .. }); if let GitOp::Commit { message, .. } = &op { + self.scm.amend = false; self.scm.committing = Some(( crate::ui::scm::state::RepoKey { host: id, diff --git a/src/terminal/search.rs b/src/terminal/search.rs index add38192..09b7d2e9 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -109,10 +109,19 @@ fn anchor_row(history: usize, point: &Point) -> i64 { impl TerminalView { pub fn open_search(&mut self, window: &mut Window, cx: &mut Context) { let fresh = self.search.is_none(); + // When the query is seeded from the grid selection, that selection is + // the thing being searched for — not a casualty of opening the bar. + // `recompute_matches` clears it because its other callers are the user + // *changing* the query (typing, toggles), where the old selection no + // longer names anything; here the query IS the selection, so it is + // put back after the scan (#584). + let mut seeded_selection = None; if fresh { - let seed = self - .selected_search_seed() - .unwrap_or_else(|| self.search_last_query.clone()); + let seed = self.selected_search_seed(); + if seed.is_some() { + seeded_selection = self.terminal.term.lock().selection.clone(); + } + let seed = seed.unwrap_or_else(|| self.search_last_query.clone()); let input = cx.new(|cx| { InputState::new(window, cx) .placeholder(t(L10nKey::SearchFind)) @@ -136,6 +145,9 @@ impl TerminalView { } if fresh { self.recompute_matches(cx); + if let Some(selection) = seeded_selection { + self.terminal.term.lock().selection = Some(selection); + } } cx.notify(); } @@ -157,7 +169,9 @@ impl TerminalView { self.search = None; self.search_focused = false; self.search_regex_error = false; - self.terminal.term.lock().selection = None; + // The grid selection stays: it is the user's, not the bar's. The same + // discipline `refresh_matches_after_output` states — the search bar + // opening and closing around a selection must not erase it (#584). window.focus(&self.focus_handle, cx); cx.notify(); } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 843bc526..70d1f6d4 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -255,6 +255,11 @@ pub struct TerminalView { pending_history: Option, completion: Option, remote_completion_inflight: bool, + /// Why the last remote listing produced nothing, when it failed: "no + /// candidates" and "the listing itself failed" used to end in the same + /// silence, and only one of them is normal (#585). Dismissed by the next + /// keystroke, like the integration notice. + remote_completion_notice: Option, completion_generation: u64, editor_handoff: Option, editor_handoff_interrupt_seq: Option, @@ -402,15 +407,8 @@ fn known_pty_shim(fg: &str) -> Option<&'static str> { fn integration_notice_message(wrapper: Option<&str>) -> String { match wrapper { - Some(w) => format!( - "tty7 shell integration is blocked in this pane — \u{201c}{w}\u{201d} is intercepting \ - shell reports, so inline completion and the Ctrl+R menu are unavailable. \ - The shell's own history search still works." - ), - None => "tty7 shell integration hasn't engaged in this pane, so inline completion and \ - the Ctrl+R menu are unavailable. A PTY wrapper (figterm-style) or an \ - unsupported shell setup can cause this." - .to_string(), + Some(w) => t_fmt(L10nKey::IntegrationNoticeBlocked, &[("wrapper", w)]), + None => t(L10nKey::IntegrationNoticeNotEngaged).to_string(), } } @@ -1253,6 +1251,7 @@ impl TerminalView { editor_handoff: None, editor_handoff_interrupt_seq: None, remote_completion_inflight: false, + remote_completion_notice: None, reverse_search: None, integration_notice: None, integration_notice_shown: false, @@ -1273,7 +1272,17 @@ impl TerminalView { cell_width: Pixels, line_height: Pixels, scale: f32, + cx: &mut Context, ) { + // A column change reflows every wrapped line, and a match point is an + // absolute (line, column) against the width it was scanned at — so + // the highlights keep washing the *old* positions until something + // rescans. Output rescans them (`Wakeup` → refresh), but a quiet + // local pane has no output coming: without this the drift outlasts + // the resize indefinitely (#586). Rescan with the output path's + // discipline — it keeps the selection and never scrolls. Rows alone + // reflow nothing, so a height-only drag stays cheap. + let cols_changed = cols != self.terminal.size().cols; if (cols, rows) != (self.terminal.size().cols, self.terminal.size().rows) { self.last_hover_cell = None; self.hovered_link = None; @@ -1298,6 +1307,9 @@ impl TerminalView { (cell_width.as_f32() * scale).round().max(1.) as u16, (line_height.as_f32() * scale).round().max(1.) as u16, ); + if cols_changed && self.search.is_some() { + self.refresh_matches_after_output(cx); + } } pub fn cwd(&self) -> Option { @@ -1570,9 +1582,18 @@ impl TerminalView { AlacEvent::PtyWrite(text) => self.terminal.write(text.into_bytes()), AlacEvent::ChildExit(_) | AlacEvent::Exit => { self.terminal.exited = true; - self.title = match self.workspace().is_some() && !self.terminal.child_exited() { - true => format!("{} — disconnected", self.default_title), - false => format!("{} — process exited", self.default_title), + // The pane keeps answering to its own name (an SSH pane's + // host, #438) — only the state suffix is localized (#602). + self.title = if self.workspace().is_some() && !self.terminal.child_exited() { + t_fmt( + L10nKey::PaneTitleDisconnected, + &[("title", &self.default_title)], + ) + } else { + t_fmt( + L10nKey::PaneTitleProcessExited, + &[("title", &self.default_title)], + ) }; if self.terminal.child_exited() { cx.emit(ChildExited); @@ -1640,6 +1661,11 @@ impl TerminalView { if self.integration_notice.take().is_some() { cx.notify(); } + // The remote-listing failure pill goes away on the next keystroke + // too — by then the user has seen it (#585). + if self.remote_completion_notice.take().is_some() { + cx.notify(); + } let reshaped = if cfg!(target_os = "macos") { super::input::reshape_option_keystroke( &ev.keystroke, @@ -4051,22 +4077,40 @@ impl TerminalView { return true; } self.remote_completion_inflight = true; + // The listing takes a network round-trip the menu says nothing about + // — paint the "listing…" pill now, or a slow link reads as a broken + // Tab key (#585). + cx.notify(); let route = crate::ui::sftp::SftpRoute::new(self.pane_id, self.workspace.clone()); let dir = req.dir.clone(); let line = line.to_string(); log::debug!(target: "tty7::completion", "listing {dir} over the remote's own connection"); cx.spawn(async move |this, cx| { let listed = cx.background_spawn(async move { route.list(&dir) }).await; - let entries = listed.unwrap_or_else(|e| { - log::warn!( - target: "tty7::completion", - "remote listing failed, treating it as no candidates: {e}" - ); - Vec::new() - }); + let (entries, failed) = match listed { + Ok(entries) => (entries, None), + Err(e) => { + // A failure is not an empty directory: the two used to + // end in the same silence (#585). + log::warn!( + target: "tty7::completion", + "remote listing failed, treating it as no candidates: {e}" + ); + (Vec::new(), Some(e.to_string())) + } + }; let _ = this.update(cx, |view, cx| { view.remote_completion_inflight = false; + if let Some(error) = failed { + view.remote_completion_notice = Some(t_fmt( + L10nKey::CompletionRemoteListingFailed, + &[("error", &error)], + )); + } view.remote_path_results(req, &line, cursor, entries, forward, cx); + // An empty listing closes the menu without one — the pill + // still has to come down. + cx.notify(); }); }) .detach(); @@ -4884,7 +4928,13 @@ impl TerminalView { Ok(forward) => LoopbackOpen::Forwarded(loopback.forwarded_url(forward.local_port)), Err(e) => { log::warn!("failed to forward loopback URL {url}: {e}"); - LoopbackOpen::ForwardFailed(format!("Couldn't forward :{} — {e}", loopback.port)) + LoopbackOpen::ForwardFailed(t_fmt( + L10nKey::LoopbackForwardFailed, + &[ + ("port", &loopback.port.to_string()), + ("error", &e.to_string()), + ], + )) } } } @@ -5665,23 +5715,45 @@ impl TerminalView { cx: &mut Context, ) -> Option> { let text = self.integration_notice.clone()?; + Some(Self::notice_pill(text, cx)) + } + + /// The one-row pill that floats over the pane's bottom-right corner. + fn notice_pill(text: String, cx: &App) -> impl IntoElement + use<> { let theme = cx.theme(); - Some( - div() - .absolute() - .bottom(px(GRID_PAD_Y)) - .right(px(GRID_PAD_X)) - .max_w(px(560.)) - .px_3() - .py_1() - .bg(theme.popover) - .border_1() - .border_color(theme.border) - .rounded(px(6.)) - .text_size(px(12.)) - .text_color(theme.muted_foreground) - .child(text), - ) + div() + .absolute() + .bottom(px(GRID_PAD_Y)) + .right(px(GRID_PAD_X)) + .max_w(px(560.)) + .px_3() + .py_1() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .rounded(px(6.)) + .text_size(px(12.)) + .text_color(theme.muted_foreground) + .child(text) + } + + /// What the remote-completion pill should say right now, if anything: + /// the listing's failure once it has one, "listing…" while it runs, and + /// nothing at all the rest of the time (#585). + fn remote_completion_notice_text(&self) -> Option { + if let Some(notice) = &self.remote_completion_notice { + return Some(notice.clone()); + } + self.remote_completion_inflight + .then(|| t(L10nKey::CompletionListingRemote).to_string()) + } + + fn render_remote_completion_notice( + &self, + cx: &mut Context, + ) -> Option> { + let text = self.remote_completion_notice_text()?; + Some(Self::notice_pill(text, cx)) } fn kind_color(&self, kind: TokenKind, cx: &App) -> gpui::Hsla { @@ -5780,6 +5852,7 @@ impl Render for TerminalView { .then(|| self.render_reverse_search_menu(cx)) .flatten(); let integration_notice = self.render_integration_notice(cx); + let remote_completion_notice = self.render_remote_completion_notice(cx); let menu_focus = self.focus_handle.clone(); let has_selection = self.any_selection(); @@ -5864,6 +5937,7 @@ impl Render for TerminalView { .children(completion_menu) .children(reverse_search_menu) .children(integration_notice) + .children(remote_completion_notice) .context_menu(move |menu, window, cx| { // Suppressing the popup means handing back an item-less menu: // gpui-component's `ContextMenu` element skips rendering the @@ -8575,16 +8649,16 @@ mod gpui_tests { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { - view.set_grid_size(80, 24, px(8.), px(17.), 1.); + view.set_grid_size(80, 24, px(8.), px(17.), 1., cx); view.hover_link_at(0, 23, true, cx); assert_eq!(view.last_hover_cell, Some((0, 23))); view.hovered_link = Some(HoveredLink { start: Point::new(Line(23), Column(0)), end: Point::new(Line(23), Column(3)), }); - view.set_grid_size(80, 24, px(8.), px(17.), 1.); + view.set_grid_size(80, 24, px(8.), px(17.), 1., cx); assert_eq!(view.last_hover_cell, Some((0, 23))); - view.set_grid_size(80, 8, px(8.), px(17.), 1.); + view.set_grid_size(80, 8, px(8.), px(17.), 1., cx); assert!(view.last_hover_cell.is_none(), "the cell is stale"); assert!(view.hovered_link.is_none(), "so is the link it resolved"); }) @@ -10611,6 +10685,29 @@ mod gpui_tests { ); } + #[gpui::test] + fn a_remote_listing_says_so_while_it_runs_and_when_it_fails(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, _| { + assert!(view.remote_completion_notice_text().is_none()); + view.remote_completion_inflight = true; + assert_eq!( + view.remote_completion_notice_text().as_deref(), + Some("listing remote…"), + "a slow link must not read as a broken Tab key (#585)" + ); + view.remote_completion_inflight = false; + view.remote_completion_notice = Some("remote listing failed — boom".to_string()); + assert_eq!( + view.remote_completion_notice_text().as_deref(), + Some("remote listing failed — boom"), + "a failed listing is not the same silence as an empty one" + ); + }) + .unwrap(); + } + #[gpui::test] fn a_tab_on_a_detached_remote_pane_never_asks_for_a_listing(cx: &mut TestAppContext) { use std::io::Write as _; @@ -10957,6 +11054,147 @@ mod gpui_tests { .unwrap(); } + /// A match point is an absolute (line, column) against the width it was + /// scanned at, so a column change reflows the text out from under every + /// highlight. Output rescans them, but a quiet pane has none coming — + /// the resize itself has to rescan (#586). + #[gpui::test] + fn a_column_resize_rescans_the_open_searchs_highlights(cx: &mut TestAppContext) { + // Rooted: the open bar's input reaches for `Root` when a frame draws + // (see `rooted_harness`). + let (window, view, mut daemon) = rooted_harness(cx); + + // 76 columns of text: one row at 80 wide, wrapped onto two at 40. + let mut line = vec![b'a'; 70]; + line.extend_from_slice(b"needle\r\n"); + DaemonMsg::Output(line).encode(&mut daemon).unwrap(); + + for _ in 0..200 { + let ready = cx.update(|cx| { + let v = view.read(cx); + let term = v.terminal.term.lock(); + let grid = term.grid(); + (0..grid.screen_lines() as i32) + .any(|l| (0..grid.columns()).any(|c| grid[Line(l)][Column(c)].c == 'n')) + }); + if ready { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |_, window, cx| { + view.update(cx, |v, cx| { + v.open_search(window, cx); + let input = v.search.as_ref().unwrap().input.clone(); + input.update(cx, |s, cx| s.set_value("needle", window, cx)); + v.recompute_matches(cx); + let before = *v.search.as_ref().unwrap().matches[0].start(); + assert_eq!( + (before.line.0, before.column.0), + (0, 70), + "one unwrapped row at 80 columns" + ); + + // No output, no debounce: the resize alone has to move the + // highlight to where the reflow put the text. Where exactly + // the wrapped half lands (next row, or the first half pushed + // into scrollback) is the grid's business — what matters is + // that the highlight sits on the needle, not its old row. + v.set_grid_size(40, 24, px(8.), px(17.), 1., cx); + let after = *v.search.as_ref().unwrap().matches[0].start(); + assert_ne!( + (after.line.0, after.column.0), + (0, 70), + "column 70 does not even exist at 40 wide — a stale point" + ); + let term = v.terminal.term.lock(); + let cell = term.grid()[after.line][after.column].c; + drop(term); + assert_eq!(cell, 'n', "the highlight follows the reflowed text"); + + // A rows-only change reflows nothing; the scan must not move. + v.set_grid_size(40, 12, px(8.), px(17.), 1., cx); + let still = *v.search.as_ref().unwrap().matches[0].start(); + assert_eq!( + (still.line.0, still.column.0), + (after.line.0, after.column.0) + ); + }); + }) + .unwrap(); + } + + /// The selection that seeds the search query is the thing being searched + /// for — opening the bar must not erase it, and closing the bar must not + /// either (#584). Only *changing* the query retires it. + #[gpui::test] + fn the_search_bar_opens_and_closes_around_a_grid_selection(cx: &mut TestAppContext) { + let (window, view, mut daemon) = rooted_harness(cx); + + DaemonMsg::Output(b"some needle in the haystack\r\n".to_vec()) + .encode(&mut daemon) + .unwrap(); + for _ in 0..200 { + let ready = cx.update(|cx| { + let v = view.read(cx); + let term = v.terminal.term.lock(); + let grid = term.grid(); + (0..grid.screen_lines() as i32) + .any(|l| (0..grid.columns()).any(|c| grid[Line(l)][Column(c)].c == 'n')) + }); + if ready { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |_, window, cx| { + view.update(cx, |v, cx| { + // Select "needle" (row 0, columns 5..=10 — the end side + // includes its cell) — the seed the bar picks up. + let mut sel = Selection::new( + SelectionType::Simple, + Point::new(Line(0), Column(5)), + Side::Left, + ); + sel.update(Point::new(Line(0), Column(10)), Side::Right); + v.terminal.term.lock().selection = Some(sel); + + v.open_search(window, cx); + assert_eq!( + v.search.as_ref().unwrap().input.read(cx).value(), + "needle", + "the bar opens on the selection as its query" + ); + assert!( + v.terminal.term.lock().selection.is_some(), + "opening the bar must not eat the selection that seeded it" + ); + + v.close_search(window, cx); + assert!( + v.terminal.term.lock().selection.is_some(), + "closing the bar must not eat it either" + ); + + // But a query the user *changed* retires the old selection: + // it no longer names what the search is about. + v.open_search(window, cx); + let input = v.search.as_ref().unwrap().input.clone(); + input.update(cx, |s, cx| s.set_value("haystack", window, cx)); + v.recompute_matches(cx); + assert!( + v.terminal.term.lock().selection.is_none(), + "a changed query retires the stale selection" + ); + }); + }) + .unwrap(); + } + #[gpui::test] fn output_under_an_open_search_bar_is_searched_too(cx: &mut TestAppContext) { let (window, view, mut daemon) = rooted_harness(cx); diff --git a/src/ui/app.rs b/src/ui/app.rs index 974aca85..3ced9798 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -270,6 +270,11 @@ pub struct Tab { pub pane: Pane, pub name: Option, last_focused: Option, + /// The pane zoomed in this tab, stashed here by `activate` while another + /// tab is on screen — zoom is a tab's view state, not the window's, so + /// looking at another tab and coming back must not lose it (#599). `None` + /// while the tab is active: then the zoom lives in `Tty7App::maximized`. + pub(crate) zoomed: Option>, pub(crate) diff_overlay: Option, pub(crate) code: Option>, pub(crate) sidebar_group: std::cell::RefCell>, @@ -293,6 +298,7 @@ impl Tab { pane, name: None, last_focused: None, + zoomed: None, diff_overlay: None, code: None, overlay_top: OverlayTop::default(), @@ -307,6 +313,7 @@ impl Tab { pane, name: tree.name.clone(), last_focused: None, + zoomed: None, diff_overlay: None, code: None, overlay_top: OverlayTop::default(), @@ -450,7 +457,11 @@ impl Tab { } pub(crate) struct Renaming { - pub(crate) index: usize, + /// The tab being renamed, by tree id rather than index: an index drifts + /// the moment any other tab closes or the strip reorders, which used to + /// force every unrelated tab event to throw the half-typed name away — + /// and left a window where the commit landed on the wrong tab (#598). + pub(crate) tab: tty7_core::core::machine::TabId, pub(crate) input: Entity, _subs: Vec, } @@ -1373,6 +1384,7 @@ impl Tty7App { pane, name: st.name, last_focused: None, + zoomed: None, diff_overlay: None, code: None, overlay_top: OverlayTop::default(), @@ -3396,8 +3408,23 @@ impl Tty7App { pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { if index < self.tabs.len() && index != self.active { self.remember_active_pane(window, cx); - self.maximized = None; + // Zoom rides with its tab (#599): stash the outgoing tab's zoom + // and bring the incoming tab's back. The clears elsewhere (drag, + // split, close) still stand — those genuinely reshape what was + // zoomed; merely looking at another tab does not. + if let Some(outgoing) = self.tabs.get_mut(self.active) { + outgoing.zoomed = self.maximized.take(); + } self.active = index; + self.maximized = self.tabs[index].zoomed.take().filter(|leaf| { + // The zoomed pane may have exited while its tab was away — + // a stale entity must not come back as the zoom. + self.tabs[index] + .pane + .leaves() + .iter() + .any(|l| l.entity_id() == leaf.entity_id()) + }); self.maybe_refresh_diff_overlay(cx); self.sidebar_scroll.scroll_to_item(index); if self.code_panel_visible() { @@ -3454,7 +3481,6 @@ impl Tty7App { return; } self.maximized = None; - self.renaming = None; let worktree_cwd = self.tab_host_cwd(index, window, cx); let snapshot = tab_to_session(&self.tabs[index], cx); self.closed.push(snapshot); @@ -3465,6 +3491,15 @@ impl Tty7App { kill_pane_off_thread(leaf.read(cx).pane_route(), leaf.read(cx).pane_id, cx); } self.tabs.remove(index); + // Only losing the renaming tab itself ends the rename — closing an + // unrelated tab must not throw the half-typed name away (#598). + if self + .renaming + .as_ref() + .is_some_and(|r| !self.tabs.iter().any(|t| t.tree_id.get() == r.tab)) + { + self.renaming = None; + } if self.tabs.is_empty() { self.active = 0; } else if self.active >= self.tabs.len() { @@ -3918,7 +3953,8 @@ impl Tty7App { if order.len() != self.tabs.len() || order.iter().enumerate().all(|(i, &o)| i == o) { return; } - self.renaming = None; + // The rename box rides out a reorder: it tracks its tab by tree id, + // so the drift that once forced it closed here is gone (#598). let was_active = self.active; let mut slots: Vec> = std::mem::take(&mut self.tabs) .into_iter() @@ -3962,7 +3998,7 @@ impl Tty7App { }, )]; self.renaming = Some(Renaming { - index, + tab: self.tabs[index].tree_id.get(), input, _subs: subs, }); @@ -4005,7 +4041,11 @@ impl Tty7App { return; }; let value = renaming.input.read(cx).value().trim().to_string(); - if let Some(tab) = self.tabs.get_mut(renaming.index) { + if let Some(tab) = self + .tabs + .iter_mut() + .find(|t| t.tree_id.get() == renaming.tab) + { tab.name = if value.is_empty() { None } else { Some(value) }; } self.save_session(cx); @@ -5252,6 +5292,16 @@ impl Tty7App { else { return; }; + // A typo here is not a directory, and the daemon then silently falls + // back to its own cwd for every new pane — "new shells don't start in + // my project" reads as a tty7 bug rather than a typo (#601). Refuse + // to save, the proxy row's pattern (#551): the field keeps the text, + // the settings row explains in red, and the last good value stays in + // config.json. + if !wd_path_saveable(&path) { + cx.notify(); + return; + } let cfg = cx.global_mut::(); if cfg.working_directory.path == path { return; @@ -6920,6 +6970,7 @@ fn tabs_from_session( pane, name: st.name.clone(), last_focused: None, + zoomed: None, diff_overlay: None, code: None, overlay_top: OverlayTop::default(), @@ -7321,6 +7372,17 @@ fn quote_shell_arg(arg: &str) -> String { quoted } +/// The one rule the "Start in" custom path lives by (#601): empty means unset +/// and saves; anything else must name a directory that exists, because the +/// daemon's picker skips a path that is not one and every new pane then +/// silently starts somewhere else. Settings refuses to save such a value and +/// marks it red — both decide through this, so the red line and the not-saved +/// config always agree. Local on purpose: this is the local daemon's config. +pub(crate) fn wd_path_saveable(path: &str) -> bool { + let path = path.trim(); + path.is_empty() || std::path::Path::new(path).is_dir() +} + pub(crate) fn parse_ssh_option_words(input: &str) -> Result, ()> { let mut words = Vec::new(); let mut current = String::new(); @@ -7707,9 +7769,29 @@ mod tests { use super::{ CloseReason, TabAgentSession, clear_window_override_values, close_prompt, join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for, parse_ssh_connect_input, - parse_ssh_option_words, split_shell_args, + parse_ssh_option_words, split_shell_args, wd_path_saveable, }; + #[test] + fn a_start_in_path_saves_only_when_it_names_a_real_directory() { + // Empty is "unset", not a broken path. + assert!(wd_path_saveable("")); + assert!(wd_path_saveable(" ")); + let real = std::env::temp_dir(); + let real = real.to_str().expect("temp dir is utf-8 here"); + assert!(wd_path_saveable(real), "{real} exists"); + assert!( + wd_path_saveable(&format!(" {real} ")), + "the commit trims, so the check trims too" + ); + assert!(!wd_path_saveable("/definitely/not/a/real/dir")); + // A file is not a directory the shell can start in. + let file = std::env::temp_dir().join("tty7-wd-saveable-probe"); + std::fs::write(&file, b"x").expect("write probe file"); + assert!(!wd_path_saveable(file.to_str().expect("utf-8"))); + let _ = std::fs::remove_file(&file); + } + #[test] fn the_close_question_names_what_it_is_about_to_end() { use crate::terminal::view::PaneBusy; @@ -8637,6 +8719,98 @@ mod rename_gpui_tests { ); }); } + #[gpui::test] + fn a_rename_rides_out_other_tabs_closing_and_the_strip_reordering(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + + app.update_in(&mut vcx, |app, window, cx| { + app.start_rename(2, window, cx); + let target = app.tabs[2].tree_id.get(); + let input = app.renaming.as_ref().expect("the box is up").input.clone(); + input.update(cx, |s, cx| s.set_value("mine", window, cx)); + + // An unrelated close used to throw the half-typed name away + // (#598). + app.close_tab_inner(0, true, window, cx); + assert!( + app.renaming.is_some(), + "closing another tab keeps the rename box" + ); + + // So did a drag-reorder — and the index the commit once landed + // on had by then drifted onto a different tab. + let order: Vec = (0..app.tabs.len()).rev().collect(); + app.apply_tab_order(&order, cx); + assert!(app.renaming.is_some(), "a reorder keeps the rename box"); + + app.commit_rename(window, cx); + let named: Vec<_> = app + .tabs + .iter() + .filter(|t| t.name.as_deref() == Some("mine")) + .collect(); + assert_eq!(named.len(), 1, "the name landed on exactly one tab"); + assert_eq!( + named[0].tree_id.get(), + target, + "and that tab is the one the box was opened on" + ); + }); + + // Closing the renaming tab itself still ends the rename. + app.update_in(&mut vcx, |app, window, cx| { + app.start_rename(0, window, cx); + assert!(app.renaming.is_some()); + app.close_tab_inner(0, true, window, cx); + assert!(app.renaming.is_none(), "losing its own tab closes the box"); + }); + } +} + +// Zoom is a tab's view state: it rides with the tab across a switch, while a +// layout change (drag, split, close) still clears it. +#[cfg(all(test, unix))] +mod zoom_gpui_tests { + use gpui::TestAppContext; + + use crate::ui::app::test_window::harness_with_tabs; + + #[gpui::test] + fn a_tabs_zoom_survives_a_round_trip_to_another_tab(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + let leaf = app.tabs[0] + .pane + .first_leaf() + .and_then(|slot| slot.terminal().cloned()) + .expect("tab 0 has a pane"); + app.maximized = Some(leaf.clone()); + + app.activate(1, window, cx); + assert!(app.maximized.is_none(), "tab 1 never zoomed anything"); + + app.activate(0, window, cx); + assert_eq!( + app.maximized.as_ref().map(|l| l.entity_id()), + Some(leaf.entity_id()), + "tab 0's zoom is still where it was left (#599)" + ); + + // A zoom stashed for a pane that is no longer in the tab does not + // come back — it exited (or was closed) while the tab was away. + app.maximized = Some(leaf.clone()); + app.activate(1, window, cx); + app.tabs[0].zoomed = Some(leaf); + app.tabs[0].pane = + crate::ui::pane::Pane::leaf(app.tabs[1].pane.first_leaf().expect("a donor leaf")); + app.activate(0, window, cx); + assert!( + app.maximized.is_none(), + "a stashed zoom whose pane is gone stays gone" + ); + }); + } } // A test window has no daemon behind it — its socket path is under the pinned diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 6794b2e7..6914a308 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -709,7 +709,12 @@ fn rollback_write( children.remove(host, &dir.to_path_buf()); } -pub(crate) fn shell_quote(path: &Path) -> String { +/// Quote a path for the shell the pane is actually running. cmd.exe only +/// treats double quotes as quoting — a single quote is an ordinary character +/// there, so the POSIX form would split the path at its first space +/// (#593). PowerShell and every POSIX shell take the single-quoted form, so +/// an unknown shell keeps it too. +pub(crate) fn shell_quote_for(path: &Path, shell_program: Option<&str>) -> String { let s = path.to_string_lossy(); if !s.is_empty() && s.chars() @@ -717,7 +722,19 @@ pub(crate) fn shell_quote(path: &Path) -> String { { return s.into_owned(); } - format!("'{}'", s.replace('\'', r"'\''")) + let is_cmd = shell_program + .map(|p| { + let base = p.rsplit(['\\', '/']).next().unwrap_or(p); + base.eq_ignore_ascii_case("cmd") || base.eq_ignore_ascii_case("cmd.exe") + }) + .unwrap_or(false); + if is_cmd { + // Windows paths cannot contain a double quote, so there is nothing + // to escape inside the quotes. + format!("\"{s}\"") + } else { + format!("'{}'", s.replace('\'', r"'\''")) + } } impl Tty7App { @@ -1487,8 +1504,9 @@ impl Tty7App { else { return; }; + let program = leaf.read(cx).shell_spec().map(|spec| spec.program); leaf.read(cx) - .run_command_line(&format!("cd {}", shell_quote(dir))); + .run_command_line(&format!("cd {}", shell_quote_for(dir, program.as_deref()))); self.focus_active(window, cx); } @@ -1965,7 +1983,10 @@ impl Tty7App { .get(this.active) .and_then(|t| t.pane.focused_or_first(window, cx)) { - leaf.update(cx, |view, cx| view.paste(shell_quote(&p), cx)); + leaf.update(cx, |view, cx| { + let program = view.shell_spec().map(|spec| spec.program); + view.paste(shell_quote_for(&p, program.as_deref()), cx); + }); } }); } @@ -2643,9 +2664,37 @@ mod tests { #[test] fn shell_quote_leaves_safe_paths_and_quotes_the_rest() { - assert_eq!(shell_quote(Path::new("/a/b.txt")), "/a/b.txt"); - assert_eq!(shell_quote(Path::new("/a dir/f")), "'/a dir/f'"); - assert_eq!(shell_quote(Path::new("/a'b")), r"'/a'\''b'"); + assert_eq!(shell_quote_for(Path::new("/a/b.txt"), None), "/a/b.txt"); + assert_eq!(shell_quote_for(Path::new("/a dir/f"), None), "'/a dir/f'"); + assert_eq!(shell_quote_for(Path::new("/a'b"), None), r"'/a'\''b'"); + } + + #[test] + fn shell_quote_for_picks_double_quotes_only_for_cmd() { + let spaced = Path::new("/a dir/f"); + assert_eq!(shell_quote_for(spaced, Some("cmd.exe")), "\"/a dir/f\""); + assert_eq!( + shell_quote_for(spaced, Some("C:\\Windows\\System32\\cmd.exe")), + "\"/a dir/f\"", + "a full path to cmd still names cmd" + ); + assert_eq!(shell_quote_for(spaced, Some("CMD.EXE")), "\"/a dir/f\""); + assert_eq!( + shell_quote_for(spaced, Some("powershell.exe")), + "'/a dir/f'" + ); + assert_eq!(shell_quote_for(spaced, Some("pwsh")), "'/a dir/f'"); + assert_eq!(shell_quote_for(spaced, Some("/bin/bash")), "'/a dir/f'"); + assert_eq!( + shell_quote_for(spaced, None), + "'/a dir/f'", + "an unknown shell keeps the POSIX form" + ); + assert_eq!( + shell_quote_for(Path::new("/plain"), Some("cmd.exe")), + "/plain", + "a path that needs no quoting stays bare under cmd too" + ); } #[test] diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 80ce60df..5d2e3ff0 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -387,6 +387,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsWdInherit => "Inherit", L10nKey::SettingsWdHome => "Home", L10nKey::SettingsWdCustom => "Custom", + L10nKey::SettingsWdPathInvalid => { + "That directory does not exist — the value was not saved." + } L10nKey::SettingsShellFooter => { "Applies to shells with nothing to inherit — like the first tab of a window. New tabs and splits keep inheriting the active pane's directory, and shells already open keep running." } @@ -1081,7 +1084,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.", L10nKey::ScmOpenChanges => "Open Changes", L10nKey::ScmDiscardAllConfirm => { - "Discard every change in this repository? This cannot be undone." + "Discard all unstaged and untracked changes? Staged changes are kept. \ + This cannot be undone." } L10nKey::ScmAmendConfirm => { "Amend the last commit? It will be replaced by a new one, so anyone who already has it has to reconcile." @@ -1302,6 +1306,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SwitcherTabsAfterOpening => "Open this workspace to see its tabs.", L10nKey::SwitcherOpenToManage => "Open this workspace to rename or stop it.", L10nKey::SwitcherConnectToUse => "Connect to this machine to open a workspace on it.", + L10nKey::SwitcherOrphanPanes => { + "Background panes — shells still running outside any window:" + } L10nKey::SwitcherTabCount => "{n} tabs", L10nKey::SwitcherTabCountOne => "1 tab", L10nKey::SwitcherActiveTab => "active", @@ -1489,6 +1496,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppReopenTabFailed => "Could not reopen the tab: no terminal started", L10nKey::AppOpenTerminalFailed => "Could not open a terminal: {error}", L10nKey::AppTabsNotRestored => "{count} tabs from last time could not be reopened", + L10nKey::LaunchWorkspacesLeftRunning => { + "Only this window was restored — {count} workspaces are still running in the background. Reopen them from the sidebar." + } L10nKey::AppSshConnectionFailed => "SSH connection failed: {error}", L10nKey::AppSshReconnectFailed => "SSH reconnect failed: {error}", L10nKey::AppSplitPaneFailed => "Could not split the pane: {error}", @@ -1616,6 +1626,27 @@ pub fn translate_en(key: L10nKey) -> &'static str { adds is written back when it closes, so nothing is lost. Applies to bash and zsh \ panes that tty7 can set up; a shell started with your own arguments is left alone." } + L10nKey::IntegrationNoticeBlocked => { + "tty7 shell integration is blocked in this pane — \u{201c}{wrapper}\u{201d} is \ + intercepting shell reports, so inline completion and the Ctrl+R menu are \ + unavailable. The shell's own history search still works." + } + L10nKey::IntegrationNoticeNotEngaged => { + "tty7 shell integration hasn't engaged in this pane, so inline completion and the \ + Ctrl+R menu are unavailable. A PTY wrapper (figterm-style) or an unsupported shell \ + setup can cause this." + } + L10nKey::PaneTitleDisconnected => "{title} — disconnected", + L10nKey::PaneTitleProcessExited => "{title} — process exited", + L10nKey::LoopbackForwardFailed => "Couldn't forward :{port} — {error}", + L10nKey::TrayTooltipAgents => "tty7 — {parts}", + L10nKey::TrayAgentSep => ", ", + L10nKey::CursorShapeBlock => "Block", + L10nKey::CursorShapeBar => "Bar", + L10nKey::CursorShapeUnderline => "Underline", + L10nKey::PaletteTryDifferentSearch => "Try a different search.", + L10nKey::CompletionListingRemote => "listing remote…", + L10nKey::CompletionRemoteListingFailed => "remote listing failed — {error}", L10nKey::PanelMoreChangedFiles => { "… and {count} more changed files — run git diff to see them." } @@ -1775,6 +1806,12 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::AppTabsNotRestored, "other") => { "{count} tabs from last time could not be reopened" } + (L10nKey::LaunchWorkspacesLeftRunning, "one") => { + "Only this window was restored — 1 workspace is still running in the background. Reopen it from the sidebar." + } + (L10nKey::LaunchWorkspacesLeftRunning, "other") => { + "Only this window was restored — {count} workspaces are still running in the background. Reopen them from the sidebar." + } (L10nKey::ScmFilesChanged, "zero") => "No files changed", (L10nKey::ScmFilesChanged, "one") => "1 file changed", (L10nKey::ScmFilesChanged, "other") => "{count} files changed", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0891b21d..f1f89957 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -392,6 +392,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsWdInherit => "継承", L10nKey::SettingsWdHome => "ホーム", L10nKey::SettingsWdCustom => "カスタム", + L10nKey::SettingsWdPathInvalid => { + "このディレクトリは存在しないため、この値は保存されませんでした" + } L10nKey::SettingsShellFooter => { "継承元のないシェルに適用されます。ウィンドウの最初のタブなどです。新しいタブと分割はアクティブなペインのディレクトリを引き継ぎ、開いているシェルは動き続けます" } @@ -1139,7 +1142,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::ScmOpenChanges => "変更を開く", L10nKey::ScmDiscardAllConfirm => { - "このリポジトリのすべての変更を破棄しますか?元に戻せません。" + "未ステージの変更と未追跡ファイルをすべて破棄しますか?ステージ済みの変更は残ります。元に戻せません。" } L10nKey::ScmAmendConfirm => { "直前のコミットを修正しますか?新しいコミットに置き換わるため、すでに取得した人は対応が必要になります。" @@ -1340,6 +1343,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherTabsAfterOpening => "このワークスペースを開くとタブが表示されます", L10nKey::SwitcherOpenToManage => "このワークスペースを開くと名前の変更や停止ができます", L10nKey::SwitcherConnectToUse => "このマシンに接続するとワークスペースを作成できます", + L10nKey::SwitcherOrphanPanes => { + "バックグラウンドペイン — どのウィンドウにも属さずに実行中のシェル:" + } L10nKey::SwitcherTabCount => "{n} 個のタブ", L10nKey::SwitcherTabCountOne => "1 個のタブ", L10nKey::SwitcherActiveTab => "アクティブ", @@ -1531,6 +1537,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppReopenTabFailed => "タブを開き直せませんでした: ターミナルが起動しませんでした", L10nKey::AppOpenTerminalFailed => "ターミナルを開けませんでした: {error}", L10nKey::AppTabsNotRestored => "前回のタブ {count} 個を開き直せませんでした", + L10nKey::LaunchWorkspacesLeftRunning => { + "このウィンドウだけを復元しました — あと {count} 個のワークスペースがバックグラウンドで実行中です。サイドバーから開き直せます。" + } L10nKey::AppSshConnectionFailed => "SSH 接続に失敗しました: {error}", L10nKey::AppSshReconnectFailed => "SSH 再接続に失敗しました: {error}", L10nKey::AppSplitPaneFailed => "ペインを分割できませんでした: {error}", @@ -1669,6 +1678,26 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { 新しいペインは空ではなく既存の履歴から始まり、追加された分はペインを閉じるときに書き戻されるので失われません。\ tty7 が設定できる bash と zsh のペインが対象で、独自の引数で起動したシェルはそのままです" } + L10nKey::IntegrationNoticeBlocked => { + "このペインでは tty7 シェル統合がブロックされています。“{wrapper}”がシェルレポートを\ + 横取りしているため、インライン補完と Ctrl+R メニューは利用できません。\ + シェル独自の履歴検索は引き続き使えます。" + } + L10nKey::IntegrationNoticeNotEngaged => { + "このペインでは tty7 シェル統合が有効になっていないため、インライン補完と Ctrl+R \ + メニューは利用できません。PTY ラッパー(figterm 系)や未対応のシェル設定が原因の可能性があります。" + } + L10nKey::PaneTitleDisconnected => "{title} — 切断されました", + L10nKey::PaneTitleProcessExited => "{title} — プロセスが終了しました", + L10nKey::LoopbackForwardFailed => ":{port} を転送できませんでした — {error}", + L10nKey::TrayTooltipAgents => "tty7: {parts}", + L10nKey::TrayAgentSep => "、", + L10nKey::CursorShapeBlock => "ブロック", + L10nKey::CursorShapeBar => "バー", + L10nKey::CursorShapeUnderline => "下線", + L10nKey::PaletteTryDifferentSearch => "別のキーワードを試してください。", + L10nKey::CompletionListingRemote => "リモートを一覧しています…", + L10nKey::CompletionRemoteListingFailed => "リモートの一覧に失敗しました — {error}", L10nKey::PanelMoreChangedFiles => { "… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください" } @@ -1825,6 +1854,12 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat } (L10nKey::AppTabsNotRestored, "one") => "前回のタブ 1 個を開き直せませんでした", (L10nKey::AppTabsNotRestored, "other") => "前回のタブ {count} 個を開き直せませんでした", + (L10nKey::LaunchWorkspacesLeftRunning, "one") => { + "このウィンドウだけを復元しました — あと 1 個のワークスペースがバックグラウンドで実行中です。サイドバーから開き直せます。" + } + (L10nKey::LaunchWorkspacesLeftRunning, "other") => { + "このウィンドウだけを復元しました — あと {count} 個のワークスペースがバックグラウンドで実行中です。サイドバーから開き直せます。" + } (L10nKey::ScmFilesChanged, "zero") => "変更されたファイルはありません", (L10nKey::ScmFilesChanged, "one") => "1 個のファイルが変更されました", (L10nKey::ScmFilesChanged, "other") => "{count} 個のファイルが変更されました", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index df5e6acb..2622a7ea 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -357,6 +357,7 @@ l10n_keys! { SettingsWdInherit, SettingsWdHome, SettingsWdCustom, + SettingsWdPathInvalid, SettingsShellFooter, SettingsScrolling, SettingsScrollback, @@ -1050,6 +1051,7 @@ l10n_keys! { SwitcherTabsAfterOpening, SwitcherOpenToManage, SwitcherConnectToUse, + SwitcherOrphanPanes, SwitcherTabCount, SwitcherTabCountOne, SwitcherActiveTab, @@ -1209,6 +1211,7 @@ l10n_keys! { AppReopenTabFailed, AppOpenTerminalFailed, AppTabsNotRestored, + LaunchWorkspacesLeftRunning, AppSshConnectionFailed, AppSshReconnectFailed, AppSplitPaneFailed, @@ -1302,6 +1305,19 @@ l10n_keys! { SettingsDaemonStaleDescInPlace, SettingsPerPaneHistory, SettingsPerPaneHistoryDescription, + IntegrationNoticeBlocked, + IntegrationNoticeNotEngaged, + PaneTitleDisconnected, + PaneTitleProcessExited, + LoopbackForwardFailed, + TrayTooltipAgents, + TrayAgentSep, + CursorShapeBlock, + CursorShapeBar, + CursorShapeUnderline, + PaletteTryDifferentSearch, + CompletionListingRemote, + CompletionRemoteListingFailed, } /// The source control strings that are translated but not yet displayed. diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 03a6a6d1..6f79fc95 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -339,6 +339,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsWdInherit => "继承", L10nKey::SettingsWdHome => "主目录", L10nKey::SettingsWdCustom => "自定义", + L10nKey::SettingsWdPathInvalid => "这个目录不存在,该值未保存。", L10nKey::SettingsShellFooter => { "仅适用于没有可继承目录的 shell,例如窗口的第一个标签页。新标签页和分屏仍会继承活动窗格的目录,已经打开的 shell 会继续运行。" } @@ -1025,7 +1026,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。", L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。", L10nKey::ScmOpenChanges => "查看改动", - L10nKey::ScmDiscardAllConfirm => "放弃本仓库的全部改动?此操作无法撤销。", + L10nKey::ScmDiscardAllConfirm => { + "放弃所有未暂存的改动和未跟踪的文件?已暂存的改动会保留。此操作无法撤销。" + } L10nKey::ScmAmendConfirm => { "修补上一次提交?它会被一个新提交取代,已经拿到旧提交的人需要自行处理。" } @@ -1218,6 +1221,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SwitcherTabsAfterOpening => "打开这个工作区后才能看到它的标签页。", L10nKey::SwitcherOpenToManage => "打开这个工作区后才能重命名或停止它。", L10nKey::SwitcherConnectToUse => "连接这台机器后才能在上面新建工作区。", + L10nKey::SwitcherOrphanPanes => "后台窗格——仍在运行、但不属于任何窗口的 shell:", L10nKey::SwitcherTabCount => "{n} 个标签页", L10nKey::SwitcherTabCountOne => "1 个标签页", L10nKey::SwitcherActiveTab => "当前", @@ -1402,6 +1406,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppReopenTabFailed => "无法重新打开标签页:没有启动终端", L10nKey::AppOpenTerminalFailed => "无法打开终端:{error}", L10nKey::AppTabsNotRestored => "上次的 {count} 个标签页没能重新打开", + L10nKey::LaunchWorkspacesLeftRunning => { + "只恢复了这个窗口——还有 {count} 个工作区在后台运行,可从侧边栏重新打开。" + } L10nKey::AppSshConnectionFailed => "SSH 连接失败:{error}", L10nKey::AppSshReconnectFailed => "SSH 重新连接失败:{error}", L10nKey::AppSplitPaneFailed => "无法拆分窗格:{error}", @@ -1522,6 +1529,25 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { 新面板会从你已有的历史开始,而不是一片空白;面板关闭时,它新增的部分会写回原来的历史文件,不会丢。\ 只对 tty7 能接管的 bash 和 zsh 面板生效;用你自己参数启动的 shell 不受影响。" } + L10nKey::IntegrationNoticeBlocked => { + "此窗格中的 tty7 shell 集成被拦截——“{wrapper}”截获了 shell 上报,\ + 内联补全和 Ctrl+R 菜单不可用。shell 自带的历史搜索仍可使用。" + } + L10nKey::IntegrationNoticeNotEngaged => { + "此窗格中的 tty7 shell 集成尚未生效,内联补全和 Ctrl+R 菜单不可用。\ + PTY 包装器(figterm 类)或不受支持的 shell 配置可能导致此问题。" + } + L10nKey::PaneTitleDisconnected => "{title} — 已断开", + L10nKey::PaneTitleProcessExited => "{title} — 进程已退出", + L10nKey::LoopbackForwardFailed => "无法转发 :{port}——{error}", + L10nKey::TrayTooltipAgents => "tty7:{parts}", + L10nKey::TrayAgentSep => "、", + L10nKey::CursorShapeBlock => "块状", + L10nKey::CursorShapeBar => "竖线", + L10nKey::CursorShapeUnderline => "下划线", + L10nKey::PaletteTryDifferentSearch => "换个关键词试试。", + L10nKey::CompletionListingRemote => "正在列出远程目录…", + L10nKey::CompletionRemoteListingFailed => "远程目录列表失败——{error}", L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 git diff 查看。", L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。", L10nKey::ScmFilesChanged => "{count} 个文件改动", @@ -1667,6 +1693,12 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat (L10nKey::SftpReplaceBody, "other") => "{names} 在这个文件夹里已经存在,上传会覆盖它们。", (L10nKey::AppTabsNotRestored, "one") => "上次的 1 个标签页没能重新打开", (L10nKey::AppTabsNotRestored, "other") => "上次的 {count} 个标签页没能重新打开", + (L10nKey::LaunchWorkspacesLeftRunning, "one") => { + "只恢复了这个窗口——还有 1 个工作区在后台运行,可从侧边栏重新打开。" + } + (L10nKey::LaunchWorkspacesLeftRunning, "other") => { + "只恢复了这个窗口——还有 {count} 个工作区在后台运行,可从侧边栏重新打开。" + } (L10nKey::PanelMoreChangedFiles, "zero") => "…还有 0 个变更文件——运行 git diff 查看。", (L10nKey::PanelMoreChangedFiles, "one") => "…还有 1 个变更文件——运行 git diff 查看。", (L10nKey::ScmFilesChanged, "zero") => "没有文件改动", diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 0d1efa5d..e49b6155 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -941,6 +941,14 @@ impl ListDelegate for PaletteDelegate { _window: &mut Window, cx: &mut Context>, ) -> impl IntoElement { + // The SSH hint only makes sense where typing user@host would actually + // connect — the root menu. A theme picker with no matches teaching SSH + // is a crossed wire (#602). + let hint = if self.quick_connect_root { + t(crate::ui::i18n::L10nKey::ConnectSshHint) + } else { + t(crate::ui::i18n::L10nKey::PaletteTryDifferentSearch) + }; v_flex() .py_8() .gap_1() @@ -950,11 +958,7 @@ impl ListDelegate for PaletteDelegate { .child(crate::ui::i18n::t( crate::ui::i18n::L10nKey::NoMatchingCommands, )) - .child( - div() - .text_xs() - .child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::ConnectSshHint)), - ) + .child(div().text_xs().child(hint)) } fn render_item( diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index 20cbcd0c..c162ffca 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -222,10 +222,13 @@ impl Tty7App { return; } let all = crate::ui::scm::panel::commit_stages_everything(&status, amend); - self.scm.amend = false; - // `run_git_op` arms `scm.committing` when the commit is actually - // dispatched — after the amend confirmation, not before it — so a - // cancelled prompt leaves nothing armed. See `scm_commit_landed`. + // The amend toggle is *not* cleared here: `run_git_op` clears it + // where it arms `scm.committing`, at dispatch — after the amend + // confirmation, not before it — so a cancelled prompt leaves the + // toggle and the armed flag exactly as the user set them. Clearing + // here made Cancel quietly switch amend off, and the next Commit + // became the new-commit the user had just declined to risk. See + // `scm_commit_landed`. self.scm_op_then( repo, GitOp::Commit { diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 88a0f89c..93408c7e 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -2237,7 +2237,11 @@ impl Tty7App { }; let cursor_style_control = self.segmented( "cursor-style", - &["Block", "Bar", "Underline"], + &[ + t(L10nKey::CursorShapeBlock), + t(L10nKey::CursorShapeBar), + t(L10nKey::CursorShapeUnderline), + ], cursor_idx, cx, |this, ix, _w, cx| { @@ -5051,10 +5055,21 @@ impl Tty7App { }, ); let wd_path_control = if wd_strategy == WdStrategy::Custom { - div() + // Same pattern as the Arguments row above, with its caveat: the + // input commits on Enter/blur and this parent renders on that + // commit, so a half-typed path is never marked wrong + // mid-keystroke. `commit_working_directory_path` refuses the same + // value through the same predicate, so the red line and the + // not-saved config always agree (#601). + let wd_path_value = wd_path_input.read(cx).value(); + let wd_path_error = (!crate::ui::app::wd_path_saveable(&wd_path_value)) + .then(|| field_error(t(L10nKey::SettingsWdPathInvalid), cx)); + v_flex() + .gap_1() .w(px(260.)) .max_w_full() .child(Input::new(&wd_path_input).small()) + .when_some(wd_path_error, |this, line| this.child(line)) .into_any_element() } else { div().into_any_element() diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 63042c38..88dce4ff 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -190,6 +190,51 @@ pub(crate) struct HostSnapshot { pub rows: Vec, } +/// A pane the daemon runs that no workspace holds — what an interrupted +/// `tty7 run` leaves behind, and what `tty7 pane ls --all` points the CLI's +/// reaper at. The switcher is where a GUI user finds and closes one (#596). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OrphanPane { + pub pane_id: u64, + pub title: String, + pub cwd: Option, + pub owner: Option, +} + +/// Every pane id the local machine's workspaces hold — what the registry +/// listing is measured against to find the orphans (#596). +fn held_local_pane_ids(cx: &App) -> HashSet { + crate::ui::machine_mirror::MachineMirrors::machine(cx, crate::core::session::HostId::LOCAL) + .map(|machine| { + machine + .workspaces + .iter() + .flat_map(|ws| ws.tabs.iter()) + .flat_map(|tab| tab.root.pane_ids()) + .collect() + }) + .unwrap_or_default() +} + +/// The registry's live panes minus the ones a workspace holds. Dead entries +/// drop out too: a corpse the daemon has not reaped yet is not something the +/// user can act on. +pub(crate) fn orphan_panes_of( + listed: Vec, + held: &HashSet, +) -> Vec { + listed + .into_iter() + .filter(|info| info.alive && !held.contains(&info.pane_id)) + .map(|info| OrphanPane { + pane_id: info.pane_id, + title: info.title, + cwd: info.cwd.map(|p| p.display().to_string()), + owner: info.owner, + }) + .collect() +} + /// Which face the card is showing: the workspace list, or the create form. pub(crate) enum Page { List, @@ -227,6 +272,10 @@ pub(crate) struct Switcher { pub query: Entity, page: Page, renaming: Option<(WorkspaceId, Entity)>, + /// Panes the local daemon runs that no workspace holds (#596). Filled + /// asynchronously after the panel opens; empty both while the listing is + /// in flight and when there is nothing to reap. + orphans: Vec, column: Column, left_sel: usize, right_sel: usize, @@ -386,6 +435,7 @@ impl Tty7App { query, page: Page::List, renaming: None, + orphans: Vec::new(), column, left_sel: 0, right_sel: 0, @@ -409,9 +459,69 @@ impl Tty7App { { sw.left_sel = at; } + self.refresh_orphan_panes(cx); cx.notify(); } + /// List the local daemon's panes and keep the ones no workspace holds. + /// The query is blocking daemon I/O, so it runs off the UI thread; the + /// filter runs back on it, where the machine mirror lives. + /// + /// Local on purpose: a remote machine's orphans belong to its own daemon, + /// and routing a listing per host is what the CLI's reaper already does. + fn refresh_orphan_panes(&mut self, cx: &mut Context) { + cx.spawn(async move |this, cx| { + let listed = cx + .background_spawn(async move { tty7_core::client::PaneClient::local().list() }) + .await; + let listed = match listed { + Ok(listed) => listed, + Err(e) => { + log::warn!(target: "tty7::switcher", "orphan pane listing failed: {e}"); + return; + } + }; + let _ = this.update(cx, |this, cx| { + let held = held_local_pane_ids(cx); + if let Some(sw) = this.switcher.as_mut() { + sw.orphans = orphan_panes_of(listed, &held); + } + cx.notify(); + }); + }) + .detach(); + } + + /// Hang up one orphan pane and show what is left. The kill is + /// fire-and-forget, so the refresh that follows is also the confirmation: + /// a pane that survived it simply stays on the list. + fn close_orphan_pane(&mut self, pane_id: u64, cx: &mut Context) { + cx.spawn(async move |this, cx| { + let listed = cx + .background_spawn(async move { + let client = tty7_core::client::PaneClient::local(); + client.kill(pane_id)?; + client.list() + }) + .await; + let listed = match listed { + Ok(listed) => listed, + Err(e) => { + log::warn!(target: "tty7::switcher", "closing orphan %{pane_id} failed: {e}"); + return; + } + }; + let _ = this.update(cx, |this, cx| { + let held = held_local_pane_ids(cx); + if let Some(sw) = this.switcher.as_mut() { + sw.orphans = orphan_panes_of(listed, &held); + } + cx.notify(); + }); + }) + .detach(); + } + /// Ctrl+Tab. The first press raises the panel on the tab column with the /// previously used tab already highlighted; further presses walk it. Holding /// the modifier keeps the panel up, releasing it commits — IDEA's gesture. @@ -1529,6 +1639,14 @@ impl Tty7App { .child(t(L10nKey::SwitcherNoMatch)), ); } + // Orphan panes belong to the machine, not to a workspace, so they are + // not rows and join no navigation — the bottom of the workspace list + // is simply where a user looking for them finds them (#596). A search + // narrows the panel to workspaces, and the box steps out of the way + // for one. + if !sw.orphans.is_empty() && sw.text(cx).is_empty() { + list = list.child(self.render_orphan_panes(cx)); + } // Fixed height, not fit-to-content: the tab column changes length every // time the left cursor moves, and a card that resizes under the pointer @@ -1857,6 +1975,70 @@ impl Tty7App { .into_any_element() } + /// The orphan block under the local group (#596): one line per pane no + /// window holds — id, owner, where it runs — and the way to stop it. + fn render_orphan_panes(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let Some(switcher) = self.switcher.as_ref() else { + return v_flex(); + }; + let mut list = v_flex().gap(px(2.)); + for orphan in &switcher.orphans { + let mut bits = vec![format!("%{}", orphan.pane_id)]; + if let Some(owner) = &orphan.owner { + bits.push(owner.clone()); + } + if let Some(cwd) = &orphan.cwd { + bits.push(cwd.clone()); + } else if !orphan.title.is_empty() { + bits.push(orphan.title.clone()); + } + let pane_id = orphan.pane_id; + list = list.child( + h_flex() + .items_center() + .justify_between() + .gap(px(6.)) + .child( + div() + .text_xs() + .text_color(theme.foreground) + .child(bits.join(" · ")), + ) + .child( + Button::new(gpui::SharedString::from(format!( + "switcher-close-orphan:{pane_id}" + ))) + .label(t(L10nKey::Close)) + .ghost() + .xsmall() + .on_click(cx.listener( + move |this, _, _window, cx| { + this.close_orphan_pane(pane_id, cx); + }, + )), + ), + ); + } + v_flex() + .gap(px(4.)) + .mx(px(4.)) + .mt(px(6.)) + .mb(px(2.)) + .px(px(10.)) + .py(px(8.)) + .rounded(px(6.)) + .border_1() + .border_color(theme.border) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(t(L10nKey::SwitcherOrphanPanes)), + ) + .child(list) + } + /// The parked group's notice (#485): no retry button — nothing it could /// try would succeed — just the way back (a fresh profile rediscovers /// the session) and the two honest actions. @@ -3232,6 +3414,35 @@ mod tests { } } + #[test] + fn orphan_panes_of_keeps_only_live_panes_no_workspace_holds() { + use tty7_core::daemon::protocol::PaneInfo; + + fn info(pane_id: u64, alive: bool) -> PaneInfo { + PaneInfo { + pane_id, + cwd: Some(std::path::PathBuf::from("/tmp/x")), + title: "zsh".to_string(), + osc_title: None, + alive, + owner: Some("tty7-cli".to_string()), + } + } + + let held: HashSet = [2].into_iter().collect(); + let orphans = orphan_panes_of(vec![info(1, true), info(2, true), info(3, false)], &held); + assert_eq!( + orphans, + vec![OrphanPane { + pane_id: 1, + title: "zsh".to_string(), + cwd: Some("/tmp/x".to_string()), + owner: Some("tty7-cli".to_string()), + }], + "%2 is held by a workspace and %3 is dead — neither is a leak to reap (#596)" + ); + } + #[test] fn a_workspace_stays_in_the_list_when_only_one_of_its_tabs_matches() { let ws = row( diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 043315ce..d1270713 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -451,7 +451,7 @@ impl Tty7App { let rename_input = self .renaming .as_ref() - .filter(|r| r.index == i) + .filter(|r| r.tab == tab.tree_id.get()) .map(|r| r.input.clone()); let shown = SidebarRowShown { diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index cecbc858..d622d922 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1339,7 +1339,7 @@ impl Tty7App { let rename_input = self .renaming .as_ref() - .filter(|r| r.index == i) + .filter(|r| r.tab == tab.tree_id.get()) .map(|r| r.input.clone()); let label_region = match rename_input { Some(input) => div() diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index 2e06dd60..f51351d3 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -98,7 +98,10 @@ impl TraySnapshot { if parts.is_empty() { "tty7".to_string() } else { - format!("tty7 — {}", parts.join(", ")) + crate::ui::i18n::t_fmt( + L10nKey::TrayTooltipAgents, + &[("parts", &parts.join(t(L10nKey::TrayAgentSep)))], + ) } } } diff --git a/src/ui/windows.rs b/src/ui/windows.rs index 93b3aea3..968878fa 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -305,6 +305,11 @@ pub fn open_from_cli(cx: &mut App, path: Option) { }); } +/// What a launch reopens: the workspace, and how many other open windows the +/// restore left detached. Their panes are still running — the count exists so +/// the launch can say so instead of letting them be forgotten (#597). +pub type RestoreTarget = (WorkspaceId, usize); + /// The workspace a launch reopens, if any. /// /// A launch that carries a directory restores the last layout exactly like a @@ -318,7 +323,7 @@ pub fn open_from_cli(cx: &mut App, path: Option) { /// machine. The requested directory is a path on this computer and a remote /// workspace has no business spawning it, so that case starts a fresh local /// workspace — which is what every path-carrying launch used to do. -pub fn restore_target(cx: &mut App, path: Option<&std::path::Path>) -> Option { +pub fn restore_target(cx: &mut App, path: Option<&std::path::Path>) -> Option { if path.is_some() { let views = WorkspaceStore::all(cx); let candidate = views.workspace_to_restore()?; @@ -329,6 +334,28 @@ pub fn restore_target(cx: &mut App, path: Option<&std::path::Path>) -> Option) { + let Some((workspace, detached)) = restored else { + return; + }; + if detached == 0 { + return; + } + let Some(handle) = WindowRegistry::window_for(cx, workspace) else { + return; + }; + let _ = handle.update(cx, |_, window, cx| { + gpui_component::WindowExt::push_notification( + window, + t_plural(L10nKey::LaunchWorkspacesLeftRunning, detached, &[]), + cx, + ); + }); +} + /// Opens a window after CLI routing reaches a GUI process with no live windows. /// /// Both shapes of request follow the same restoration policy as normal startup; @@ -339,7 +366,8 @@ fn open_missing_cli_window_with( open: impl FnOnce(&mut App, Option, Option), ) { let restore = restore_target(cx, path.as_deref()); - open(cx, restore, path); + open(cx, restore.map(|(id, _)| id), path); + announce_detached_at_launch(cx, restore); } pub fn refresh_menu(cx: &mut App) {