mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix: 19 项低危 UX 问题(#584–#602) (#615)
* fix(scm): say what "discard all" actually discards (#594) The group-level Discard prompt asked to "discard every change in this repository", but discard_all_ops has only ever swept unstaged edits and untracked files — staged changes survive, as the function's own comment notes. Users confirmed under one belief and the code kept another. Narrow the prompt to the operation's real footprint, in all three languages. * fix(scm): keep the amend toggle when its confirmation is cancelled (#595) scm_commit cleared scm.amend when Commit was pressed, before the "rewrite the last commit?" prompt. Answering Cancel returned to a panel whose amend mode had silently been dropped, so the next Commit created a brand-new commit — exactly what the user had just declined to risk. The toggle now clears where scm.committing arms, at dispatch in run_git_op, extending the rule the armed flag already followed: a cancelled confirmation leaves nothing behind. * fix(cli): answer a wait timeout in the success path's JSON shape (#589) The 124 branch returned {pane,status,timed_out} while a finished wait returns {pane,status,matched,stale,activity,message,session_id} — so the one branch a consumer writes error handling for was the one missing its fields. The timeout now carries the full shape plus timed_out, and reference.mdx documents the schema and the flag. * fix(cli): report a failed wait on stderr, even under -q (#590) wait's failures are structured exits (124, or 1 when the pane died first), so they never passed through the anyhow path whose eprintln is the only thing quiet mode cannot silence — contradicting the documented "errors still go to stderr". Both exits now print their headline to stderr, the discipline pane close already established. * docs(cli): describe owner as the workspace that may attach (#591) commands.md still claimed the CLI stamps a literal "tty7-cli" owner on the panes it spawns — the behaviour the orphan-workspaces work removed, because an owner names the workspace allowed to attach and a stranger's stamp got the panes respawned. Every spawn path now writes the workspace id, or nothing while the pane is still unfiled. Bring commands.md in line with reference.mdx, and note the absent case in both. * docs(cli): close five contract drifts between the tables and the code (#592) - The key tables listed pgup/pgdn as aliases but not pgdown, which the parser has always taken; both references name it now. - "Case-insensitive" was flat wrong for Alt: M-x keeps its case because Alt is a prefixed ESC, unlike Ctrl. Both references note the exception. - procs' ports JSON has carried addr since the field exists; both schemas show it. - TTY7_WS is tab ls's default too; both environment tables say so. - split --ratio's clamp to [0.05, 0.95] was discoverable only in code; both split sections document it. * fix(cli): doctor exits 1 when the server is unreachable (#592) 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 table and JSON still go out (the context rows are the other half of what doctor is for), and stderr carries the headline under -q. MockBackend grows an `unreachable` flag so the branch is testable; no Status/Routes round-trips happen once hello has failed. * fix(settings): refuse a Start-in path that names no directory (#601) The custom path was stored unchecked, and the daemon's picker then skipped it — not a directory — so every new pane silently started in the fallback directory and the typo read as a tty7 bug. Settings now marks the field red and refuses to save, the proxy row's pattern (#551), with the red line and the commit gated on one shared predicate so they can never disagree; a hand-edited config.json holding such a path gets a log::warn! naming it at the moment the fallback engages. * fix(terminal): rescan search highlights when the pane's width changes (#586) 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 (Wakeup → refresh), but a quiet local pane has no output coming and the drift outlasted the resize indefinitely. set_grid_size now rescans on a column change with the output path's discipline — selection and scroll untouched — and takes the Context it needs to do so; a rows-only change reflows nothing and stays cheap. * fix(terminal): keep the grid selection when the search bar opens and closes (#584) The selection that seeds the query is the thing being searched for, yet opening the bar ran recompute_matches' unconditional clear — right for its other callers, where the user *changed* the query and the old selection names nothing — and closing cleared it again, so select → Ctrl+F → Esc lost the selection every time. The seeded selection is now restored after the opening scan, and close_search no longer clears; a query the user actually changed still retires the stale selection, the discipline refresh_matches_after_output already stated. * fix(tabs): a zoomed pane stays zoomed across a tab switch (#599) Zoom was a window-level value that activate() cleared unconditionally, so looking at another tab and coming back restored the split layout — while a zoom is a tab's temporary view state, like its focused pane. It now rides with the Tab: activate stashes the outgoing tab's zoom and brings the incoming tab's back. The clears that genuinely reshape the layout (drag, split, close) still stand, and a stashed zoom whose pane exited while the tab was away is validated away rather than restored. * fix(tabs): track an open rename box by tree id, not index (#598) The rename box held only an index, which drifts the moment any other tab closes or the strip reorders — so close_tab_inner and apply_tab_order threw the half-typed name away on any unrelated tab event, and a reorder mid-rename still left a window where the commit landed on whichever tab had taken the index over. The box now names its tab by tree id end to end (start, render match, commit): only closing the renaming tab itself ends the rename, and the name lands on the tab the box was opened on wherever it has since moved. * fix(i18n): move seven hard-coded user-facing strings into the language tables (#602) Seven spots rendered English no matter which UI language was set: the shell-integration notice that explains why a wrapper was blocked or never engaged, the titles a pane wears once its process exits or the server loses it, the loopback forward's failure line, the tray tooltip that lists running agents (whose separator also wanted a CJK enumeration comma), the cursor-shape choices in settings, the command palette's empty-result hint, and the updater's install hint. Each is a L10nKey now with en/zh/ja entries, so the parity guard keeps them translated from here on. The palette's empty state was also wrong in content, not just language: every menu suggested connecting over SSH when nothing matched, including menus that have no hosts in them. The hint now only appears in the quick-connect menu; everywhere else the palette suggests a different search instead. Verified on Linux: the title/palette/tray suites (48 tests) and the i18n parity guard all pass. * fix(terminal): show remote path completion is listing, and say when it fails (#585) Tab-completing a path on a remote workspace had two silences. The whole network round-trip painted nothing, so a slow link read as a broken Tab key; and a listing that failed was unwrapped into an empty candidate list, so "the directory is empty" and "the listing never happened" ended in the same nothing. A pill over the pane's bottom-right corner — the style the integration notice already uses, factored out — now says the listing is running from the moment it starts, and a failed listing sets a notice with its error instead of the empty vector. The failure pill stays until the next keystroke dismisses it, and the trailing notify after an empty listing closes the menu brings the "listing…" pill down with it. Verified on Linux: the new gpui test covers the idle/listing/failed states, and the neighbouring completion tests still pass. * fix(files): quote cd Here / Insert Path for the shell the pane runs (#593) Both file-tree actions wrapped a path with spaces in POSIX single quotes whatever the focused pane's shell was. In cmd.exe a single quote is an ordinary character, so `cd 'C:\Users\me\My Documents'` split at the first space and cmd complained about 'C:\Users\me\My' — while the same action was fine in PowerShell and bash, which is why only cmd users ever saw it. shell_quote_for takes the pane's shell program (the pane already knows it — the settings page lists it) and picks double quotes for cmd.exe, single quotes for everything else; an unknown shell keeps the POSIX form, and a path that needs no quoting stays bare either way. Windows paths cannot contain a double quote, so the cmd form has nothing to escape. * fix(cli): pane close fails for a pane the registry does not hold (#588) `tty7 pane close %99` printed {"closed":[99]} and exited 0 for a pane that never existed. The workspace path cannot drift this way — PaneClose answers — but an orphan has no workspace to route through, so close hangs it up directly, and that kill is fire-and-forget: the daemon never says whether it knew the pane, so Ok(()) only ever meant the bytes reached the socket. A reaper script chasing the orphans `pane ls --all` points at would read the ghost success as cleanup done. The direct path now reads the running-pane registry once per batch and refuses ids it does not hold: the miss lands in `failed` with exit 1, next to the failures kill itself can report. A pane that exits between the listing and the kill is gone either way, which is what closing it wanted, so that race still reports closed. * fix(session): a launch that leaves workspaces running says so (#597) Quitting with several windows open and starting again restored only the most recent one; every other open window was marked detached — panes alive, nothing on screen, the only trace a "left N detached" log line. The workspaces were reachable from the sidebar, but nothing said they existed, so they were easy to forget entirely. restore_one now returns how many windows it detached, and both launch paths (normal startup and the CLI-driven open) push an in-app notification into the restored window naming the count and where to reopen them. The count rides the return value rather than firing the notification inside the store, because the store has no window to notify in — and a launch that detaches nothing, like the reattach-the-last- closed case, stays silent. * fix(switcher): list the local machine's orphan panes, with a way to close them (#596) A pane whose workspace went away — an interrupted `tty7 run`, a forgotten workspace that kept its shells — was invisible everywhere in the GUI: not in the sidebar, not in the switcher, not in the tray. It kept its process and its memory, and the only way to even learn it existed was the CLI's `tty7 pane ls --all`, which a GUI-only user never runs. The switcher's local machine group now carries a "Background panes" block under its workspace rows: one line per live pane the daemon's registry holds and no workspace does — id, owner, cwd — each with a Close button. The listing is the same PaneClient::list the CLI's reaper reads, fetched off the UI thread when the panel opens; closing kills and then re-lists, so a pane that survived simply stays on the list instead of pretending to be gone. The block steps out of the way while the search field holds a query, which narrows the panel to workspaces. Local on purpose: a remote machine's orphans belong to its own daemon, and routing a listing per host is what the CLI reaper is already for. The block joins no keyboard navigation — the panes are not workspaces and the arrows have no business landing on them. * fix(updater): keep Inno's progress window on screen during the install (#600) The Windows installer ran /VERYSILENT, so from the app quitting for the update to the watcher bringing the new build up — tens of seconds, longer under an antivirus scan — the screen held nothing at all: no window, no progress, no tray note. "Clicked update, the app vanished" reads as a crash, and double-clicking the icon does nothing while the files are being replaced. The installer now runs /SILENT instead. Nothing about the flow becomes interactive — /SP-, /SUPPRESSMSGBOXES, /NORESTART and /CLOSEAPPLICATIONS are untouched — but Inno's own progress window stays on screen for the gap, which is exactly the span the user had no word about. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
+114
@@ -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
|
||||
|
||||
@@ -101,6 +101,8 @@ pub mod mock {
|
||||
pub runs: Vec<RunSpec>,
|
||||
pub run_exit: Option<i32>,
|
||||
pub events: Vec<ControlEvent>,
|
||||
/// 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<ControlHelloOk> {
|
||||
if self.unreachable {
|
||||
anyhow::bail!("connect: no server is listening");
|
||||
}
|
||||
Ok(ControlHelloOk {
|
||||
control_version: CONTROL_VERSION,
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
|
||||
@@ -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<Vec<u64>> = 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<Outc
|
||||
// Structured even here: a script has to tell "my peer died"
|
||||
// apart from "the daemon is unreachable", and an anyhow error
|
||||
// would leave --json with nothing to read.
|
||||
//
|
||||
// The headline goes to stderr all the same, so `-q` still
|
||||
// reports it — the discipline `pane close` set: a failure is
|
||||
// not "output on success", and an exit code alone says which
|
||||
// wait died nowhere (#590).
|
||||
eprintln!("tty7: pane %{pane} exited before reaching the awaited state");
|
||||
return Ok(Outcome::Exit(
|
||||
1,
|
||||
Report {
|
||||
@@ -1127,11 +1155,30 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
|
||||
--changed",
|
||||
);
|
||||
}
|
||||
// The headline goes to stderr all the same, so `-q` still
|
||||
// reports it — see the sibling exit above for why (#590).
|
||||
eprintln!("tty7: pane %{pane}: still {} — timed out", current.name());
|
||||
return Ok(Outcome::Exit(
|
||||
124,
|
||||
Report {
|
||||
human,
|
||||
json: json!({ "pane": pane, "status": current.name(), "timed_out": true }),
|
||||
// The same shape as a finished wait, plus the flag that
|
||||
// says the deadline ended it: a consumer written against
|
||||
// the success path must not find its fields missing on
|
||||
// exactly the branch it wrote error handling for (#589).
|
||||
json: {
|
||||
let session = entry.as_ref().map(|e| &e.state);
|
||||
json!({
|
||||
"pane": pane,
|
||||
"status": current.name(),
|
||||
"matched": false,
|
||||
"stale": !changed,
|
||||
"timed_out": true,
|
||||
"activity": session.map(|s| s.activity),
|
||||
"message": session.and_then(|s| s.message.clone()),
|
||||
"session_id": session.and_then(|s| s.session_id.clone()),
|
||||
})
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -1394,9 +1441,9 @@ fn doctor(ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
|
||||
"\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<Outcome> {
|
||||
"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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,20 @@ fn initial_working_directory(cwd: Option<PathBuf>) -> Option<PathBuf> {
|
||||
.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()
|
||||
|
||||
+19
-8
@@ -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-<char>` (Ctrl, e.g. `C-c`), `M-<char>` (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 —
|
||||
|
||||
@@ -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-<char>` (Ctrl: `C-c`, `C-d`, `C-z`, also `C-@ C-[ C-\ C-] C-^ C-_ C-?`), `M-<char>` (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
|
||||
|
||||
+17
-2
@@ -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()
|
||||
|
||||
+38
-2
@@ -102,7 +102,11 @@ impl WorkspaceStore {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn restore_one(cx: &mut gpui::App) -> Option<WorkspaceId> {
|
||||
/// 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,
|
||||
|
||||
+7
-5
@@ -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")?;
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+18
-4
@@ -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<Self>) {
|
||||
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();
|
||||
}
|
||||
|
||||
+277
-39
@@ -255,6 +255,11 @@ pub struct TerminalView {
|
||||
pending_history: Option<PendingHistory>,
|
||||
completion: Option<CompletionSession>,
|
||||
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<String>,
|
||||
completion_generation: u64,
|
||||
editor_handoff: Option<u64>,
|
||||
editor_handoff_interrupt_seq: Option<u64>,
|
||||
@@ -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<Self>,
|
||||
) {
|
||||
// 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<std::path::PathBuf> {
|
||||
@@ -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<Self>,
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
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<String> {
|
||||
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<Self>,
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
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);
|
||||
|
||||
+181
-7
@@ -270,6 +270,11 @@ pub struct Tab {
|
||||
pub pane: Pane,
|
||||
pub name: Option<String>,
|
||||
last_focused: Option<gpui::EntityId>,
|
||||
/// 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<Entity<TerminalView>>,
|
||||
pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>,
|
||||
pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>,
|
||||
pub(crate) sidebar_group: std::cell::RefCell<Option<std::path::PathBuf>>,
|
||||
@@ -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<InputState>,
|
||||
_subs: Vec<Subscription>,
|
||||
}
|
||||
@@ -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<Self>) {
|
||||
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<Option<Tab>> = 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::<Config>();
|
||||
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<Vec<String>, ()> {
|
||||
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<usize> = (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
|
||||
|
||||
+56
-7
@@ -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]
|
||||
|
||||
+38
-1
@@ -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",
|
||||
|
||||
+36
-1
@@ -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} 個のファイルが変更されました",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+33
-1
@@ -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") => "没有文件改动",
|
||||
|
||||
+9
-5
@@ -941,6 +941,14 @@ impl ListDelegate for PaletteDelegate {
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<ListState<Self>>,
|
||||
) -> 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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+17
-2
@@ -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()
|
||||
|
||||
@@ -190,6 +190,51 @@ pub(crate) struct HostSnapshot {
|
||||
pub rows: Vec<RemoteWorkspaceRow>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub owner: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<u64> {
|
||||
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<tty7_core::daemon::protocol::PaneInfo>,
|
||||
held: &HashSet<u64>,
|
||||
) -> Vec<OrphanPane> {
|
||||
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<InputState>,
|
||||
page: Page,
|
||||
renaming: Option<(WorkspaceId, Entity<InputState>)>,
|
||||
/// 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<OrphanPane>,
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) -> 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<u64> = [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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+4
-1
@@ -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)))],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
-2
@@ -305,6 +305,11 @@ pub fn open_from_cli(cx: &mut App, path: Option<std::path::PathBuf>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<std::path::PathBuf>) {
|
||||
/// 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<WorkspaceId> {
|
||||
pub fn restore_target(cx: &mut App, path: Option<&std::path::Path>) -> Option<RestoreTarget> {
|
||||
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<Wo
|
||||
WorkspaceStore::restore_one(cx)
|
||||
}
|
||||
|
||||
/// Tell the user about the workspaces a launch restored away. A desktop toast
|
||||
/// would work, but the window is right there — and the notification names the
|
||||
/// place the workspaces can be got back from.
|
||||
pub fn announce_detached_at_launch(cx: &mut App, restored: Option<RestoreTarget>) {
|
||||
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<WorkspaceId>, Option<std::path::PathBuf>),
|
||||
) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user