From f852629ba109985f61f3e301f0e33e18e4987a71 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 13:49:28 +0800 Subject: [PATCH 01/20] fix(procinfo): read proc_listallpids' answer as a pid count, not bytes (#947) libproc's proc_listallpids takes its buffer size in bytes but answers with the number of pids, and the macOS process table read that number as bytes twice: once sizing the buffer (count / 4 + 64 slots) and once reading back how much was filled (count / 4 again). On a Mac with ~700 processes only the first ~60 pids reached the table. The kernel lists its newest processes first, so what survived was whatever started most recently. A `go run` server launched a moment ago was in; the pane's shell, started long before, usually was not, and a walk from a root missing from the table returns nothing: no processes, no ports, and a probe state of Ok. A freshly built parent/child chain, which is what a quick check of the probe builds, is exactly the case that happened to work. The listing now goes through list_all_pids, which takes the call as a closure so the size arithmetic is tested off a Mac against an emulation of libproc's convention. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- crates/tty7-core/src/daemon/procinfo.rs | 91 ++++++++++++++++++++----- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs index 217a530b..e55581fb 100644 --- a/crates/tty7-core/src/daemon/procinfo.rs +++ b/crates/tty7-core/src/daemon/procinfo.rs @@ -225,23 +225,15 @@ fn walk(table: &HashMap, shell_pid: u32, fg_pgid: Option) -> Vec< #[cfg(target_os = "macos")] fn process_table() -> HashMap { let mut table = HashMap::new(); - let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; - if bytes <= 0 { - return table; - } - let cap = (bytes as usize / std::mem::size_of::()) + 64; - let mut pids = vec![0 as libc::c_int; cap]; - let written = unsafe { - libc::proc_listallpids( - pids.as_mut_ptr() as *mut libc::c_void, - (cap * std::mem::size_of::()) as libc::c_int, - ) - }; - if written <= 0 { - return table; - } - let n = written as usize / std::mem::size_of::(); - for &pid in pids.iter().take(n.min(cap)) { + let pids = list_all_pids(|buf| { + let ptr = match buf.is_empty() { + true => std::ptr::null_mut(), + false => buf.as_mut_ptr() as *mut libc::c_void, + }; + let bytes = std::mem::size_of_val(buf) as libc::c_int; + unsafe { libc::proc_listallpids(ptr, bytes) } + }); + for &pid in &pids { if pid <= 0 { continue; } @@ -276,6 +268,37 @@ fn process_table() -> HashMap { table } +/// Every pid on the machine, by way of `list`, which has the calling +/// convention of libproc's `proc_listallpids`: handed a buffer, it fills as +/// much of it as it can, and it answers with a *count of pids* — not a byte +/// count, although the size it is given is in bytes. An empty buffer asks +/// only for the count, padded by the kernel for processes born in between. +/// +/// Reading that count as bytes was #731. It divided the answer by four twice +/// over, once sizing the buffer and once reading back how much was filled, so +/// only about one pid in sixteen made it into the table — and since the kernel +/// lists its newest processes first, the ones kept were whatever had started +/// most recently. A `go run` server just launched was in; the pane's shell, +/// started long before, was not, and a walk from a root the table has never +/// heard of comes back empty: no processes, no ports, and nothing to say why. +/// A process tree built moments earlier, which is what a quick check of the +/// probe builds, is exactly the case that happened to work. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn list_all_pids(mut list: impl FnMut(&mut [i32]) -> i32) -> Vec { + let count = list(&mut []); + if count <= 0 { + return Vec::new(); + } + let mut pids = vec![0; count as usize + 64]; + let written = list(&mut pids); + if written <= 0 { + return Vec::new(); + } + pids.truncate(written as usize); + pids.retain(|&pid| pid > 0); + pids +} + #[cfg(target_os = "macos")] fn cstr_field(buf: &[libc::c_char]) -> String { let bytes: Vec = buf @@ -968,6 +991,40 @@ mod tests { ); } + /// `proc_listallpids` as libproc implements it: the kernel's newest + /// processes first, a padded estimate when asked with no buffer, and + /// always a count of pids back, never bytes. + fn fake_listallpids(live: &[i32]) -> impl FnMut(&mut [i32]) -> i32 + '_ { + move |buf: &mut [i32]| { + if buf.is_empty() { + return live.len() as i32 + 20; + } + let n = live.len().min(buf.len()); + buf[..n].copy_from_slice(&live[..n]); + n as i32 + } + } + + /// #731 on macOS: a busy Mac has hundreds of processes, and the pane's + /// shell is an old one. Every pid has to reach the table, or the walk + /// starts from a root it cannot find and the pane shows no processes and + /// no ports at all. + #[test] + fn every_pid_is_listed_not_the_newest_sixteenth() { + // Newest first, the way the kernel lists them: the `go run` server + // just started, and the shell it runs under from an hour ago. + let live: Vec = (1..=700).rev().collect(); + let got = list_all_pids(fake_listallpids(&live)); + assert_eq!(got.len(), 700, "the whole machine, not the newest few"); + assert!(got.contains(&1), "the oldest process is still there"); + } + + #[test] + fn a_pid_list_that_cannot_be_read_is_empty() { + assert!(list_all_pids(|_| -1).is_empty()); + assert!(list_all_pids(|_| 0).is_empty()); + } + #[test] fn a_pathological_tree_still_stops() { let mut table: HashMap = [(100, row(1, "zsh"))].into_iter().collect(); From 3a52e34b02acf5b51bd2daa8e0cec23e67985b00 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 13:50:39 +0800 Subject: [PATCH 02/20] fix(linux): wrap the confirmation dialog's text instead of clipping it (#948) Linux has no native prompt, so every window.prompt fell through to gpui's fallback renderer, which sets the message and the detail as unbreakable single lines in a fixed-width box with overflow hidden. The quit-and-stop warning was cut off mid-sentence. Install a prompt builder on platforms without a native dialog that lays the same prompt out as a card whose text wraps, in the app's own surfaces, with Return taking answer 0 and Escape the cancel answer. Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM --- src/main.rs | 1 + src/ui/mod.rs | 1 + src/ui/prompt.rs | 180 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 src/ui/prompt.rs diff --git a/src/main.rs b/src/main.rs index 8a9500df..76c9559d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -705,6 +705,7 @@ fn main() { .detach(); gpui_component::init(cx); register_bundled_fonts(cx); + crate::ui::prompt::install(cx); cx.activate(true); #[cfg(target_os = "macos")] set_dock_icon_for_bare_binary(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4ec0d0af..df167e08 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -27,6 +27,7 @@ pub mod pending_pane; pub mod perf; pub mod prefill; pub mod presets; +pub mod prompt; pub mod remote_connect; pub mod remote_workspace; pub mod reorder; diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs new file mode 100644 index 00000000..80c6459e --- /dev/null +++ b/src/ui/prompt.rs @@ -0,0 +1,180 @@ +//! The confirmation dialog on platforms with no native one. +//! +//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux +//! has neither, so gpui falls back to a renderer of its own — and that one sets +//! the message and the detail as single unbreakable lines inside a fixed-width +//! box that clips its overflow. Any sentence longer than the box is cut off +//! mid-word, which for the quit-and-stop warning is exactly the part that says +//! what will be lost (#920). This is the same dialog laid out as a card that +//! wraps its text, in the app's own surfaces, with Return and Escape answering +//! the way they do on the native dialogs. + +use gpui::{ + App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel, + PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; + +/// Route every `window.prompt` through [`TextPrompt`] where the platform has +/// no dialog of its own to show. +pub(crate) fn install(cx: &mut App) { + if cfg!(any(target_os = "macos", target_os = "windows")) { + return; + } + cx.set_prompt_builder(build); +} + +fn build( + _level: PromptLevel, + message: &str, + detail: Option<&str>, + answers: &[PromptButton], + handle: PromptHandle, + window: &mut Window, + cx: &mut App, +) -> RenderablePromptHandle { + let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx)); + handle.with_view(prompt, window, cx) +} + +pub(crate) struct TextPrompt { + message: String, + detail: Option, + answers: Vec, + focus: FocusHandle, +} + +impl TextPrompt { + fn new( + message: &str, + detail: Option<&str>, + answers: &[PromptButton], + cx: &mut Context, + ) -> Self { + Self { + message: message.to_string(), + detail: detail.map(str::to_string), + answers: answers.to_vec(), + focus: cx.focus_handle(), + } + } + + /// The answer a key picks. Return is answer 0 — `confirm_answers` puts the + /// action there for exactly this — and Escape is the one marked cancel; + /// a prompt with no cancel answer has nothing Escape can safely mean. + fn answer_for_key(&self, key: &str) -> Option { + match key { + "enter" if !self.answers.is_empty() => Some(0), + "escape" => self.answers.iter().position(PromptButton::is_cancel), + _ => None, + } + } +} + +impl EventEmitter for TextPrompt {} + +impl Focusable for TextPrompt { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl Render for TextPrompt { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + // Answer 0 goes rightmost, where the native dialogs put it and where + // `confirm_answers` expects it to land. + let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| { + Button::new(("prompt-answer", ix)) + .label(answer.label().clone()) + .small() + .when(ix == 0 && !answer.is_cancel(), |b| b.primary()) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(PromptResponse(ix)); + cx.stop_propagation(); + })) + }); + + let card = v_flex() + .occlude() + .w(px(420.)) + .max_w(relative(0.9)) + .gap_3() + .p_5() + .map(|card| crate::ui::theme::floating_surface(card, cx)) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(self.message.clone()), + ) + .children( + self.detail + .clone() + .map(|detail| div().text_sm().text_color(muted).child(detail)), + ) + .child( + h_flex() + .pt_2() + .justify_end() + .flex_wrap() + .gap_2() + .children(buttons), + ); + + div() + .id("text-prompt") + .track_focus(&self.focus) + .size_full() + .cursor_default() + .bg(crate::ui::presets::scrim_fill(cx)) + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| { + if let Some(ix) = this.answer_for_key(&ev.keystroke.key) { + cx.emit(PromptResponse(ix)); + cx.stop_propagation(); + } + })) + .flex() + .items_center() + .justify_center() + .child(card) + } +} + +#[cfg(test)] +mod tests { + use super::TextPrompt; + use gpui::{AppContext as _, PromptButton, TestAppContext}; + + fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity { + cx.update(|cx| { + cx.new(|cx| { + TextPrompt::new( + "Quit and stop tty7 server?", + Some("a detail long enough to need more than one line"), + answers, + cx, + ) + }) + }) + } + + #[gpui::test] + fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) { + let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel")); + p.read_with(cx, |p, _| { + assert_eq!(p.answer_for_key("enter"), Some(0)); + assert_eq!(p.answer_for_key("escape"), Some(1)); + assert_eq!(p.answer_for_key("a"), None); + }); + } + + #[gpui::test] + fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) { + let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]); + p.read_with(cx, |p, _| { + assert_eq!(p.answer_for_key("escape"), None); + }); + } +} From 575d2cd68e3adaabe5bb89e73edfb2b3aaff3e6f Mon Sep 17 00:00:00 2001 From: LiuSir <32128085+CodeHourra@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:52:36 +0800 Subject: [PATCH 03/20] fix(agents): drop the previous session id when the foreground agent changes (#957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一 pane 里前台从 Claude 换成 omp,或别的 agent 的 hook 写进这个 TTY 时,旧的 session id 会留在新 agent 上,Fork 就会拿错 id。 Co-authored-by: stevelliu Co-authored-by: Cursor --- crates/tty7-core/src/daemon/pane.rs | 45 ++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 8c9650a8..2a9281f3 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -3057,6 +3057,14 @@ fn apply_agent_signals( let before = st.agent_session.clone(); for event in &events { + // A hook names the agent that emitted it. Once this pane has a + // foreground agent, a different one's session id must not land here: + // omp forking with a Claude UUID is "session not found". + if let (Some(current), Some(from)) = (st.agent, event.agent) + && current != from + { + continue; + } if st.agent.is_none() && event.agent.is_some() { st.agent = event.agent; notify(st, DaemonMsg::Agent(st.agent)); @@ -3183,7 +3191,9 @@ fn apply_agent( stamp_launch_argv(st, argv); return; } - if agent.is_none() && st.agent_session.is_some() { + // The session belongs to whoever was in the foreground. Switching from + // Claude to omp (or back to the shell) must not keep the previous id. + if st.agent_session.is_some() { st.agent_session = None; notify(st, DaemonMsg::AgentStatus(None)); } @@ -5378,6 +5388,39 @@ mod tests { assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None)))); } + #[test] + fn a_foreign_agent_hook_does_not_replace_the_foreground_session() { + use crate::core::cli_agent::CLIAgent; + + let mut st = test_state(true); + let mut sniffer = OscSniffer::new(); + let omp = concat!( + "\x1b]777;notify;tty7://cli-agent;", + r#"{"v":1,"agent":"omp","event":"session-start","session_id":"01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"}"#, + "\x07", + ); + apply_signals(&mut st, sniffer.feed(omp.as_bytes())); + assert_eq!(st.agent, Some(CLIAgent::OhMyPi)); + + let claude = concat!( + "\x1b]777;notify;tty7://cli-agent;", + r#"{"v":1,"agent":"claude","event":"session-start","session_id":"e18a20ff-4c8c-4b94-867b-dd4b79032a6c"}"#, + "\x07", + ); + apply_signals(&mut st, sniffer.feed(claude.as_bytes())); + assert_eq!( + st.agent_session.as_ref().unwrap().session_id.as_deref(), + Some("01a0d21f-2f79-72e0-bb16-d6d908aaa6e0"), + "a Claude hook on an omp pane must not steal the session id" + ); + + apply_agent(&mut st, Some((CLIAgent::Claude, vec!["claude".into()]))); + assert!( + st.agent_session.is_none(), + "switching the foreground agent drops the previous session" + ); + } + #[test] fn opaque_notifications_only_fall_back_when_no_rich_state() { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; From 455e74dc2ca5be078df52a6f3555bfbe20caee19 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 13:53:44 +0800 Subject: [PATCH 04/20] feat(scm): filter changed files and show them as a tree (#950) * feat(scm): filter changed files and show them as a tree * test(scm): key the tree test's settle on the panel's own root --- CHANGELOG.md | 17 ++ assets/icons/list-flat.svg | 1 + assets/icons/list-tree.svg | 1 + crates/tty7-core/src/core/config.rs | 7 + src/ui/assets.rs | 4 + src/ui/i18n/en.rs | 4 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 4 + src/ui/i18n/zh.rs | 4 + src/ui/right_panel.rs | 14 ++ src/ui/scm/actions.rs | 10 + src/ui/scm/mod.rs | 1 + src/ui/scm/panel.rs | 279 +++++++++++++++++++++++++- src/ui/scm/state.rs | 16 ++ src/ui/scm/tree.rs | 298 ++++++++++++++++++++++++++++ 15 files changed, 660 insertions(+), 4 deletions(-) create mode 100644 assets/icons/list-flat.svg create mode 100644 assets/icons/list-tree.svg create mode 100644 src/ui/scm/tree.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f77640ff..09ae4e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to tty7 are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **The source control panel's changed files can be filtered and shown as a + tree** (#473). A filter field sits above the list: every word typed has to + appear somewhere in a file's path, in any order and any case, and the group + headers' counts and their stage/unstage/discard-all buttons follow what the + filter leaves on screen. The tile at its end switches the list to a + directory tree — directories before files, a chain of directories that hold + nothing but the next one compacted into a single row (`crates/core/src`), + each directory row foldable and counting the files beneath it. A filter + opens every folded directory while it is set. The choice between list and + tree is remembered in the config. Both are built from the status the host + already sends, so a remote repository gets them unchanged. Searching the + diff text itself is not part of this. + ## [26.9.3] - 2026-09-23 ### Added diff --git a/assets/icons/list-flat.svg b/assets/icons/list-flat.svg new file mode 100644 index 00000000..b6249b13 --- /dev/null +++ b/assets/icons/list-flat.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/list-tree.svg b/assets/icons/list-tree.svg new file mode 100644 index 00000000..04086a1a --- /dev/null +++ b/assets/icons/list-tree.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 01fb7bfc..07bc28b0 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -246,6 +246,12 @@ pub struct Config { /// impression than one they asked for. #[serde(default)] pub scm_graph_expanded: bool, + /// Whether the source control panel lists changed files as a directory + /// tree rather than one flat run per group. Off by default: the flat list + /// is what a handful of changes reads best as, and the tree is for the + /// agent run that touched sixty files across a dozen modules. + #[serde(default)] + pub scm_changes_tree: bool, /// Whether a file opens in the code panel with soft wrap on. Not a /// setting anyone picks up front: it is whatever the status bar's Wrap /// toggle (or `ToggleDocumentWrap`) was last left at, so the next file @@ -676,6 +682,7 @@ impl Default for Config { document_layout: DocumentLayout::default(), document_ratio: default_document_ratio(), scm_graph_expanded: false, + scm_changes_tree: false, editor_soft_wrap: false, editor_markdown_preview: false, sidebar_grouping: SidebarGrouping::Repo, diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 2bf9e9ab..a35f0329 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -47,6 +47,8 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"), "icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"), "icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"), + "icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"), + "icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"), "icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"), "icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"), "icons/agents/traecli.svg" => include_bytes!("../../assets/icons/agents/traecli.svg"), @@ -114,6 +116,8 @@ mod tests { "icons/git-branch.svg", "icons/git-sync.svg", "icons/git-commit.svg", + "icons/list-tree.svg", + "icons/list-flat.svg", ] { assert!( Assets.load(path).unwrap().is_some(), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index d0e8621a..6013f4e6 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1216,6 +1216,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ScmShowMore => "Show more", L10nKey::ScmShowLess => "Show less", L10nKey::ScmCommitNotFound => "This commit is not in this repository.", + L10nKey::ScmFilterChanges => "Filter changed files…", + L10nKey::ScmViewAsTree => "View as Tree", + L10nKey::ScmViewAsList => "View as List", + L10nKey::ScmNoMatchingChanges => "No changed files match the filter.", L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.", L10nKey::ScmOpenChanges => "Open Changes", L10nKey::ScmDiscardAllConfirm => { diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 56470abf..028e45a5 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1290,6 +1290,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ScmShowMore => "続きを表示", L10nKey::ScmShowLess => "折りたたむ", L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。", + L10nKey::ScmFilterChanges => "変更ファイルを絞り込む…", + L10nKey::ScmViewAsTree => "ツリーで表示", + L10nKey::ScmViewAsList => "リストで表示", + L10nKey::ScmNoMatchingChanges => "条件に一致する変更ファイルはありません。", L10nKey::ScmTooManyChanges => { "変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index c3b97bb1..b8b60db2 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -913,6 +913,10 @@ l10n_keys! { ScmShowLess, ScmCommitNotFound, ScmTooManyChanges, + ScmFilterChanges, + ScmViewAsTree, + ScmViewAsList, + ScmNoMatchingChanges, ScmOpenChanges, ScmDiscardAllConfirm, ScmAmendConfirm, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 82d96fb2..f9c59eb0 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1156,6 +1156,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ScmShowMore => "展开", L10nKey::ScmShowLess => "收起", L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。", + L10nKey::ScmFilterChanges => "筛选变更文件…", + L10nKey::ScmViewAsTree => "以树形显示", + L10nKey::ScmViewAsList => "以列表显示", + L10nKey::ScmNoMatchingChanges => "没有匹配筛选的变更文件。", L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。", L10nKey::ScmOpenChanges => "查看改动", L10nKey::ScmDiscardAllConfirm => { diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 994f83c0..d96dbb32 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -729,6 +729,18 @@ impl Tty7App { input: &gpui::Entity, cx: &mut Context, ) -> AnyElement { + self.panel_search_with(input, None, cx) + } + + /// [`Self::panel_search`] with a tile at its far end, inset the way the + /// branch row's tile is so the two stack in one column. + pub(crate) fn panel_search_with( + &self, + input: &gpui::Entity, + trailing: Option, + cx: &mut Context, + ) -> AnyElement { + let has_trailing = trailing.is_some(); h_flex() .flex_none() .items_center() @@ -753,6 +765,8 @@ impl Tty7App { // still reads as one line of chrome. .child(Input::new(input).appearance(false).xsmall().cleanable(true)), ) + .when(has_trailing, |row| row.pr(px(tile_trailing_inset_sm()))) + .children(trailing) .into_any_element() } diff --git a/src/ui/scm/actions.rs b/src/ui/scm/actions.rs index 3d5ea2e1..bdede8b6 100644 --- a/src/ui/scm/actions.rs +++ b/src/ui/scm/actions.rs @@ -71,6 +71,16 @@ impl Tty7App { cx.notify(); } + /// Switch the changed-file list between flat and tree, and remember it. + /// + /// Global like `diff_view`: someone who reads changes by directory reads + /// every repository's that way. + pub(crate) fn scm_toggle_changes_tree(&mut self, cx: &mut Context) { + let next = !cx.global::().scm_changes_tree; + self.update_config(cx, |cfg| cfg.scm_changes_tree = next); + cx.notify(); + } + /// Flip the diff overlay between side-by-side and unified. /// /// Global rather than per-overlay, matching `diffEditor.renderSideBySide`: diff --git a/src/ui/scm/mod.rs b/src/ui/scm/mod.rs index 50f4f4e9..39529ce4 100644 --- a/src/ui/scm/mod.rs +++ b/src/ui/scm/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod path; pub(crate) mod state; #[allow(dead_code)] pub(crate) mod status; +pub(crate) mod tree; pub(crate) use actions::ScmIntent; pub(crate) use state::{GraphState, ScmPanelState}; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 63013637..1bd1c430 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -39,6 +39,7 @@ use crate::ui::scm::ScmIntent; use crate::ui::scm::path::split_display_path; use crate::ui::scm::state::{RepoKey, ScmGroup}; use crate::ui::scm::status::{status_color, status_glyph}; +use crate::ui::scm::tree::{TreeRow, normalize_query, path_matches, tree_rows}; /// Shared compact pitch for working-tree and commit-detail file rows. pub(super) const ROW_H: f32 = 26.; @@ -52,6 +53,10 @@ pub(super) const ROW_H: f32 = 26.; /// different numbers. const BADGE_W: f32 = 14.; +/// One level of the tree view, the file tree's step: the Files panel and this +/// one draw the same directories, and the same depth should read the same. +const TREE_INDENT: f32 = 14.; + /// The key context the message box installs, and the one `ScmCommit` is /// bound inside. The two are the same string on purpose: a binding whose /// context nothing attaches is a binding that never fires. @@ -236,7 +241,12 @@ impl Tty7App { Some(t(L10nKey::PanelNoChangesHint)), cx, ), - None => self.scm_groups(&repo, &status, cx), + None => { + // Pinned with the rest, so the filter stays in reach however + // far down the list has been scrolled. + pinned.push(self.scm_filter_row(window, cx)); + self.scm_groups(&repo, &status, cx) + } }; let history = self.render_graph_section(&repo, window, cx); self.scm_shell_full(title, pinned, body, history) @@ -1291,24 +1301,63 @@ impl Tty7App { status: &Arc, cx: &mut Context, ) -> AnyElement { + let query = self.scm_filter_query(cx); + let tree = cx.global::().scm_changes_tree; + let mut matched = 0; let mut list = v_flex().px(px(CONTENT_INSET - ROW_INSET)).py(px(2.)); for group in ScmGroup::ORDER { + // Filtered before anything else reads it, so the header's count + // and its stage/discard-all act on what is on screen — a + // "discard all" that also reached the files the filter hid would + // be the one surprise this panel cannot take back. let entries: Vec<&StatusEntry> = status .entries .iter() .filter(|e| in_group(e, group)) + .filter(|e| { + query + .as_deref() + .is_none_or(|q| path_matches(e.path.as_str(), q)) + }) .collect(); if entries.is_empty() { continue; } + matched += entries.len(); let collapsed = self.scm.group_collapsed(group, entries.len()); list = list.child(self.scm_group_header(repo, group, &entries, collapsed, cx)); if collapsed { continue; } let shown = entries.len().min(MAX_RENDERED_FILES); - for entry in entries.iter().take(shown) { - list = list.child(self.scm_file_row(repo, group, entry, cx)); + if tree { + // A filter opens every directory: a match folded away under + // a closed row is a match the filter failed to show. + let filtering = query.is_some(); + let folded = &self.scm.folded_dirs; + let rows = tree_rows(entries[..shown].iter().map(|e| e.path.as_str()), |key| { + !filtering && folded.contains(&(group, key.to_string())) + }); + for row in rows { + list = list.child(match row { + TreeRow::Dir { + key, + label, + depth, + files, + collapsed, + } => self.scm_dir_row( + group, key, label, depth, files, collapsed, !filtering, cx, + ), + TreeRow::File { index, depth } => { + self.scm_file_row(repo, group, entries[index], Some(depth), cx) + } + }); + } + } else { + for entry in entries.iter().take(shown) { + list = list.child(self.scm_file_row(repo, group, entry, None, cx)); + } } if entries.len() > shown { list = list.child(self.scm_note( @@ -1317,6 +1366,9 @@ impl Tty7App { )); } } + if query.is_some() && matched == 0 { + list = list.child(self.scm_note(t(L10nKey::ScmNoMatchingChanges).to_string(), cx)); + } if status.truncated { list = list.child(self.scm_note( t_fmt( @@ -1332,6 +1384,142 @@ impl Tty7App { list.into_any_element() } + /// The filter over the changed files, with the list/tree switch at its + /// end. + /// + /// A standing field, like the Files panel's, rather than one behind a + /// tile like the history's: the list is what this panel is for, and a + /// run that touched sixty files is exactly when nobody wants to go + /// looking for how to narrow it. + fn scm_filter_row(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let input = match self.scm.filter.clone() { + Some(input) => input, + None => { + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder(t(L10nKey::ScmFilterChanges)) + }); + // An `InputState` is its own entity; without this the list + // would never hear about the text typed into it. + self.scm.filter_sub = + Some( + cx.subscribe_in(&input, window, |_this, _input, ev, _window, cx| { + if matches!(ev, gpui_component::input::InputEvent::Change) { + cx.notify(); + } + }), + ); + self.scm.filter = Some(input.clone()); + input + } + }; + let tree = cx.global::().scm_changes_tree; + // The tile shows where it goes, not where you are — the rows under it + // already say which view this is. + let (glyph, tip) = match tree { + true => ("icons/list-flat.svg", t(L10nKey::ScmViewAsList)), + false => ("icons/list-tree.svg", t(L10nKey::ScmViewAsTree)), + }; + let toggle = crate::ui::tab_strip::chrome_tile_sized( + Button::new("scm-changes-view").icon(Icon::empty().path(glyph)), + crate::ui::app::TILE_SIZE_SM, + crate::ui::app::TILE_GLYPH_SM, + false, + cx, + ) + .rounded_md() + .tooltip(tip) + .on_click(cx.listener(|this, _, _window, cx| this.scm_toggle_changes_tree(cx))) + .into_any_element(); + self.panel_search_with(&input, Some(toggle), cx) + } + + /// The filter box's text, if it has any. + fn scm_filter_query(&self, cx: &gpui::App) -> Option { + normalize_query(&self.scm.filter.as_ref()?.read(cx).value()) + } + + /// A directory in the tree view: a chevron in the status letters' + /// column, the (possibly compacted) name, and how many changed files + /// are under it. + #[allow(clippy::too_many_arguments)] + fn scm_dir_row( + &self, + group: ScmGroup, + key: String, + label: String, + depth: usize, + files: usize, + collapsed: bool, + foldable: bool, + cx: &mut Context, + ) -> AnyElement { + let sf = cx.global::().sidebar; + let mono = cx.theme().mono_font_family.clone(); + let muted = cx.theme().muted_foreground; + let id = SharedString::from(format!("scm-dir-{group:?}-{key}")); + let selector = id.clone(); + let toggle = cx.listener({ + let key = key.clone(); + move |this, _, _window, cx| { + this.scm.toggle_dir(group, key.clone()); + cx.notify(); + } + }); + h_flex() + .id(id) + .debug_selector(move || selector.to_string()) + .items_center() + .gap(px(8.)) + .min_h(rems(ROW_H / 16.)) + .w_full() + .min_w_0() + .px(px(ROW_INSET)) + .pl(px(ROW_INSET + depth as f32 * TREE_INDENT)) + .rounded(px(5.)) + .when(foldable, |row| { + row.cursor_pointer() + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .on_click(toggle) + }) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(key.clone()).build(window, cx) + }) + .child( + div() + .flex_none() + .w(px(BADGE_W)) + .flex() + .justify_center() + .text_color(muted) + .child( + Icon::new(if collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .xsmall(), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(rems(crate::ui::right_panel::TEXT)) + .text_color(gpui::rgb(sf.text_resting)) + .child(label), + ) + .child( + div() + .flex_none() + .text_size(rems(META_MONO)) + .font_family(mono) + .text_color(muted) + .child(files.to_string()), + ) + .into_any_element() + } + /// "…and 40 more", and the note about a status git had to truncate. /// /// Secondary: it is prose about the list rather than a row of it, and the @@ -1413,11 +1601,15 @@ impl Tty7App { .into_any_element() } + /// One changed file. `depth` is its level in the tree view, and `None` in + /// the flat list — where the row carries its directory beside the name, + /// since there is no directory row above it to say it. fn scm_file_row( &self, repo: &RepoKey, group: ScmGroup, entry: &StatusEntry, + depth: Option, cx: &mut Context, ) -> AnyElement { let sf = cx.global::().sidebar; @@ -1438,8 +1630,10 @@ impl Tty7App { cx, ); + let selector = id.clone(); h_flex() .id(id.clone()) + .debug_selector(move || selector.to_string()) .group(id) .relative() .items_center() @@ -1448,6 +1642,9 @@ impl Tty7App { .w_full() .min_w_0() .px(px(ROW_INSET)) + .when_some(depth, |row, depth| { + row.pl(px(ROW_INSET + depth as f32 * TREE_INDENT)) + }) .py(px(3.)) .rounded(px(5.)) .cursor_pointer() @@ -1503,7 +1700,7 @@ impl Tty7App { // Cap the secondary directory while the filename fills the rest. // A rem cap also gives short paths their intrinsic width inside // the context-menu wrapper's flex layout. - .when(!dir.is_empty(), |this| { + .when(!dir.is_empty() && depth.is_none(), |this| { this.child( div() .flex_none() @@ -3012,6 +3209,80 @@ mod render_idle_gpui_tests { assert_eq!(draws_while_idle(&mut vcx), 0); let _ = std::fs::remove_dir_all(&root); } + + /// The tree view and the filter, end to end over a real repository: the + /// rows that come out are the ones the pure helpers promise, drawn. + #[gpui::test] + fn the_changes_list_folds_into_a_tree_and_filters_by_path(cx: &mut TestAppContext) { + let _serial = serial(); + crate::core::config::pin_test_config_dir(); + let root = scratch("tree"); + git(&root, &["init", "--quiet"]); + std::fs::create_dir_all(root.join("src/ui")).unwrap(); + std::fs::create_dir_all(root.join("docs")).unwrap(); + std::fs::write(root.join("src/ui/a.rs"), "a\n").unwrap(); + std::fs::write(root.join("src/ui/b.rs"), "b\n").unwrap(); + std::fs::write(root.join("docs/readme.md"), "r\n").unwrap(); + + // Keyed off the root the panel resolved, not off `root`: on Windows + // `scratch` hands back a `\\?\` verbatim path that never compares + // equal to git's spelling of the same directory — see the note on + // `a_settled_source_control_panel_reaches_render_idle`. + let (app, mut vcx, _pane) = scm_panel_on(cx, &root, |app, cx| { + app.scm.repo.as_ref().is_some_and(|r| { + crate::terminal::git_data::status_of(cx, r.host, &r.root) + .is_some_and(|s| s.untracked().count() == 3) + }) + }); + let drawn = |vcx: &mut VisualTestContext, what: &'static str| { + app.update_in(vcx, |_, _, cx| cx.notify()); + vcx.run_until_parked(); + vcx.debug_bounds(what).is_some() + }; + let a = "scm-row-Untracked-src/ui/a.rs"; + let readme = "scm-row-Untracked-docs/readme.md"; + let src_ui = "scm-dir-Untracked-src/ui"; + + // The flat list is the default, and it has no directory rows. + assert!(drawn(&mut vcx, a)); + assert!(!drawn(&mut vcx, src_ui)); + + // `src` holds nothing but `ui`, so the two share one row. + app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx)); + assert!(vcx.update(|_, cx| cx.global::().scm_changes_tree)); + assert!(drawn(&mut vcx, src_ui)); + assert!(!drawn(&mut vcx, "scm-dir-Untracked-src")); + assert!(drawn(&mut vcx, "scm-dir-Untracked-docs")); + assert!(drawn(&mut vcx, a)); + + // Folding the directory keeps its row and hides what is under it. + app.update(&mut vcx, |app, cx| { + app.scm.toggle_dir(ScmGroup::Untracked, "src/ui".into()); + cx.notify(); + }); + assert!(drawn(&mut vcx, src_ui)); + assert!(!drawn(&mut vcx, a)); + assert!(drawn(&mut vcx, readme)); + + // A filter drops what does not match — and opens the folded + // directory the match is in. + app.update_in(&mut vcx, |app, window, cx| { + let input = app.scm.filter.clone().expect("the filter row was drawn"); + input.update(cx, |state, cx| state.set_value("UI A", window, cx)); + }); + assert!(drawn(&mut vcx, a)); + assert!(!drawn(&mut vcx, "scm-row-Untracked-src/ui/b.rs")); + assert!(!drawn(&mut vcx, readme)); + assert!(!drawn(&mut vcx, "scm-dir-Untracked-docs")); + + // The same filter narrows the flat list too. + app.update(&mut vcx, |app, cx| app.scm_toggle_changes_tree(cx)); + assert!(drawn(&mut vcx, a)); + assert!(!drawn(&mut vcx, readme)); + assert!(!drawn(&mut vcx, src_ui)); + + let _ = std::fs::remove_dir_all(&root); + } } /// The action strip is the one place in the panel where a hover reveal sits diff --git a/src/ui/scm/state.rs b/src/ui/scm/state.rs index a201385f..ef44bef5 100644 --- a/src/ui/scm/state.rs +++ b/src/ui/scm/state.rs @@ -96,6 +96,14 @@ pub(crate) struct ScmPanelState { /// opening one by hand has to outlast the next file landing in it. pub(crate) collapsed: HashSet, pub(crate) toggled: HashSet, + /// Directories folded shut in the tree view, per group — the same + /// directory can be open under Changes and shut under Staged. Keyed by + /// the full repo-relative path of the (possibly compacted) row. + pub(crate) folded_dirs: HashSet<(ScmGroup, String)>, + /// The filter over the changed files. Created on first render like + /// `commit_input`, with the subscription that turns typing into a repaint. + pub(crate) filter: Option>, + pub(crate) filter_sub: Option, /// Working directory → the repository root containing it, or `None` when /// there is none, with when the answer was given. The root is what every /// write runs from and what every cache is keyed by, so it is resolved @@ -165,6 +173,14 @@ impl ScmPanelState { } } + /// Fold a tree directory shut, or open it again. + pub(crate) fn toggle_dir(&mut self, group: ScmGroup, key: String) { + let entry = (group, key); + if !self.folded_dirs.remove(&entry) { + self.folded_dirs.insert(entry); + } + } + pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) { self.toggled.insert(group); if collapsed { diff --git a/src/ui/scm/tree.rs b/src/ui/scm/tree.rs new file mode 100644 index 00000000..c0ed93b4 --- /dev/null +++ b/src/ui/scm/tree.rs @@ -0,0 +1,298 @@ +//! The changed-file list as a tree, and the filter both views share. +//! +//! Pure, and over plain paths rather than `StatusEntry`: the panel hands in +//! one group's paths and gets back rows that point into that same slice, so +//! the file rows it draws are the ones the flat list would have drawn — same +//! letter, same click, same context menu — only indented under a directory. +//! +//! Everything here comes out of the status the host already sent. A remote +//! repository gets the same tree without a single extra read of its disk. + +use std::collections::BTreeMap; + +/// One line of the tree view. +#[derive(Clone, PartialEq, Eq, Debug)] +pub(crate) enum TreeRow { + /// A directory, possibly several compacted into one: `src/ui/scm` when + /// `src` and `ui` hold nothing but the next directory down. `key` is the + /// full repo-relative path, which is what the fold state is keyed by; + /// `label` is what the row reads, relative to its parent row. + Dir { + key: String, + label: String, + depth: usize, + /// Every file underneath, at any depth — what a folded row still + /// owes the reader. + files: usize, + collapsed: bool, + }, + /// A file, by its position in the paths the tree was built from. + File { index: usize, depth: usize }, +} + +#[derive(Default)] +struct Node<'a> { + dirs: BTreeMap<&'a str, Node<'a>>, + files: Vec<(&'a str, usize)>, +} + +impl Node<'_> { + fn file_count(&self) -> usize { + self.files.len() + self.dirs.values().map(Node::file_count).sum::() + } +} + +/// Group `paths` by directory, directories before files at every level and +/// each run in byte order — the order git itself lists paths in. +/// +/// A directory whose only content is one more directory is folded into it, +/// the way VS Code's compact folders do: an agent's change three levels down +/// `crates/tty7-core/src` should cost one row to reach, not three. +/// +/// `collapsed` is asked once per directory row with its full path. A folded +/// directory still gets its own row; only what is under it is left out. +pub(crate) fn tree_rows<'a>( + paths: impl IntoIterator, + collapsed: impl Fn(&str) -> bool, +) -> Vec { + let mut root = Node::default(); + for (index, path) in paths.into_iter().enumerate() { + // An untracked directory arrives as `dir/`. It is a leaf here, the + // same as it is a single row in the flat list. + let trimmed = path.strip_suffix('/').unwrap_or(path); + let (dir, name) = match trimmed.rsplit_once('/') { + Some((dir, name)) => (Some(dir), name), + None => (None, trimmed), + }; + let mut node = &mut root; + for part in dir.into_iter().flat_map(|d| d.split('/')) { + node = node.dirs.entry(part).or_default(); + } + node.files.push((name, index)); + } + let mut rows = Vec::new(); + emit(&root, "", 0, &collapsed, &mut rows); + rows +} + +fn emit( + node: &Node<'_>, + prefix: &str, + depth: usize, + collapsed: &impl Fn(&str) -> bool, + rows: &mut Vec, +) { + for (name, child) in &node.dirs { + let mut label = (*name).to_string(); + let mut child = child; + while child.files.is_empty() && child.dirs.len() == 1 { + let (next, grandchild) = child.dirs.iter().next().expect("one entry"); + label.push('/'); + label.push_str(next); + child = grandchild; + } + let key = match prefix { + "" => label.clone(), + _ => format!("{prefix}/{label}"), + }; + let folded = collapsed(&key); + rows.push(TreeRow::Dir { + key: key.clone(), + label, + depth, + files: child.file_count(), + collapsed: folded, + }); + if !folded { + emit(child, &key, depth + 1, collapsed, rows); + } + } + let mut files = node.files.clone(); + files.sort_by(|a, b| a.0.cmp(b.0)); + rows.extend( + files + .into_iter() + .map(|(_, index)| TreeRow::File { index, depth }), + ); +} + +/// The filter box's text, normalised once: lowercased, trimmed, and `None` +/// when there is nothing left to filter by. +pub(crate) fn normalize_query(text: &str) -> Option { + let q = text.trim().to_lowercase(); + (!q.is_empty()).then_some(q) +} + +/// Whether a changed path survives the filter. +/// +/// Every whitespace-separated word has to appear somewhere in the path, in +/// any order: `scm panel` finds `src/ui/scm/panel.rs`, and so does `panel +/// scm`. `query` is what [`normalize_query`] returned. +pub(crate) fn path_matches(path: &str, query: &str) -> bool { + let hay = path.to_lowercase(); + query.split_whitespace().all(|word| hay.contains(word)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open(paths: &[&str]) -> Vec { + tree_rows(paths.iter().copied(), |_| false) + } + + fn dir(key: &str, label: &str, depth: usize, files: usize) -> TreeRow { + TreeRow::Dir { + key: key.into(), + label: label.into(), + depth, + files, + collapsed: false, + } + } + + fn file(index: usize, depth: usize) -> TreeRow { + TreeRow::File { index, depth } + } + + #[test] + fn files_are_grouped_under_their_directories() { + let rows = open(&["src/a.rs", "README.md", "src/b.rs", "docs/x.md"]); + assert_eq!( + rows, + vec![ + dir("docs", "docs", 0, 1), + file(3, 1), + dir("src", "src", 0, 2), + file(0, 1), + file(2, 1), + // Files after directories, at the root as everywhere else. + file(1, 0), + ] + ); + } + + #[test] + fn a_directory_holding_only_a_directory_is_compacted() { + let rows = open(&[ + "crates/core/src/git/status.rs", + "crates/core/src/git/diff.rs", + "crates/core/Cargo.toml", + ]); + assert_eq!( + rows, + vec![ + // `crates` holds only `core`, so they share a row; `core` + // holds a file, so the chain stops there. + dir("crates/core", "crates/core", 0, 3), + dir("crates/core/src/git", "src/git", 1, 2), + file(1, 2), + file(0, 2), + file(2, 1), + ] + ); + } + + #[test] + fn a_directory_with_two_children_is_not_compacted() { + let rows = open(&["a/b/x.rs", "a/c/y.rs"]); + assert_eq!( + rows, + vec![ + dir("a", "a", 0, 2), + dir("a/b", "b", 1, 1), + file(0, 2), + dir("a/c", "c", 1, 1), + file(1, 2), + ] + ); + } + + #[test] + fn a_folded_directory_keeps_its_row_and_its_count() { + let rows = tree_rows(["src/ui/a.rs", "src/ui/b.rs", "src/main.rs"], |key| { + key == "src/ui" + }); + assert_eq!( + rows, + vec![ + dir("src", "src", 0, 3), + TreeRow::Dir { + key: "src/ui".into(), + label: "ui".into(), + depth: 1, + files: 2, + collapsed: true, + }, + file(2, 1), + ] + ); + } + + #[test] + fn folding_is_asked_about_the_compacted_path() { + let asked = std::cell::RefCell::new(Vec::new()); + tree_rows(["a/b/c/x.rs"], |key| { + asked.borrow_mut().push(key.to_string()); + false + }); + assert_eq!(asked.into_inner(), vec!["a/b/c".to_string()]); + } + + #[test] + fn an_untracked_directory_is_a_leaf() { + let rows = open(&["build/", "src/new/"]); + assert_eq!(rows, vec![dir("src", "src", 0, 1), file(1, 1), file(0, 0)]); + } + + #[test] + fn every_path_lands_in_exactly_one_file_row() { + let paths = [ + "z.rs", + "a/b/c.rs", + "a/b/d.rs", + "a/e.rs", + "f/g/h/i.rs", + "f/j.rs", + "a/b/", + ]; + let mut seen: Vec = open(&paths) + .into_iter() + .filter_map(|row| match row { + TreeRow::File { index, .. } => Some(index), + TreeRow::Dir { .. } => None, + }) + .collect(); + seen.sort_unstable(); + assert_eq!(seen, (0..paths.len()).collect::>()); + } + + #[test] + fn an_empty_list_is_an_empty_tree() { + assert!(open(&[]).is_empty()); + } + + #[test] + fn the_filter_needs_every_word_in_any_order_and_any_case() { + let q = normalize_query(" Scm PANEL ").unwrap(); + assert!(path_matches("src/ui/scm/panel.rs", &q)); + assert!(path_matches("src/ui/Panel/SCM.rs", &q)); + assert!(!path_matches("src/ui/scm/state.rs", &q)); + let q = normalize_query("panel scm").unwrap(); + assert!(path_matches("src/ui/scm/panel.rs", &q)); + } + + #[test] + fn a_blank_filter_is_no_filter() { + assert_eq!(normalize_query(""), None); + assert_eq!(normalize_query(" "), None); + assert_eq!(normalize_query(" a "), Some("a".into())); + } + + #[test] + fn the_filter_reads_non_ascii_paths() { + let q = normalize_query("设计").unwrap(); + assert!(path_matches("文档/设计/方案.md", &q)); + assert!(!path_matches("文档/实现.md", &q)); + } +} From 23948bcddaef05dad3336ea30f2114ab1dbb9823 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:28:49 +0800 Subject: [PATCH 05/20] fix(render): draw fallback Nerd Font icons at the text's size (#949) * fix(render): scale fallback-font icons up to fill their cell A lone Private Use Area glyph supplied by a fallback face is drawn at the primary's font size on the fallback's own metrics, so Nerd Font icons came out about two thirds of the cell. Grow such a glyph, aspect ratio kept, until it fills the width of its cells or the height of the row (at most 2x), and centre it there. Overflow is still shrunk as before, text from a fallback face keeps its metrics, and the primary's own icons are untouched. Closes #866 * fix(render): fit fallback icons to their cells, borrowing a same-colour blank Checked in the running app with Symbols Nerd Font Mono behind Menlo: its icons ink a full em (1.6 cells), so growth alone never fired. What made them small was the one-cell budget an icon gets when the space after it paints a background, which is every icon in a coloured prompt segment. Fit a fallback icon to its cells and one em of height instead, letting it take the blank after it when that blank paints no background or the icon's own, and only when that makes it bigger. Leave the Powerline separators to the existing rule. * docs(render): name icon_fit in the fit_scale test's comment --- CHANGELOG.md | 17 ++ src/terminal/element.rs | 390 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 379 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ae4e40..fdd34533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 already sends, so a remote repository gets them unchanged. Searching the diff text itself is not part of this. +### Fixed + +- **Nerd Font icons from a fallback font come out at the text's size** (#866). + With a Nerd Font icon face such as Symbols Nerd Font Mono behind a primary + that lacks the icons, an icon followed by a space on the same background — + every icon in a coloured Powerline or p10k segment — was held to one cell + and shrunk to about two thirds of the text's height. Icons with a plain + space after them were already drawn full size, so one prompt had icons in + two sizes. A lone Private Use Area glyph supplied by a fallback face is now + fitted, aspect ratio kept, to its cells and one em of height, taking the + blank after it when that blank paints no background or the icon's own and + when doing so makes it bigger, and centred in the row; a face whose icons + ink less than a cell grows (at most 2×). The Powerline separators, CJK, + emoji, other text and the primary font's own icons are drawn as before. + Not on Linux, where the text system reports a glyph's advance box rather + than its ink. + ## [26.9.3] - 2026-09-23 ### Added diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 448aedc1..7424b570 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -884,8 +884,8 @@ fn segment_row(row: &[RenderCell]) -> Vec { thread_local! { static CHAR_STRINGS: RefCell> = RefCell::new(HashMap::new()); - /// Measured ink extents and the font size they were measured at. - static INK_EXTENTS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option>)> = + /// Measured ink bounds and the font size they were measured at. + static INK_BOUNDS: RefCell<(Pixels, HashMap<(gpui::FontId, char), Option>>)> = RefCell::new((px(0.), HashMap::new())); } @@ -1089,6 +1089,9 @@ fn seg_budget(solo: bool, measured: bool, cells: usize, room: bool, cell_width: } /// How much to shrink a segment so its glyph stops inside its budget. +/// +/// Never more than 1. A fallback icon is not held to this at all: +/// [`icon_fit`] sizes it, up or down. fn fit_scale(ink: Pixels, budget: Pixels) -> f32 { if ink <= budget || ink <= px(0.) { 1. @@ -1097,22 +1100,157 @@ fn fit_scale(ink: Pixels, budget: Pixels) -> f32 { } } -/// Where a segment's ink ends, measured from the left edge of its first cell. +/// Whether the platform's `typographic_bounds` is the glyph's ink. +/// +/// CoreText and DirectWrite answer with the ink box. cosmic-text answers with +/// the advance box pinned to the baseline, which says nothing about where an +/// icon's ink sits; sizing and centring on that would misplace it, so the +/// Linux build keeps the shrink-only rule for icons too. +const INK_BOUNDS_ARE_INK: bool = cfg!(any(target_os = "macos", target_os = "windows")); + +/// The most an icon is ever enlarged by. +/// +/// The cap keeps a glyph that is small by design — a dot, a bullet — from +/// being blown up to a cell-sized blob. +const MAX_ICON_GROWTH: f32 = 2.; + +/// Whether a character is an icon: a code point in one of Unicode's Private +/// Use Areas, where Nerd Fonts, Font Awesome, Codicons and Material Design +/// icons all live. +/// +/// Nothing in these ranges is text, so sizing one to its cells cannot change +/// how prose reads — the reason the rule is keyed on the code point and not +/// on the face: a CJK or emoji fallback keeps its own metrics. +/// +/// The Powerline separators (`U+E0B0`–`U+E0D7`) are not icons. They are +/// shapes meant to abut the cells around them, the solid ones are painted +/// natively, and the thin ones already reach the row's full height, so +/// fitting them to an em and centring them would only pull them away from +/// the segments they join. +fn is_icon(c: char) -> bool { + matches!( + c as u32, + 0xE000..=0xE0AF | 0xE0D8..=0xF8FF | 0xF_0000..=0xF_FFFD | 0x10_0000..=0x10_FFFD + ) +} + +/// Whether the cell after an icon can lend it room, the way kitty lets a +/// Private Use Area glyph followed by a space take two cells. +/// +/// Everything [`has_room_after`] lends, and one thing more: a blank that +/// paints the *same* background as the icon's own cell. That is the space +/// after every icon in a coloured prompt segment, and backgrounds are painted +/// before any glyph, so the icon lands on its own colour. Held back to one +/// cell there, a Nerd Font icon is shrunk to about two thirds of the text's +/// height, which is #866. +fn lends_to_icon(row: &[RenderCell], start: usize, cells: usize) -> bool { + if has_room_after(row, start, cells) { + return true; + } + let (Some(icon), Some(next)) = (row.get(start), row.get(start + cells)) else { + return false; + }; + is_blank(next) + && !next.selected + && icon.draw_bg + && next.draw_bg + && next.bg == icon.bg + && next.match_hit == icon.match_hit + && next.match_current == icon.match_current + && !GlyphStyle::of(next).draws_on_blanks() +} + +/// How to size an icon a fallback face supplied, and how many cells it spans. +/// +/// A fallback face is drawn at the primary's font size on its own metrics, so +/// what an icon looks like beside the text depends on how the two faces' +/// cells compare. Symbols Nerd Font Mono inks a full em per icon, which is +/// 1.6 cells of a 0.6em primary; a face with a narrower cell than the +/// primary's inks less than one. Neither is the icon at the text's size. +/// +/// So fit the ink, aspect ratio kept, to the cells it is given and one em of +/// height — the size the icon was drawn at in its own face, and never taller +/// than the text's line. That may shrink or grow it, growth capped at +/// [`MAX_ICON_GROWTH`] and ignored under 2%, which is not worth a reshape. +/// When `lend` says the next cell is free the icon may take it too, but only +/// if that lets it come out bigger: a narrow glyph that already fits one cell +/// keeps to it and stays beside what follows. +fn icon_fit( + ink: Bounds, + cell_width: Pixels, + cells: usize, + lend: bool, + height: Pixels, +) -> (f32, usize) { + let (w, h) = (ink.size.width.as_f32(), ink.size.height.as_f32()); + if !(w > 0. && h > 0.) { + return (1., cells); + } + let fit = |cells: usize| { + let scale = (cell_width.as_f32() * cells as f32 / w) + .min(height.as_f32() / h) + .min(MAX_ICON_GROWTH); + if (1. ..=1.02).contains(&scale) { + 1. + } else { + scale + } + }; + let own = fit(cells); + if lend && fit(cells + 1) > own { + (fit(cells + 1), cells + 1) + } else { + (own, cells) + } +} + +/// Where to paint a fitted icon, relative to its cell's top-left corner, so +/// its ink sits centred in `room` — vertically always, and horizontally only +/// when `centre_x`. An icon spilling into a borrowed cell stays where its +/// face put it instead, as it would unfitted, so the gap before the text +/// after it is the one its face drew. +/// +/// `ink` is the glyph's bounds at its native size (y up from the baseline); +/// `ascent` and `descent` are the line's as shaped at the fitted size, which +/// is what gpui centres the baseline in the row with. +fn icon_offset( + ink: Bounds, + scale: f32, + room: gpui::Size, + centre_x: bool, + ascent: Pixels, + descent: Pixels, +) -> Point { + let baseline = (room.height - ascent - descent) / 2. + ascent; + let ink_top = baseline - (ink.origin.y + ink.size.height) * scale; + let x = if centre_x { + (room.width - ink.size.width * scale) / 2. - ink.origin.x * scale + } else { + // Nudged only as far as it takes to keep the ink inside the room. + px(0.) + .min(room.width - (ink.origin.x + ink.size.width) * scale) + .max(-ink.origin.x * scale) + }; + point(x, (room.height - ink.size.height * scale) / 2. - ink_top) +} + +/// The ink bounds of a segment's first glyph, relative to its pen position, +/// y up from the baseline. /// /// Advance is the wrong yardstick: Apple Color Emoji advances 1.31em but only /// inks 1.25em, so going by advance shrinks glyphs that would have fit. Ask /// for the glyph's own bounds instead, cached per (face, char) because the /// lookup is a font query a screenful of CJK would otherwise repeat on every /// cell of every frame. -fn ink_extent( +fn ink_bounds( cx: &App, shaped: &gpui::ShapedLine, text: &str, font_size: Pixels, -) -> Option { +) -> Option> { let font_id = shaped.runs.first()?.font_id; let c = text.chars().next()?; - INK_EXTENTS.with(|cache| { + INK_BOUNDS.with(|cache| { let hit = { let mut cache = cache.borrow_mut(); if cache.0 != font_size { @@ -1127,13 +1265,12 @@ fn ink_extent( if let Some(extent) = hit { return extent; } - let extent = cx + let bounds = cx .text_system() .typographic_bounds(font_id, font_size, c) - .ok() - .map(|bounds| bounds.origin.x + bounds.size.width); - cache.borrow_mut().1.insert((font_id, c), extent); - extent + .ok(); + cache.borrow_mut().1.insert((font_id, c), bounds); + bounds }) } @@ -1234,7 +1371,7 @@ fn shape_piece( .shape_line(piece, font_size, run_buf, force_width) } -/// Whether [`ink_extent`]'s answer speaks for the whole segment. +/// Whether [`ink_bounds`]'s answer speaks for the whole segment. /// /// It measures the segment's first character in the run's first face. That is /// all of a one-character segment and only part of anything longer: a @@ -1284,6 +1421,9 @@ fn paint_glyphs( build_font(bold_font.unwrap_or(base_font), true, true), ]; + // Resolved only once an icon asks, so a pane without one pays nothing. + let mut primary_ids: [Option; 4] = [None; 4]; + let run_buf = &mut [TextRun { len: 0, font: base_font.clone(), @@ -1435,17 +1575,40 @@ fn paint_glyphs( .shape_line(text.clone(), font_size, run_buf, force_width); // Measured before the budget is set, because how far a solo glyph // may reach turns on whether its ink is known at all. - let ink = ink_extent(cx, &shaped, &text, font_size); + let bounds = ink_bounds(cx, &shaped, &text, font_size); + let ink = bounds.map(|b| b.origin.x + b.size.width); + let measured = ink_covers_segment(ink, &text); let budget = seg_budget( solo, - ink_covers_segment(ink, &text), + measured, cells, has_room_after(row_cells, start, cells), geom.cell_width, ); - if let Some(ink) = ink { - let scale = fit_scale(ink, budget); - if scale < 1. { + let mut origin = point(x, y); + let mut clip_width = budget; + if let (Some(ink), Some(bounds)) = (ink, bounds) { + // Only a lone icon that a fallback face supplied is fitted: + // the primary's own icons were drawn for its cell already. + let icon = INK_BOUNDS_ARE_INK + && measured + && text.chars().next().is_some_and(is_icon) + && shaped.runs.first().is_some_and(|r| { + r.font_id + != *primary_ids[face_ix].get_or_insert_with(|| { + window.text_system().resolve_font(&faces[face_ix]) + }) + }); + let (scale, room) = if icon { + let lend = lends_to_icon(row_cells, start, cells); + let height = font_size.min(geom.line_height); + let (scale, spans) = icon_fit(bounds, geom.cell_width, cells, lend, height); + let room = size(geom.cell_width * spans as f32, geom.line_height); + (scale, Some((room, spans == cells))) + } else { + (fit_scale(ink, budget), None) + }; + if scale != 1. { shaped = window.text_system().shape_line( text.clone(), font_size * scale, @@ -1453,18 +1616,16 @@ fn paint_glyphs( force_width, ); } + if let Some((room, centre_x)) = room { + origin += + icon_offset(bounds, scale, room, centre_x, shaped.ascent, shaped.descent); + clip_width = room.width; + } } - let clip = Bounds::new(point(x, y), size(budget, geom.line_height)); + let clip = Bounds::new(point(x, y), size(clip_width, geom.line_height)); window.with_content_mask(Some(ContentMask { bounds: clip }), |window| { - _ = shaped.paint( - point(x, y), - geom.line_height, - TextAlign::Left, - None, - window, - cx, - ); + _ = shaped.paint(origin, geom.line_height, TextAlign::Left, None, window, cx); }); } } @@ -2675,11 +2836,12 @@ mod tests { } #[test] - fn fit_scale_only_shrinks_glyphs_that_overflow_their_budget() { + fn fit_scale_shrinks_what_overflows_and_never_enlarges() { let budget = px(15.); assert_eq!(fit_scale(px(19.2), budget), 15. / 19.2); // Whatever already fits keeps its own size, including a glyph that - // lands exactly on the edge and a run that measured as empty. + // lands exactly on the edge and a run that measured as empty. Making + // a small glyph bigger is `icon_fit`'s rule, not this one's. assert_eq!(fit_scale(px(15.), budget), 1.); assert_eq!(fit_scale(px(12.), budget), 1.); assert_eq!(fit_scale(px(0.), budget), 1.); @@ -4452,4 +4614,176 @@ mod tests { Some(&['\u{FE0F}'][..]), ); } + + fn ink(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds::new(point(px(x), px(y)), size(px(w), px(h))) + } + + /// Menlo at 22px: a 13.2px cell in a 30.8px row (line height 1.4). + const CELL: f32 = 13.2; + const EM: f32 = 22.; + + fn fit(ink: Bounds, cells: usize, lend: bool) -> (f32, usize) { + icon_fit(ink, px(CELL), cells, lend, px(EM)) + } + + #[test] + fn only_private_use_code_points_count_as_icons() { + for c in [ + '\u{e000}', // first of the BMP Private Use Area + '\u{e0a0}', // Powerline branch + '\u{e0d8}', // first past the Powerline separators + '\u{f179}', // Font Awesome apple + '\u{f8ff}', // last of the BMP Private Use Area + '\u{f0001}', // Material Design icon, Supplementary PUA-A + '\u{10fffd}', // end of Supplementary PUA-B + ] { + assert!(is_icon(c), "U+{:04X}", c as u32); + } + // Text a fallback face supplies keeps its own metrics: CJK, emoji, + // Latin, and the drawing characters painted natively anyway. + for c in [ + '中', + 'あ', + '한', + '\u{1F600}', + '\u{2764}', + 'M', + '─', + '█', + '\u{f900}', + ] { + assert!(!is_icon(c), "U+{:04X}", c as u32); + } + // Nor do the Powerline separators, which are shapes, not icons. + for c in ['\u{e0b0}', '\u{e0b1}', '\u{e0b3}', '\u{e0d7}'] { + assert!(!is_icon(c), "U+{:04X}", c as u32); + } + } + + #[test] + fn a_nerd_font_icon_before_a_space_takes_two_cells_at_full_size() { + // Symbols Nerd Font Mono's apple, as CoreText measures it at 22px: a + // full em tall and 1.4 cells wide. Held to one cell it is shrunk to + // 0.71 of the text's size, which is what #866 saw; with the space + // after it lent, it keeps its whole em. + let apple = ink(1.76, -4.43, 18.48, 22.03); + let (held, cells) = fit(apple, 1, false); + assert_eq!(cells, 1); + assert!((apple.size.width.as_f32() * held - CELL).abs() < 1e-3); + let (lent, cells) = fit(apple, 1, true); + assert_eq!(cells, 2); + assert!((apple.size.height.as_f32() * lent - EM).abs() < 1e-3); + assert!(apple.size.width.as_f32() * lent <= 2. * CELL); + } + + #[test] + fn a_narrow_icon_keeps_to_its_own_cell_even_when_the_next_is_free() { + // The branch symbol is an em tall and 0.4em wide: one cell already + // holds it at full height, so borrowing would only push it off + // towards the text after it. + let branch = ink(6.52, -4.4, 8.96, 22.); + assert_eq!(fit(branch, 1, true), (1., 1)); + } + + #[test] + fn a_small_icon_from_a_narrow_face_grows_to_fill_its_cell() { + // A face with a narrower cell than the primary inks well under it. + let icon = ink(0.5, -1., 8.8, 9.); + let (scale, cells) = fit(icon, 1, false); + assert_eq!(cells, 1); + assert!(scale > 1.); + assert!((icon.size.width.as_f32() * scale - CELL).abs() < 1e-3); + // The same icon two cells wide may use both. + let (two, cells) = fit(icon, 2, false); + assert_eq!(cells, 2); + assert!(two > scale); + } + + #[test] + fn a_fitted_icon_is_centred_in_its_room() { + let icon = ink(0.4, -0.6, 5.3, 6.); + let room = size(px(CELL), px(30.8)); + let (scale, _) = fit(icon, 1, false); + let (ascent, descent) = (px(20.4 * scale), px(5.2 * scale)); + let at = icon_offset(icon, scale, room, true, ascent, descent); + // Spilling into a borrowed cell, it stays where its face put it, + // unless that would take its ink past either edge of the room. + assert_eq!( + icon_offset(icon, scale, room, false, ascent, descent).x, + px(0.) + ); + let lent = size(px(2. * CELL), px(30.8)); + let overhang = ink(-1., 0., 20., 20.); + assert_eq!( + icon_offset(overhang, 1., lent, false, ascent, descent).x, + px(1.) + ); + let pushed = ink(8., 0., 20., 20.); + let x = icon_offset(pushed, 1., lent, false, ascent, descent).x; + assert!((x.as_f32() - (2. * CELL - 28.)).abs() < 1e-4, "{x:?}"); + let left = at.x.as_f32() + icon.origin.x.as_f32() * scale; + let right = left + icon.size.width.as_f32() * scale; + assert!((left - (CELL - right)).abs() < 1e-3, "{left}..{right}"); + let baseline = at.y + (room.height - ascent - descent) / 2. + ascent; + let top = (baseline - (icon.origin.y + icon.size.height) * scale).as_f32(); + let bottom = (baseline - icon.origin.y * scale).as_f32(); + assert!((top - (30.8 - bottom)).abs() < 1e-3, "{top}..{bottom}"); + } + + #[test] + fn icon_fit_leaves_what_is_already_the_right_size_alone() { + // Within 2% of the cell: not worth shaping the glyph again. + assert_eq!(fit(ink(0., 0., 13., 13.), 1, false), (1., 1)); + // Nothing measured, nothing to scale by. + assert_eq!(fit(ink(0., 0., 0., 0.), 1, true), (1., 1)); + // A dot is small by design and grows no more than the cap. + assert_eq!(fit(ink(1., 2., 2., 2.), 1, false).0, MAX_ICON_GROWTH); + } + + fn icon_row(next: RenderCell) -> Vec { + let icon = RenderCell { + c: '\u{f179}', + ..RenderCell::default() + }; + vec![icon, next] + } + + #[test] + fn a_blank_in_the_same_colour_lends_an_icon_its_cell() { + let blank = RenderCell::default(); + assert!(lends_to_icon(&icon_row(blank.clone()), 0, 1)); + // Whatever `has_room_after` lends, the end of the row included. + assert!(lends_to_icon(&icon_row(blank.clone())[..1], 0, 1)); + // A prompt segment: icon and the space after it on one background. + let bg = Hsla::blue(); + let mut row = icon_row(RenderCell { + bg, + draw_bg: true, + ..RenderCell::default() + }); + row[0].bg = bg; + row[0].draw_bg = true; + assert!(lends_to_icon(&row, 0, 1)); + // The next segment's colour, a glyph or a selection do not. + row[1].bg = Hsla::red(); + assert!(!lends_to_icon(&row, 0, 1)); + let letter = RenderCell { + c: 'x', + ..RenderCell::default() + }; + assert!(!lends_to_icon(&icon_row(letter), 0, 1)); + let selected = RenderCell { + selected: true, + ..RenderCell::default() + }; + assert!(!lends_to_icon(&icon_row(selected), 0, 1)); + // Nor does a coloured blank after an icon on the default background. + let coloured = RenderCell { + bg, + draw_bg: true, + ..blank + }; + assert!(!lends_to_icon(&icon_row(coloured), 0, 1)); + } } From 02d8611e3358d5e8be50582dbf39541754cf0daa Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:32:19 +0800 Subject: [PATCH 06/20] feat(cli): add exec, and send --stdin/--from-file/--paste (#951) `tty7 exec %N -- CMD` types a line at an existing pane's shell prompt, follows the shell integration's OSC 133 marks to the command's end, prints what it printed (rendered to text by default, `--raw` for the bytes) and exits with its exit code. `--timeout` exits 124 and leaves the command running. A pane with no prompt marks, or one not at a prompt, is refused before anything is typed. `tty7 send` can take its text from `--stdin` or `--from-file`, sent byte for byte and never echoed in --json, so a secret stays out of `ps` and shell history. `--paste` frames the text the way the GUI's paste does: bracketed when the pane has mode 2004 on, unframed otherwise, reported in --json. The framing moves into tty7-core (`core::paste`) so the GUI's clipboard paste, the agent prompt and the CLI share one construction, and the daemon now reports each pane's bracketed-paste mode in PaneContext. Closes #839 Closes #838 --- CHANGELOG.md | 22 + crates/tty7-cli/src/backend.rs | 39 ++ crates/tty7-cli/src/backend/real.rs | 111 +++++ crates/tty7-cli/src/cli.rs | 145 +++++- crates/tty7-cli/src/commands.rs | 596 +++++++++++++++++++++++- crates/tty7-cli/src/exec.rs | 305 ++++++++++++ crates/tty7-cli/src/keys.rs | 6 + crates/tty7-cli/src/main.rs | 1 + crates/tty7-cli/tests/cli_e2e.rs | 223 +++++++++ crates/tty7-core/src/core/mod.rs | 1 + crates/tty7-core/src/core/paste.rs | 99 ++++ crates/tty7-core/src/core/term_modes.rs | 20 + crates/tty7-core/src/daemon/pane.rs | 1 + crates/tty7-core/src/daemon/protocol.rs | 9 + skills/tty7/SKILL.md | 47 +- skills/tty7/references/commands.md | 80 +++- src/core/agent_prompt.rs | 5 +- src/terminal/view.rs | 10 +- 18 files changed, 1684 insertions(+), 36 deletions(-) create mode 100644 crates/tty7-cli/src/exec.rs create mode 100644 crates/tty7-core/src/core/paste.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd34533..d870f6f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`tty7 exec` runs a command in a pane that already exists and hands back its + result** (#839). `run` makes a new pane and `send` does not wait, so every + script that wanted a command's exit code from a shell it already had ended up + as `send`, a sleep, a `capture` and a guess, or an `echo $? > /tmp/rc` side + channel. `tty7 exec %3 -- cargo test` types the line at the pane's prompt, + follows the shell integration's marks to the command's end, prints what it + printed — as text, the way `capture --plain` reads it, or escapes intact with + `--raw` — and exits with its exit code. `--timeout` gives up with 124 and + leaves the command running; a pane with no prompt marks, or one that is not + at a prompt, is refused before anything is typed instead of waited on. + +- **`tty7 send` takes its text from stdin or a file, and can paste it** + (#838). A token passed as `send %1 "$TOKEN"` sits in the CLI's command line, + where any local user reads it in `ps`, and in the caller's shell history; + `--stdin` and `--from-file` send the bytes exactly as read and never echo + them in `--json`. `--paste` sends the text the way a paste into the window + does — in bracketed paste when the pane has switched it on, so a multi-line + text arrives as text instead of running line by line, with any ESC stripped + so the text cannot close the paste itself. A pane without the mode gets the + same unframed paste the GUI would send it, and `--json` says which one went. + The server now reports each pane's bracketed-paste mode in `tty7 procs`. + - **The source control panel's changed files can be filtered and shown as a tree** (#473). A filter field sits above the list: every word typed has to appear somewhere in a file's path, in any order and any case, and the group diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 448cfc66..7807524d 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -61,6 +61,17 @@ pub trait Backend { fn run_wait(&mut self) -> Result>; + /// Type `line` at the pane's shell prompt and follow the pane until its + /// prompt comes back, the deadline passes, or it exits. Refuses — sending + /// nothing — a pane with no shell integration or one that is not at a + /// prompt; see [`ExecEnd`](crate::exec::ExecEnd). + fn exec( + &mut self, + pane: u64, + line: Vec, + timeout: Option, + ) -> Result; + fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>; } @@ -78,6 +89,7 @@ pub mod mock { use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs}; use super::{Backend, CaptureSegment, RunSpec}; + use crate::exec::{ExecEnd, ExecRun}; pub struct MockBackend { pub machine: Machine, @@ -103,6 +115,8 @@ pub mod mock { pub events: Vec, /// Set to make `hello` fail — doctor's unreachable-server branch. pub unreachable: bool, + pub execs: Vec<(u64, Vec, Option)>, + pub exec_run: ExecRun, } impl Default for MockBackend { @@ -127,6 +141,21 @@ pub mod mock { run_exit: Some(0), events: Vec::new(), unreachable: false, + execs: Vec::new(), + exec_run: ExecRun { + end: ExecEnd::Finished { + exit: Some(0), + ran: true, + }, + output: Vec::new(), + dropped: 0, + size: tty7_core::daemon::protocol::WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 16, + }, + }, } } } @@ -219,6 +248,16 @@ pub mod mock { Ok(self.run_exit) } + fn exec( + &mut self, + pane: u64, + line: Vec, + timeout: Option, + ) -> Result { + self.execs.push((pane, line, timeout)); + Ok(self.exec_run.clone()) + } + fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> { for event in self.events.drain(..) { on_event(event)?; diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index 9447eac6..8723d2ea 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -13,6 +13,7 @@ use tty7_core::daemon::protocol::{DaemonMsg, PaneInfo, PaneProcs, ShellSpec, Win use tty7_core::daemon::router::RouteTarget; use super::{Backend, CaptureSegment, RunSpec}; +use crate::exec::{ExecEnd, ExecRun, Transcript}; const SESSION_SIZE: WinSize = WinSize { cols: 120, @@ -23,6 +24,9 @@ const SESSION_SIZE: WinSize = WinSize { const REPLAY_FIRST_WAIT: Duration = Duration::from_secs(10); const REPLAY_SETTLE: Duration = Duration::from_millis(300); +/// A backstop on reading the replay: it ends on the first live output or a +/// quiet spell, and this only bounds a daemon that keeps sending status. +const EXEC_REPLAY_MAX: Duration = Duration::from_secs(10); const NOT_RUNNING: &str = "could not reach the tty7 server on this machine — `tty7 server start` brings one up"; @@ -275,6 +279,113 @@ impl Backend for RealBackend { Ok(code) } + fn exec(&mut self, pane: u64, line: Vec, timeout: Option) -> Result { + let deadline = timeout.and_then(|t| std::time::Instant::now().checked_add(t)); + // Watching starts before anything is typed, so no byte the command + // prints can go past unseen. The replay that comes first is only read + // for the pane's state — the daemon sends its prompt report last. + let mut session = self + .pane_client()? + .observe(pane, SESSION_SIZE) + .with_context(|| format!("observing pane %{pane}"))?; + let mut size = SESSION_SIZE; + // (integration active, at a prompt), as the daemon last reported them. + let mut prompt: Option<(bool, bool)> = None; + let until = std::time::Instant::now() + EXEC_REPLAY_MAX; + let _ = session.set_recv_timeout(Some(REPLAY_FIRST_WAIT)); + let finish = |session: PaneSession, end, size| -> Result { + let _ = session.detach(); + Ok(ExecRun { + end, + output: Vec::new(), + dropped: 0, + size, + }) + }; + loop { + match session.recv() { + Ok(DaemonMsg::Size(seen)) => size = seen, + Ok(DaemonMsg::Prompt { + active, at_prompt, .. + }) => prompt = Some((active, at_prompt)), + Ok(DaemonMsg::Exited { code }) => { + return finish(session, ExecEnd::PaneExited(code), size); + } + // Live output: the replay is over, and so is `capture`'s + // reading of it. A pane still printing is not at a prompt, + // and its report has already said so. + Ok(DaemonMsg::Output(_)) => break, + Ok(_) => {} + Err(e) if timed_out(&e) => break, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + return finish(session, ExecEnd::PaneExited(None), size); + } + Err(e) => return Err(anyhow!(e).context("reading the pane replay")), + } + if std::time::Instant::now() >= until { + break; + } + let _ = session.set_recv_timeout(Some(REPLAY_SETTLE)); + } + match prompt { + Some((true, true)) => {} + Some((true, false)) => return finish(session, ExecEnd::Busy, size), + _ => return finish(session, ExecEnd::NoIntegration, size), + } + + self.send_input(pane, line)?; + let mut transcript = Transcript::new(); + let end = loop { + let wait = match deadline { + Some(d) => { + let left = d.saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + break ExecEnd::TimedOut { + started: transcript.started(), + }; + } + Some(left) + } + None => None, + }; + let _ = session.set_recv_timeout(wait); + match session.recv() { + Ok(DaemonMsg::Output(bytes)) => { + transcript.output(&bytes); + transcript.compact(); + } + Ok(DaemonMsg::Size(seen)) => size = seen, + Ok(DaemonMsg::Prompt { + at_prompt, + last_exit, + .. + }) => { + if let Some(done) = transcript.prompt(at_prompt, last_exit) { + break ExecEnd::Finished { + exit: done.exit, + ran: done.ran, + }; + } + } + Ok(DaemonMsg::Exited { code }) => break ExecEnd::PaneExited(code), + Ok(_) => {} + Err(e) if timed_out(&e) => continue, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + break ExecEnd::PaneExited(None); + } + Err(e) => return Err(anyhow!(e).context("following the command's output")), + } + }; + let _ = session.detach(); + let (output, dropped) = transcript.take(); + Ok(ExecRun { + end, + output, + dropped, + size, + }) + } + fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> { let client = self.control_client()?; for event in client.events() { diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 7cfd0b73..368d3be2 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -72,11 +72,35 @@ pub enum Command { // written out here: a hand-copied vocabulary drifts the first time a key // is added, and this is the text a caller reaches for to learn the names. #[command( - about = "Type text into a pane, or send it keystrokes with --key", + about = "Type text into a pane — from an argument, --stdin or --from-file — or send \ + it keystrokes with --key", long_about = crate::keys::send_long_help() )] Send(SendArgs), + // `run` is the other half of this pair, and the split is docker's: `run` + // makes a new place for a command, `exec` uses one that already exists. + // Not a flag on `run` — that verb execs an argv in a fresh pty, while this + // types a line into a shell that is already sitting at its prompt, and + // none of `run`'s flags (`--keep`, `--cwd`, `--ws`) mean anything here. + #[command( + about = "Run a command line in an existing pane's shell, wait for its prompt to \ + come back, print what the command printed, and exit with its exit code", + long_about = "Run a command line in an existing pane's shell, wait for its prompt to \ + come back, print what the command printed, and exit with its exit code.\n\n\ + The line is typed at the shell's prompt, exactly as `send … --enter` \ + would, so it runs with that shell's cwd, environment and history. The \ + pane's shell integration is what says where the command's output starts \ + and ends and what its exit code was: a pane with none (no OSC 133 prompt \ + marks yet) is refused rather than waited on, and so is a pane that is not \ + at a prompt. The output is what the pane printed between the command \ + starting and the prompt coming back, replayed through a terminal to \ + text the way `capture --plain` does — or as raw bytes with `--raw`.\n\n\ + A line the shell never runs — an unclosed quote waiting on a continuation \ + prompt — has no end to wait for; pass --timeout in anything unattended." + )] + Exec(ExecArgs), + #[command( about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \ escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \ @@ -158,6 +182,47 @@ pub struct RunArgs { pub cmd: Vec, } +#[derive(Debug, Args)] +pub struct ExecArgs { + #[arg( + value_name = "%PANE", + help = "Pane whose shell runs the command; defaults to $TTY7_PANE inside a tty7 shell" + )] + pub target: Option, + + #[arg( + long, + value_name = "SECS", + help = "Give up after this many seconds, with exit code 124; the command is left \ + running in the pane" + )] + pub timeout: Option, + + // Text is the default here, unlike `capture`: what a caller of this verb + // wants is the answer, and the raw bytes between the marks are not only + // the command's — zsh draws its PROMPT_SP (the `%` that marks a missing + // newline, then a row of spaces and a CR) *before* its precmd hooks run, + // so before the `D` mark. Replayed through a grid that erases itself, as + // it does on screen; left raw it is a line of noise after every command. + #[arg( + long, + help = "Print the output as the pane received it, escapes intact, instead of \ + replaying it through a terminal to text (the `capture` default)" + )] + pub raw: bool, + + // Joined with spaces and handed to the pane's shell as one line, which is + // what separates this from `run`: `tty7 exec %3 -- 'make && ls'` means the + // shell's `&&`, where `run` would look for a program called `make && ls`. + #[arg( + last = true, + required = true, + value_name = "CMD", + help = "The command line, after `--`: tty7 exec %3 -- cargo test" + )] + pub cmd: Vec, +} + #[derive(Debug, Args)] #[command(group = ArgGroup::new("axis").required(true))] pub struct SplitArgs { @@ -221,6 +286,34 @@ pub struct SendArgs { (C-c, escape, up, enter, …). See `tty7 send --help`" )] pub keys: Vec, + + // The other place the text can come from. A secret in an argument is in + // this process's command line, where every local user can read it in + // `ps`, and in the caller's own shell history; reading it from a pipe or a + // file keeps it out of both. The bytes go to the pty exactly as read. + #[arg( + long, + conflicts_with_all = ["from_file", "second"], + help = "Send what stdin holds, byte for byte, instead of TEXT — keeps a secret \ + out of `ps` and your shell history" + )] + pub stdin: bool, + + #[arg( + long, + value_name = "PATH", + conflicts_with = "second", + help = "Send the file's contents, byte for byte, instead of TEXT" + )] + pub from_file: Option, + + #[arg( + long, + help = "Send the text as a paste, the way the GUI pastes: framed in bracketed \ + paste when the pane has switched it on, so the lines of a multi-line text \ + arrive as text rather than each running on its own" + )] + pub paste: bool, } /// One resting place a `wait` can end on. Three ontologies meet here, which is @@ -945,4 +1038,54 @@ mod tests { let err = Cli::try_parse_from(["tty7", "tab", "move", "@7", "not-a-number"]).unwrap_err(); assert_eq!(err.exit_code(), 2); } + + #[test] + fn exec_takes_an_address_then_the_command_after_the_separator() { + let Some(Command::Exec(args)) = parse(&[ + "tty7", + "exec", + "%3", + "--timeout", + "5", + "--", + "make", + "--raw", + ]) + .command + else { + panic!("exec did not parse"); + }; + assert_eq!(args.target.as_deref(), Some("%3")); + assert_eq!(args.timeout, Some(5)); + assert!(!args.raw, "after `--` a flag belongs to the command"); + assert_eq!(args.cmd, ["make", "--raw"]); + + let Some(Command::Exec(args)) = parse(&["tty7", "exec", "--", "ls"]).command else { + panic!("exec without an address did not parse"); + }; + assert_eq!(args.target, None, "the pane then comes from $TTY7_PANE"); + + let err = Cli::try_parse_from(["tty7", "exec", "%3"]).unwrap_err(); + assert_eq!(err.exit_code(), 2, "a command is required"); + } + + #[test] + fn send_reads_its_text_from_one_place() { + let Some(Command::Send(args)) = + parse(&["tty7", "send", "%3", "--stdin", "--paste", "--enter"]).command + else { + panic!("send --stdin did not parse"); + }; + assert!(args.stdin && args.paste && args.enter); + assert_eq!(args.first.as_deref(), Some("%3")); + + for bad in [ + vec!["tty7", "send", "%3", "text", "--stdin"], + vec!["tty7", "send", "%3", "text", "--from-file", "f"], + vec!["tty7", "send", "%3", "--stdin", "--from-file", "f"], + ] { + let err = Cli::try_parse_from(&bad).unwrap_err(); + assert_eq!(err.exit_code(), 2, "{bad:?} should be a usage error"); + } + } } diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index e251c486..1cd5f935 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -11,9 +11,10 @@ use tty7_core::daemon::protocol::PROTOCOL_VERSION; use crate::address::{self, Context, WorkspaceAddress}; use crate::backend::{Backend, RunSpec}; use crate::cli::{ - CaptureArgs, Cli, Command, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, SplitArgs, - TabCmd, WaitArgs, WaitState, WsCmd, + CaptureArgs, Cli, Command, ExecArgs, MachineCmd, PaneCmd, RunArgs, SendArgs, ServerCmd, + SplitArgs, TabCmd, WaitArgs, WaitState, WsCmd, }; +use crate::exec::ExecEnd; use crate::output; use crate::resolve; use crate::screen; @@ -78,6 +79,7 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result send(args, ctx, backend), + Some(Command::Exec(args)) => exec(args, ctx, backend), Some(Command::Capture(args)) => capture(args, ctx, backend), Some(Command::Procs { target }) => procs(target.as_deref(), ctx, backend), Some(Command::Tab(TabCmd::Ls { ws })) => tab_ls(ws.as_deref(), ctx, backend), @@ -486,8 +488,6 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu } fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { - const KEY_GAP: Duration = Duration::from_millis(200); - // `--enter` is the same thing as `--key enter`, and predates it. Keeping it // as sugar rather than deprecating it: it reads better for the overwhelming // case, which is typing one command and running it. Going through the same @@ -500,6 +500,43 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { + std::fs::read(path).with_context(|| format!("reading {}", path.display()))? + } + None => { + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut std::io::stdin(), &mut bytes) + .context("reading stdin")?; + bytes + } + }; + return deliver( + pane, + Payload::Piped { + source: if args.stdin { "stdin" } else { "file" }, + bytes: payload, + }, + &args, + pressed, + backend, + ); + } + // Three shapes reach here, and only the address is ever ambiguous: // `send %3 "text"`, `send "text"` (this pane), and — new with --key — // `send %3 --key C-c`, where there is no text at all and the lone @@ -555,9 +592,75 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Payload::Typed(text.to_string()), + None => Payload::None, + }; + deliver(pane, payload, &args, pressed, backend) +} + +/// What `send` has to put in front of the keys. +enum Payload { + None, + /// From the command line — echoed back in `--json`, as it always was. + Typed(String), + /// From stdin or a file. Never echoed: the reason to use these is to keep + /// the bytes out of places other people can read, and a JSON line in a + /// log is one of them. + Piped { + source: &'static str, + bytes: Vec, + }, +} + +fn deliver( + pane: u64, + payload: Payload, + args: &SendArgs, + pressed: Vec, + backend: &mut dyn Backend, +) -> Result { + const KEY_GAP: Duration = Duration::from_millis(200); + + let (source, bytes) = match &payload { + Payload::None => ("none", &[][..]), + Payload::Typed(text) => ("argument", text.as_bytes()), + Payload::Piped { source, bytes } => (*source, &bytes[..]), + }; + if args.paste && bytes.is_empty() { + bail!("--paste needs text to paste — TEXT, --stdin or --from-file"); + } + // `--paste` frames the way the GUI's paste does, which is to ask the pane + // first: brackets for a pane that switched mode 2004 on, and for one that + // did not, the line breaks a keyboard would send. Forcing the brackets on + // a pane that never asked for them would put `ESC[200~` in front of a + // program that reads it as keystrokes. `None` is a server that cannot say. + let mode = if args.paste { + backend.procs(pane)?.context.and_then(|c| c.bracketed_paste) + } else { + None + }; + let bracketed = mode == Some(true); + if args.paste && !bracketed { + eprintln!( + "tty7: pane %{pane} {} bracketed paste, so the text went unframed — each line \ + break was sent as Enter", + if mode.is_none() { + "is on a server too old to say whether it has" + } else { + "has not switched on" + } + ); + } + let wire = if args.paste { + tty7_core::core::paste::paste_bytes(bytes, bracketed) + } else { + bytes.to_vec() + }; + let mut already_wrote = false; - if let Some(text) = text { - backend.send_input(pane, text.as_bytes().to_vec())?; + if !wire.is_empty() { + backend.send_input(pane, wire)?; already_wrote = true; } for key in &pressed { @@ -576,7 +679,18 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Some(text.as_str()), + Payload::None => Some(""), + Payload::Piped { .. } => None, + }, + "source": source, + "bytes": bytes.len(), + "paste": args.paste, + // Whether the paste went framed, and what the pane said about the + // mode — `null` when nothing was asked or the server cannot say. + "bracketed": bracketed, + "bracketed_paste_mode": mode, "enter": args.enter, "keys": pressed.iter().map(|k| k.name.as_str()).collect::>(), }), @@ -594,6 +708,132 @@ fn tried_to_write_an_address(s: &str) -> bool { .is_some_and(|c| c.is_ascii_digit()) } +fn exec(args: ExecArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { + let pane = address::pane_or_context(args.target.as_deref(), ctx)?; + let line = args.cmd.join(" "); + if line.trim().is_empty() { + bail!("exec needs a command after `--`"); + } + // The line is typed, so every byte of it goes through the shell's line + // editor: a newline would submit half of it and a Tab would complete. One + // line of printable text is the only thing that runs as written — the same + // test the GUI applies before it types a line rather than pasting it. + if line.chars().any(char::is_control) { + bail!( + "exec types one line at the shell's prompt, and this one holds a control \ + character (a newline, a tab, an escape) the line editor would act on — join \ + the commands with `;` or `&&`, or put them in a script and exec that" + ); + } + let mut wire = line.clone().into_bytes(); + wire.push(b'\r'); + let started = std::time::Instant::now(); + let run = backend.exec(pane, wire, args.timeout.map(Duration::from_secs))?; + let elapsed_ms = started.elapsed().as_millis() as u64; + + // The caller's own pane is the one pane that can never be at a prompt + // while this runs: its shell is busy running `tty7 exec`. + let own = ctx + .pane + .as_deref() + .and_then(|p| address::parse_pane(p).ok()) + == Some(pane); + match run.end { + ExecEnd::NoIntegration => bail!( + "pane %{pane} has sent no shell-integration prompt marks, so nothing would say \ + when a command there ends — exec needs tty7's shell integration loaded in the \ + pane's shell (`tty7 doctor`); without it, `send … --enter` and \ + `wait --until free` are the way" + ), + ExecEnd::Busy if own => bail!( + "pane %{pane} is this shell, and it is busy running this very command — exec \ + into another pane" + ), + ExecEnd::Busy => bail!( + "pane %{pane} is not at a shell prompt — something is already running there. \ + `tty7 wait %{pane} --until free` waits for it" + ), + _ => {} + } + + let text = if args.raw { + String::from_utf8_lossy(&run.output).into_owned() + } else { + screen::render(&[crate::backend::CaptureSegment { + size: run.size, + bytes: run.output.clone(), + }]) + }; + if run.dropped > 0 { + eprintln!( + "tty7: exec %{pane}: the first {} bytes of output were dropped to keep the rest \ + in memory — the pane's own scrollback may still hold them", + run.dropped + ); + } + let (code, known, timed_out) = match run.end { + ExecEnd::Finished { + exit: Some(code), + ran: true, + } => (code, true, false), + ExecEnd::Finished { + exit: None, + ran: true, + } => { + eprintln!("tty7: {EXIT_CODE_UNKNOWN}"); + (1, false, false) + } + ExecEnd::Finished { ran: false, .. } => { + eprintln!( + "tty7: pane %{pane}: the shell came back to its prompt without running the \ + line — a syntax error, most likely; what it printed is the output" + ); + (1, false, false) + } + ExecEnd::TimedOut { started: true } => { + eprintln!( + "tty7: pane %{pane}: still running after {}s — timed out, and left running \ + (`tty7 send %{pane} --key C-c` interrupts it)", + args.timeout.unwrap_or_default() + ); + (124, false, true) + } + ExecEnd::TimedOut { started: false } => { + eprintln!( + "tty7: pane %{pane}: timed out after {}s, and the shell never started the \ + line — it is most likely waiting on a continuation prompt (an unclosed \ + quote or bracket); `tty7 send %{pane} --key C-c` abandons it", + args.timeout.unwrap_or_default() + ); + (124, false, true) + } + ExecEnd::PaneExited(_) => { + eprintln!("tty7: pane %{pane} exited before its shell came back to a prompt"); + (1, false, false) + } + ExecEnd::NoIntegration | ExecEnd::Busy => unreachable!("refused above"), + }; + // `-q` keeps the exit code and drops the output, which is what the global + // flag already does to any report. + Ok(Outcome::Exit( + code, + Report { + human: text.clone(), + json: json!({ + "pane": pane, + "command": line, + "exit": code, + "exit_code_known": known, + "timed_out": timed_out, + "pane_exited": matches!(run.end, ExecEnd::PaneExited(_)), + "output": text, + "bytes": run.output.len(), + "elapsed_ms": elapsed_ms, + }), + }, + )) +} + fn capture(args: CaptureArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { let pane = address::pane_or_context(args.target.as_deref(), ctx)?; let segments = backend.capture(pane, args.scrollback)?; @@ -3446,6 +3686,7 @@ mod tests { local_pty: true, at_prompt, remote_prompt_seen: false, + bracketed_paste: None, } } @@ -3472,6 +3713,7 @@ mod tests { local_pty: true, at_prompt, remote_prompt_seen, + bracketed_paste: None, }), } } @@ -3493,6 +3735,7 @@ mod tests { local_pty: false, at_prompt, remote_prompt_seen: at_prompt == Some(true), + bracketed_paste: None, }), } } @@ -4472,4 +4715,343 @@ mod tests { backend.control_calls ); } + + fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun { + crate::exec::ExecRun { + end, + output: output.to_vec(), + dropped: 0, + size: segment(b"").size, + } + } + + fn exec_outcome(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> (i32, Report) { + match execute(cli(args), ctx, backend) { + Ok(Outcome::Exit(code, report)) => (code, report), + other => panic!("exec stands in for the command, so it exits: {other:?}"), + } + } + + #[test] + fn exec_types_one_line_and_passes_the_exit_code_through() { + let mut backend = mock(); + backend.exec_run = exec_run( + ExecEnd::Finished { + exit: Some(3), + ran: true, + }, + b"built\r\n", + ); + let (code, report) = exec_outcome( + &[ + "tty7", + "exec", + "%1", + "--raw", + "--timeout", + "30", + "--", + "make", + "&&", + "ls", + ], + &Context::default(), + &mut backend, + ); + assert_eq!(code, 3, "the command's own code, not ours"); + assert_eq!( + backend.execs, + vec![(1, b"make && ls\r".to_vec(), Some(Duration::from_secs(30)))], + "the words are one shell line, typed and submitted" + ); + assert_eq!(report.human, "built\r\n"); + assert_eq!(report.json["exit"], 3); + assert_eq!(report.json["exit_code_known"], true); + assert_eq!(report.json["command"], "make && ls"); + assert_eq!(report.json["output"], "built\r\n"); + assert_eq!(report.json["timed_out"], false); + // Nothing was sent anywhere but through the exec seam. + assert!(backend.sent.is_empty()); + } + + #[test] + fn exec_renders_the_output_the_way_capture_plain_does() { + let mut backend = mock(); + backend.exec_run = exec_run( + ExecEnd::Finished { + exit: Some(0), + ran: true, + }, + b"\x1b[31mred\x1b[0m\r\nprogress 10%\rprogress 100%\r\n", + ); + let (code, report) = exec_outcome( + &["tty7", "exec", "%1", "--", "x"], + &Context::default(), + &mut backend, + ); + assert_eq!(code, 0); + assert!(!report.human.contains('\u{1b}'), "{:?}", report.human); + assert!(report.human.contains("red"), "{:?}", report.human); + assert!(report.human.contains("progress 100%"), "{:?}", report.human); + assert!( + !report.human.contains("progress 10%\n"), + "{:?}", + report.human + ); + } + + #[test] + fn exec_refuses_a_pane_it_cannot_follow_before_typing_anything() { + // The backend sends nothing on these; the verb has to turn them into + // an error that says what to do instead of an exit code that says + // nothing. + let mut backend = mock(); + backend.exec_run = exec_run(ExecEnd::NoIntegration, b""); + let err = execute( + cli(&["tty7", "exec", "%1", "--", "ls"]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("shell-integration"), "{err}"); + assert!(err.contains("wait --until free"), "{err}"); + + let mut backend = mock(); + backend.exec_run = exec_run(ExecEnd::Busy, b""); + let err = execute( + cli(&["tty7", "exec", "%1", "--", "ls"]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("not at a shell prompt"), "{err}"); + + // The caller's own pane can only ever be busy — running this. + let ctx = Context { + pane: Some("%1".into()), + ..Context::default() + }; + let err = execute(cli(&["tty7", "exec", "--", "ls"]), &ctx, &mut backend) + .unwrap_err() + .to_string(); + assert!(err.contains("this shell"), "{err}"); + } + + #[test] + fn exec_refuses_a_line_the_editor_would_act_on() { + for line in ["make\nrm -rf build", "ls\tx", "a\u{1b}b"] { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "exec", "%1", "--", line]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("control"), "{line:?}: {err}"); + assert!(backend.execs.is_empty(), "nothing may be typed: {line:?}"); + } + } + + #[test] + fn exec_reports_every_way_it_can_end_without_an_exit_code() { + let cases = [ + (ExecEnd::TimedOut { started: true }, 124, true, false), + (ExecEnd::TimedOut { started: false }, 124, true, false), + (ExecEnd::PaneExited(Some(0)), 1, false, true), + ( + ExecEnd::Finished { + exit: None, + ran: true, + }, + 1, + false, + false, + ), + ( + ExecEnd::Finished { + exit: None, + ran: false, + }, + 1, + false, + false, + ), + ]; + for (end, want, timed_out, pane_exited) in cases { + let mut backend = mock(); + backend.exec_run = exec_run(end.clone(), b"partial\r\n"); + let (code, report) = exec_outcome( + &["tty7", "exec", "%1", "--raw", "--timeout", "1", "--", "x"], + &Context::default(), + &mut backend, + ); + assert_eq!(code, want, "{end:?}"); + assert_eq!(report.json["exit_code_known"], false, "{end:?}"); + assert_eq!(report.json["timed_out"], timed_out, "{end:?}"); + assert_eq!(report.json["pane_exited"], pane_exited, "{end:?}"); + assert_eq!( + report.human, "partial\r\n", + "what did arrive is still the answer: {end:?}" + ); + } + } + + fn procs_with_paste_mode(mode: Option) -> tty7_core::daemon::protocol::PaneProcs { + tty7_core::daemon::protocol::PaneProcs { + context: Some(tty7_core::daemon::protocol::PaneContext { + local_pty: true, + bracketed_paste: mode, + ..Default::default() + }), + ..Default::default() + } + } + + #[test] + fn send_from_file_sends_the_bytes_verbatim_and_never_echoes_them() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + std::fs::write(&path, b"s3cr\\et\n\xff").unwrap(); + let mut backend = mock(); + let json = json_of(run_cli( + &["tty7", "send", "%1", "--from-file", path.to_str().unwrap()], + &Context::default(), + &mut backend, + )); + assert_eq!(backend.sent, vec![(1, b"s3cr\\et\n\xff".to_vec())]); + assert!( + json["sent"].is_null(), + "the secret must not be echoed: {json}" + ); + assert!(!json.to_string().contains("s3cr"), "{json}"); + assert_eq!(json["source"], "file"); + assert_eq!(json["bytes"], 9); + assert!( + backend.procs_calls.is_empty(), + "without --paste the pane is not asked anything" + ); + + // Keys still follow the payload, the way they follow typed text. + let mut backend = mock(); + run_cli( + &[ + "tty7", + "send", + "%1", + "--from-file", + path.to_str().unwrap(), + "--enter", + ], + &Context::default(), + &mut backend, + ); + assert_eq!( + backend.sent, + vec![(1, b"s3cr\\et\n\xff".to_vec()), (1, b"\r".to_vec())] + ); + } + + #[test] + fn send_from_file_takes_its_positional_as_an_address_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("payload"); + std::fs::write(&path, b"x").unwrap(); + let mut backend = mock(); + let err = execute( + cli(&[ + "tty7", + "send", + "echo", + "--from-file", + path.to_str().unwrap(), + ]), + &Context::default(), + &mut backend, + ) + .unwrap_err(); + let err = format!("{err:#}"); + assert!(err.contains("has to be the pane"), "{err}"); + assert!(backend.sent.is_empty(), "nothing may be typed"); + + // With no address it is this pane, as it is for text. + let ctx = Context { + pane: Some("%3".into()), + ..Context::default() + }; + run_cli( + &["tty7", "send", "--from-file", path.to_str().unwrap()], + &ctx, + &mut backend, + ); + assert_eq!(backend.sent, vec![(3, b"x".to_vec())]); + } + + #[test] + fn send_paste_frames_only_for_a_pane_that_switched_bracketed_paste_on() { + let mut backend = mock(); + backend.procs_reply = procs_with_paste_mode(Some(true)); + let json = json_of(run_cli( + &[ + "tty7", + "send", + "%1", + "a\r\nb\x1b[201~c", + "--paste", + "--enter", + ], + &Context::default(), + &mut backend, + )); + assert_eq!( + backend.sent, + vec![ + (1, b"\x1b[200~a\nb[201~c\x1b[201~".to_vec()), + (1, b"\r".to_vec()) + ], + "the GUI's framing: CRLF folded, ESC stripped so the text cannot close the paste" + ); + assert_eq!(json["bracketed"], true); + assert_eq!(json["bracketed_paste_mode"], true); + + // Off: brackets would reach the program as keystrokes, so the text goes + // the way a paste into such a pane always has — a CR per line break. + let mut backend = mock(); + backend.procs_reply = procs_with_paste_mode(Some(false)); + let json = json_of(run_cli( + &["tty7", "send", "%1", "a\nb", "--paste"], + &Context::default(), + &mut backend, + )); + assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]); + assert_eq!(json["bracketed"], false); + assert_eq!(json["bracketed_paste_mode"], false); + + // A server that cannot say is not taken as a yes. + let mut backend = mock(); + backend.procs_reply = procs_with_paste_mode(None); + let json = json_of(run_cli( + &["tty7", "send", "%1", "a\nb", "--paste"], + &Context::default(), + &mut backend, + )); + assert_eq!(backend.sent, vec![(1, b"a\rb".to_vec())]); + assert!(json["bracketed_paste_mode"].is_null(), "{json}"); + } + + #[test] + fn send_paste_needs_something_to_paste() { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "send", "%1", "--paste", "--enter"]), + &Context::default(), + &mut backend, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("--paste needs text"), "{err}"); + assert!(backend.sent.is_empty()); + } } diff --git a/crates/tty7-cli/src/exec.rs b/crates/tty7-cli/src/exec.rs new file mode 100644 index 00000000..623ab544 --- /dev/null +++ b/crates/tty7-cli/src/exec.rs @@ -0,0 +1,305 @@ +//! Finding one command's output and exit code in a pane's byte stream. +//! +//! `tty7 exec` types a line at a shell's prompt and has to know two things the +//! pty alone never says: where the command's output starts and ends, and how it +//! exited. The shell integration says both, in OSC 133 marks: `C` right before +//! the command runs, `D;` the moment it is over, `A` when the next prompt +//! opens. The daemon already reads those marks — it is what sends `Prompt` — +//! but only as a state, not as a position in the bytes, so the positions are +//! found here, in the same stream the daemon read them from. +//! +//! Which of the two gets the last word is deliberate. The byte marks say +//! *where*; the daemon's `Prompt` report says *when*, because it is the one +//! that has already set aside marks relayed by a program running in the +//! foreground (a nested shell, an `ssh`) — a `D` in the bytes is only the end +//! once the daemon agrees the shell is back at its prompt. + +use tty7_core::core::osc::OscTokenizer; +use tty7_core::daemon::protocol::WinSize; + +/// Output kept past this is dropped from the front: the end of a long build is +/// the part worth reading, and a command that prints without bound must not +/// take the CLI's memory with it. The pane's own scrollback is the same size. +pub const KEEP_OUTPUT: usize = 8 * 1024 * 1024; + +const MARK_D: &[u8] = b"\x1b]133;D"; +const MARK_A: &[u8] = b"\x1b]133;A"; + +/// How an `exec` ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExecEnd { + /// The pane's shell has never sent a prompt mark, so nothing would ever + /// say the command is over. Nothing was sent. + NoIntegration, + /// The pane is not at a prompt — something already runs there. Nothing + /// was sent. + Busy, + /// The shell is back at its prompt. `ran` is false when it came back + /// without running anything — a syntax error, most likely — in which case + /// there is no exit code to report and the output is what the shell said + /// about the line. + Finished { exit: Option, ran: bool }, + /// The deadline passed first. `started` says whether the shell ever + /// began the command: when it did not, the line is most likely sitting on + /// a continuation prompt (an unclosed quote) rather than running. + TimedOut { started: bool }, + /// The pane went away before its shell came back to a prompt. + PaneExited(Option), +} + +/// What an `exec` saw: how it ended, and the output that belongs to it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecRun { + pub end: ExecEnd, + pub output: Vec, + /// Bytes of output dropped from the front to stay under [`KEEP_OUTPUT`]. + pub dropped: usize, + /// The pane's size, which is what `--plain` replays the output at. + pub size: WinSize, +} + +/// The pane's output from the moment the line was sent, and where the marks +/// that bound the command fell in it. +pub struct Transcript { + tok: OscTokenizer, + bytes: Vec, + /// Absolute offset of `bytes[0]` — everything before it was dropped. + base: usize, + /// One past the command's `C` mark: where its output starts. + started: Option, + /// Where the newest `D` after `started` begins: where the output ends. + ended: Option, + /// Where an `A` that arrived with no `C` before it begins — the shell drew + /// a fresh prompt without running the line. + reprompted: Option, + exit_mark: Option, +} + +/// What the daemon's `Prompt` report made of the pane. +pub struct Finished { + pub exit: Option, + pub ran: bool, +} + +impl Default for Transcript { + fn default() -> Transcript { + Transcript::new() + } +} + +impl Transcript { + pub fn new() -> Transcript { + Transcript { + tok: OscTokenizer::new(&[b"133"]), + bytes: Vec::new(), + base: 0, + started: None, + ended: None, + reprompted: None, + exit_mark: None, + } + } + + /// Take the next chunk of the pane's output. + pub fn output(&mut self, chunk: &[u8]) { + let at = self.base + self.bytes.len(); + self.bytes.extend_from_slice(chunk); + let (bytes, base) = (&self.bytes, self.base); + let (mut started, mut ended, mut reprompted, mut exit_mark) = + (self.started, self.ended, self.reprompted, self.exit_mark); + self.tok.feed_at(chunk, |end, payload| { + let end = at + end; + // Where the sequence that just ended began: the newest copy of its + // introducer before its terminator. Payloads carry no ESC, so the + // search cannot land inside a different sequence. + let begin = |intro: &[u8]| { + let upto = end - base; + bytes[..upto] + .windows(intro.len()) + .rposition(|w| w == intro) + .map_or(end, |at| base + at) + }; + match payload.get(4) { + // Only the first: a command that prints a `C` of its own must + // not move the start of its own output. + Some(b'C') if started.is_none() => started = Some(end), + Some(b'D') if started.is_some() => { + ended = Some(begin(MARK_D)); + exit_mark = payload + .strip_prefix(b"133;D;") + .and_then(|c| std::str::from_utf8(c).ok()) + .and_then(|c| c.trim().parse().ok()); + } + Some(b'A') if started.is_none() && reprompted.is_none() => { + reprompted = Some(begin(MARK_A)); + } + _ => {} + } + }); + (self.started, self.ended, self.reprompted, self.exit_mark) = + (started, ended, reprompted, exit_mark); + } + + /// The daemon's report that the pane's shell is (or is not) at a prompt. + /// `Some` once the command is over. + pub fn prompt(&self, at_prompt: bool, last_exit: Option) -> Option { + if !at_prompt { + return None; + } + if self.started.is_some() { + // The byte mark and the report are the same `D`, read twice; the + // report is preferred only because the daemon parsed it first. + return self.ended.map(|_| Finished { + exit: last_exit.or(self.exit_mark), + ran: true, + }); + } + self.reprompted.map(|_| Finished { + exit: None, + ran: false, + }) + } + + /// Whether the shell has begun running the command. + pub fn started(&self) -> bool { + self.started.is_some() + } + + /// The output that belongs to the command, as far as it has got, and how + /// much of it was dropped from the front to keep it bounded. + pub fn take(self) -> (Vec, usize) { + let all = self.base + self.bytes.len(); + let (from, to) = match (self.started, self.ended, self.reprompted) { + (Some(start), Some(end), _) => (start, end), + (Some(start), None, _) => (start, all), + // Never ran: what the shell printed about the line, up to the new + // prompt — the echo of the line itself and whatever error it drew. + (None, _, Some(end)) => (0, end), + (None, _, None) => (0, all), + }; + let to = to.clamp(from, all); + let kept = from.max(self.base).max(to.saturating_sub(KEEP_OUTPUT)); + ( + self.bytes[kept - self.base..to - self.base].to_vec(), + kept - from, + ) + } + + /// Bound the memory held: once twice [`KEEP_OUTPUT`] has piled up, drop + /// all but the last [`KEEP_OUTPUT`]. Only ever what [`take`](Self::take) + /// would not return anyway, which keeps the newest that much. + pub fn compact(&mut self) { + let len = self.bytes.len(); + if len <= KEEP_OUTPUT * 2 { + return; + } + let drop = len - KEEP_OUTPUT; + self.bytes.drain(..drop); + self.base += drop; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What zsh with tty7's integration prints for one `echo hi` typed at its + /// prompt: the echo of the line, `C` with the line, the output, `D` with + /// the exit code, then the next prompt. + const ONE_COMMAND: &[u8] = b"echo hi\r\n\x1b]133;C;echo hi\x07hi\r\n\x1b]133;D;0\x07\ + \x1b]7;file://host/tmp\x07\x1b]133;A\x07% \x1b]133;B\x07"; + + #[test] + fn the_output_is_what_lies_between_c_and_d() { + let mut t = Transcript::new(); + t.output(ONE_COMMAND); + let done = t.prompt(true, Some(0)).expect("D then a prompt is the end"); + assert_eq!((done.exit, done.ran), (Some(0), true)); + assert_eq!(t.take(), (b"hi\r\n".to_vec(), 0)); + } + + #[test] + fn marks_split_across_reads_still_bound_the_output() { + // The pty hands the stream over in whatever chunks it likes, including + // through the middle of a mark. + for split in 1..ONE_COMMAND.len() { + let mut t = Transcript::new(); + t.output(&ONE_COMMAND[..split]); + t.output(&ONE_COMMAND[split..]); + assert!(t.prompt(true, Some(0)).is_some(), "split at {split}"); + assert_eq!(t.take().0, b"hi\r\n".to_vec(), "split at {split}"); + } + } + + #[test] + fn a_prompt_report_before_the_command_has_ended_is_not_the_end() { + let mut t = Transcript::new(); + // The shell has only echoed the line; a prompt redraw (a `B` in PS1 + // repainted by a transient-prompt theme) reports "at a prompt" too. + t.output(b"make\r\n\x1b]133;B\x07"); + assert!(t.prompt(true, None).is_none()); + // Running: the daemon reports "not at a prompt". + t.output(b"\x1b]133;C;make\x07building\r\n"); + assert!(t.prompt(false, None).is_none()); + // A `D` the daemon did not believe — relayed by something running in + // the foreground, reported as "not at a prompt" — ends nothing. + t.output(b"\x1b]133;D;7\x07more\r\n"); + assert!(t.prompt(false, Some(7)).is_none()); + t.output(b"done\r\n\x1b]133;D;2\x07\x1b]133;A\x07$ "); + let done = t.prompt(true, Some(2)).expect("the real end"); + assert_eq!(done.exit, Some(2)); + assert_eq!( + t.take().0, + b"building\r\n\x1b]133;D;7\x07more\r\ndone\r\n".to_vec(), + "the output runs to the D the shell's own prompt followed" + ); + } + + #[test] + fn a_prompt_with_no_command_before_it_means_the_line_never_ran() { + let mut t = Transcript::new(); + t.output(b"echo (\r\nzsh: parse error near `('\r\n\x1b]133;A\x07% "); + let done = t.prompt(true, None).expect("the shell is back"); + assert_eq!((done.exit, done.ran), (None, false)); + assert_eq!( + t.take().0, + b"echo (\r\nzsh: parse error near `('\r\n".to_vec(), + "what the shell said about the line is the only output there is" + ); + } + + #[test] + fn the_exit_code_falls_back_to_the_mark_itself() { + let mut t = Transcript::new(); + t.output(b"\x1b]133;C\x07\x1b]133;D;5\x1b\\"); + let done = t + .prompt(true, None) + .expect("ST terminates a mark as well as BEL"); + assert_eq!(done.exit, Some(5)); + assert!(t.take().0.is_empty()); + } + + #[test] + fn a_timeout_keeps_what_the_command_has_printed_so_far() { + let mut t = Transcript::new(); + t.output(b"sleep 9\r\n\x1b]133;C;sleep 9\x07tick\r\n"); + assert_eq!(t.take().0, b"tick\r\n".to_vec()); + } + + #[test] + fn output_past_the_bound_is_dropped_from_the_front() { + let mut t = Transcript::new(); + t.output(b"\x1b]133;C\x07"); + let chunk = vec![b'x'; KEEP_OUTPUT]; + for _ in 0..3 { + t.output(&chunk); + t.compact(); + } + t.output(b"end\x1b]133;D;0\x07"); + assert!(t.prompt(true, Some(0)).is_some()); + let (out, dropped) = t.take(); + assert_eq!(out.len(), KEEP_OUTPUT); + assert!(out.ends_with(b"end")); + assert_eq!(out.len() + dropped, KEEP_OUTPUT * 3 + 3); + } +} diff --git a/crates/tty7-cli/src/keys.rs b/crates/tty7-cli/src/keys.rs index 6762cf05..545bded9 100644 --- a/crates/tty7-cli/src/keys.rs +++ b/crates/tty7-cli/src/keys.rs @@ -90,6 +90,12 @@ pub fn send_long_help() -> String { when there is none, so `send %42 --enter` runs whatever is already typed in pane 42. \ An unmarked id is not a target for it — `send 83 --enter` is refused, because it \ reads just as much like typing 83 into your own pane; write %83 to mean the pane.\n\n\ + --stdin and --from-file take the text from there instead, byte for byte — the way \ + to send a secret, which as TEXT is visible to every local user in `ps` and lands in \ + your shell history. The one positional is then the pane.\n\n\ + --paste sends the text the way the GUI pastes: framed in bracketed paste when the \ + pane has switched it on, so a multi-line text arrives as one block of text instead \ + of running line by line. --json reports whether it was framed.\n\n\ Keys: {}. Aliases: {}.", vocabulary(), aliases.join(", ") diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs index b4ef3f1b..b9f8a9c9 100644 --- a/crates/tty7-cli/src/main.rs +++ b/crates/tty7-cli/src/main.rs @@ -2,6 +2,7 @@ mod address; mod backend; mod cli; mod commands; +mod exec; mod gui; mod keys; mod output; diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index d06c050e..89f5e70e 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -87,6 +87,22 @@ fn main() { "procs_says_where_the_panes_session_lives", procs_says_where_the_panes_session_lives, ), + ( + "exec_returns_the_commands_output_and_exit_code", + exec_returns_the_commands_output_and_exit_code, + ), + ( + "exec_refuses_a_pane_with_no_prompt_marks", + exec_refuses_a_pane_with_no_prompt_marks, + ), + ( + "send_stdin_delivers_bytes_it_never_echoes", + send_stdin_delivers_bytes_it_never_echoes, + ), + ( + "send_paste_asks_the_pane_for_its_bracketed_paste_mode", + send_paste_asks_the_pane_for_its_bracketed_paste_mode, + ), ]; let mut failed = 0; @@ -1052,3 +1068,210 @@ fn capture_tail_trims_a_real_panes_answer(daemon: &Daemon) { std::thread::sleep(Duration::from_millis(200)); } } + +/// A pane running `program` with no shell in front of it — so no prompt marks +/// and no line editor, just the pty's own echo. What the tests below type into +/// it comes back exactly as the pane received it. +fn bare_pane(daemon: &Daemon, program: &str) -> u64 { + let shell = ShellSpec { + program: program.into(), + args: Vec::new(), + args_are_tty7_defaults: false, + }; + let session = PaneClient::at(daemon.pane_endpoint()) + .spawn( + None, + WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 16, + }, + Some(shell), + Some("bare-pane-e2e".into()), + None, + ) + .unwrap_or_else(|e| panic!("spawn {program}: {e}")); + let pane = session.pane_id(); + session.detach().expect("leave the bare pane running"); + pane +} + +/// Block until the pane's shell has drawn its first prompt: before that there +/// are no marks for `exec` to follow, and it refuses rather than guesses. +fn await_prompt(daemon: &Daemon, address: &str) { + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let procs = daemon.run_json(&["procs", address]); + if procs["context"]["at_prompt"].as_bool() == Some(true) { + return; + } + assert!( + Instant::now() < deadline, + "the shell never reported a prompt; last procs: {procs}" + ); + std::thread::sleep(Duration::from_millis(200)); + } +} + +fn exec_returns_the_commands_output_and_exit_code(daemon: &Daemon) { + // The marks come from tty7's integration for zsh/bash/fish/nushell/pwsh; + // the shell a Windows runner defaults to is not one this can count on. + if cfg!(windows) { + return; + } + let created = daemon.run_json(&["new", &workdir()]); + let pane = created["pane"].as_u64().expect("new prints the pane id"); + let address = format!("%{pane}"); + await_prompt(daemon, &address); + + // `sh -c` so the exit code does not depend on which shell the pane runs. + let out = daemon.run(&[ + "exec", + &address, + "--timeout", + "60", + "--", + "sh", + "-c", + "'echo tty7_e2e_exec_$((6*7)); exit 3'", + ]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(3), + "exec exits with the command's code; stdout {stdout:?}, stderr {:?}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(stdout.contains("tty7_e2e_exec_42"), "{stdout:?}"); + assert!( + !stdout.contains("6*7") && !stdout.contains("exit 3"), + "the typed line is not the command's output: {stdout:?}" + ); + + // The pane is back at its prompt, so it takes the next one straight away. + // `--json` ahead of `--`: after it, it would be an argument to `true`. + let out = daemon.run_ok(&["exec", &address, "--json", "--timeout", "60", "--", "true"]); + let json: serde_json::Value = serde_json::from_str(&out).expect("--json prints JSON"); + assert_eq!(json["exit"], 0, "{json}"); + assert_eq!(json["exit_code_known"], true, "{json}"); + assert_eq!(json["pane"], pane, "{json}"); + assert_eq!( + json["output"], "", + "a command that prints nothing has nothing to show — not the shell's \ + prompt preparation: {json}" + ); +} + +fn exec_refuses_a_pane_with_no_prompt_marks(daemon: &Daemon) { + if cfg!(windows) { + return; + } + let pane = bare_pane(daemon, "/bin/cat"); + let out = daemon.run(&["exec", &format!("%{pane}"), "--", "echo", "never"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains("shell-integration"), "{stderr}"); + // Refused before typing: nothing reached the pane. + let seen = daemon.run_ok(&["capture", &format!("%{pane}"), "--scrollback"]); + assert!(!seen.contains("never"), "{seen:?}"); +} + +fn send_via_stdin(daemon: &Daemon, args: &[&str], input: &[u8]) -> Output { + let mut child = daemon + .cli(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| panic!("could not run tty7 {args:?}: {e}")); + child + .stdin + .take() + .expect("stdin was piped") + .write_all(input) + .expect("feed tty7's stdin"); + child.wait_with_output().expect("tty7 ran to completion") +} + +fn await_capture(daemon: &Daemon, address: &str, marker: &str) -> String { + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let seen = daemon.run_ok(&["capture", address, "--scrollback"]); + if seen.contains(marker) { + return seen; + } + assert!( + Instant::now() < deadline, + "{marker:?} never showed up; last capture:\n{seen}" + ); + std::thread::sleep(Duration::from_millis(200)); + } +} + +fn send_stdin_delivers_bytes_it_never_echoes(daemon: &Daemon) { + if cfg!(windows) { + return; + } + let pane = bare_pane(daemon, "/bin/cat"); + let address = format!("%{pane}"); + let out = send_via_stdin( + daemon, + &["send", &address, "--stdin", "--json"], + b"tty7_e2e_stdin_secret\n", + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "{stdout}{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !stdout.contains("tty7_e2e_stdin_secret"), + "the payload must not come back in the report: {stdout}" + ); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("--json prints JSON"); + assert_eq!(json["source"], "stdin", "{json}"); + assert_eq!(json["bytes"], 22, "{json}"); + await_capture(daemon, &address, "tty7_e2e_stdin_secret"); +} + +fn send_paste_asks_the_pane_for_its_bracketed_paste_mode(daemon: &Daemon) { + if cfg!(windows) { + return; + } + let pane = bare_pane(daemon, "/bin/cat"); + let address = format!("%{pane}"); + + // Off to begin with: nothing in the pane has asked for it. + let json = daemon.run_json(&["send", &address, "tty7_e2e_one\ntty7_e2e_two", "--paste"]); + assert_eq!(json["bracketed_paste_mode"], false, "{json}"); + assert_eq!(json["bracketed"], false, "{json}"); + await_capture(daemon, &address, "tty7_e2e_two"); + + // `cat` hands back what it reads, so the program in the pane is now the + // one that switched mode 2004 on — the daemon sees it in the output. + let out = send_via_stdin(daemon, &["send", &address, "--stdin"], b"\x1b[?2004h\n"); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let procs = daemon.run_json(&["procs", &address]); + if procs["context"]["bracketed_paste"].as_bool() == Some(true) { + break; + } + assert!( + Instant::now() < deadline, + "the pane never reported bracketed paste on: {procs}" + ); + std::thread::sleep(Duration::from_millis(200)); + } + let json = daemon.run_json(&["send", &address, "tty7_e2e_framed", "--paste"]); + assert_eq!(json["bracketed"], true, "{json}"); + // The frame reached the pty: cat's echo of it carries the opener's tail. + let seen = await_capture(daemon, &address, "tty7_e2e_framed"); + assert!(seen.contains("[200~"), "{seen:?}"); +} diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 43a72d97..e2ed7f97 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -13,6 +13,7 @@ pub mod kitty_graphics; pub mod logfile; pub mod machine; pub mod osc; +pub mod paste; pub mod path_spelling; pub mod proc; pub mod session; diff --git a/crates/tty7-core/src/core/paste.rs b/crates/tty7-core/src/core/paste.rs new file mode 100644 index 00000000..692dc32a --- /dev/null +++ b/crates/tty7-core/src/core/paste.rs @@ -0,0 +1,99 @@ +//! What a paste puts on the wire. +//! +//! Lives here rather than beside the GUI's clipboard handling because the GUI +//! is not its only sender: `tty7 send --paste` has to frame a payload exactly +//! the way a paste into the window does, and a second copy of the framing is +//! how the two would come to disagree about what sending multi-line text means. + +/// Opens a bracketed paste (DEC mode 2004). +pub const START: &[u8] = b"\x1b[200~"; +/// Closes one. +pub const END: &[u8] = b"\x1b[201~"; + +/// `payload` inside paste brackets, with every ESC taken out. +/// +/// The stripping is the point, not a cleanup: a payload that carries its own +/// `ESC[201~` would otherwise end the paste early, and whatever follows it +/// would reach the shell as typed input — which is how pasted text turns into +/// commands. No escape sequence survives the strip, so none can close the +/// bracket from inside. +pub fn bracket(payload: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(START.len() + payload.len() + END.len()); + bytes.extend_from_slice(START); + bytes.extend(payload.iter().copied().filter(|&b| b != 0x1b)); + bytes.extend_from_slice(END); + bytes +} + +/// The bytes a paste of `text` sends, for a pane that has (`bracketed`) or has +/// not switched bracketed paste on. +/// +/// CRLF folds to one line break either way, so a Windows clipboard pastes like +/// any other. Framed, the breaks stay `\n` and the shell inserts them as text. +/// Unframed there is no way to say "text" to a pty, so each break goes as the +/// `\r` a keyboard's Enter sends — the lines run one at a time, which is what +/// pasting into a terminal without the mode has always meant. +pub fn paste_bytes(text: &[u8], bracketed: bool) -> Vec { + let mut folded = Vec::with_capacity(text.len()); + let mut i = 0; + while i < text.len() { + if text[i] == b'\r' && text.get(i + 1) == Some(&b'\n') { + folded.push(b'\n'); + i += 2; + } else { + folded.push(text[i]); + i += 1; + } + } + if bracketed { + bracket(&folded) + } else { + for b in &mut folded { + if *b == b'\n' { + *b = b'\r'; + } + } + folded + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_payload_cannot_close_its_own_bracket() { + let out = bracket(b"a\x1b[201~; rm -rf /\nb"); + let inner = &out[START.len()..out.len() - END.len()]; + assert!(out.starts_with(START) && out.ends_with(END)); + assert!(!inner.contains(&0x1b), "{inner:?}"); + assert_eq!(inner, b"a[201~; rm -rf /\nb"); + } + + #[test] + fn crlf_folds_before_either_framing() { + assert_eq!(paste_bytes(b"a\r\nb\n", true), b"\x1b[200~a\nb\n\x1b[201~"); + assert_eq!(paste_bytes(b"a\r\nb\nc", false), b"a\rb\rc"); + } + + #[test] + fn a_lone_cr_is_left_alone() { + assert_eq!(paste_bytes(b"a\rb", true), b"\x1b[200~a\rb\x1b[201~"); + assert_eq!(paste_bytes(b"a\rb", false), b"a\rb"); + } + + #[test] + fn unframed_paste_keeps_escapes() { + // Only the frame needs protecting; without one there is nothing to + // close, and the GUI has always sent these bytes through untouched. + assert_eq!(paste_bytes(b"a\x1b[201~b", false), b"a\x1b[201~b"); + } + + #[test] + fn bytes_that_are_not_utf8_survive() { + assert_eq!( + paste_bytes(b"\xff\xfe", true), + b"\x1b[200~\xff\xfe\x1b[201~" + ); + } +} diff --git a/crates/tty7-core/src/core/term_modes.rs b/crates/tty7-core/src/core/term_modes.rs index b23763df..74bdf6e9 100644 --- a/crates/tty7-core/src/core/term_modes.rs +++ b/crates/tty7-core/src/core/term_modes.rs @@ -41,6 +41,9 @@ const TRACKED: &[u16] = &[ 1, 47, 1047, 1049, 1000, 1002, 1003, 1004, 1005, 1006, 1007, 1015, 1016, 2004, ]; +/// Bracketed paste: whether a paste may arrive framed as `ESC[200~ … ESC[201~`. +pub const BRACKETED_PASTE: u16 = 2004; + /// A CSI longer than this is not a mode set; keep the buffer bounded. const MAX_PARAMS: usize = 64; @@ -85,6 +88,11 @@ impl TerminalModes { &self.on } + /// Whether `mode` is on. Only ever true for a mode in [`TRACKED`]. + pub fn is_on(&self, mode: u16) -> bool { + self.on.contains(&mode) + } + /// The bytes that put a freshly reset terminal back into these modes, or /// `None` when there is nothing to restore. pub fn restore_bytes(&self) -> Option> { @@ -348,4 +356,16 @@ mod tests { modes.feed(b"\x1bc"); assert!(modes.is_empty()); } + + #[test] + fn bracketed_paste_reads_as_the_shell_last_left_it() { + // zle switches it on to read a line and off again before running it, + // so the answer has to follow both edges rather than latch the first. + let mut modes = TerminalModes::new(); + assert!(!modes.is_on(BRACKETED_PASTE)); + modes.feed(b"\x1b[?2004h"); + assert!(modes.is_on(BRACKETED_PASTE)); + modes.feed(b"\x1b[?2004l"); + assert!(!modes.is_on(BRACKETED_PASTE)); + } } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 2a9281f3..5aad6979 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -2272,6 +2272,7 @@ impl DaemonPane { local_pty: matches!(self.backend, PaneBackend::Pty(_)), at_prompt: st.shell.active.then_some(st.shell.mark_at_prompt), remote_prompt_seen: st.remote_prompt_seen, + bracketed_paste: Some(st.modes.is_on(crate::core::term_modes::BRACKETED_PASTE)), } } diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 3f9632f4..9d3517db 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -551,6 +551,15 @@ pub struct PaneContext { /// prompt" on a remote pane means nothing: the newest mark is then the /// near shell's own "I started `ssh`", and it will never be replaced. pub remote_prompt_seen: bool, + /// Whether the pane's output has switched bracketed paste (mode 2004) on, + /// from the same fold the replay restores modes from. `None` from a daemon + /// built before the field existed — "cannot say", not "off". + /// + /// It is here so a client with no terminal of its own — `tty7 send + /// --paste` — can frame a paste the way the GUI does, by asking the pane + /// rather than assuming. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bracketed_paste: Option, } fn default_term() -> String { diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md index 40ead057..dc818b0d 100644 --- a/skills/tty7/SKILL.md +++ b/skills/tty7/SKILL.md @@ -1,7 +1,7 @@ --- name: tty7 description: >- - Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup. + Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, run a command in a shell pane you already have and get back its output and exit code, block until a pane finishes or needs input, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel and merging what they produce); whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup. --- # Driving tty7 from the command line @@ -22,7 +22,7 @@ status hooks are installed, and whether `TTY7_CONFIG_DIR` / `TTY7_WS` / `TTY7_PANE` are set — i.e. whether you are running *inside* a tty7 pane. Being inside a pane matters for two reasons: the address-taking verbs -(`split`, `send`, `capture`, `procs`, `wait`, `pane close`) default to +(`split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`) default to `$TTY7_PANE`, and `run --keep` files its pane into `$TTY7_WS`. Outside a tty7 shell you must name a target explicitly, and the error will say so rather than guessing. @@ -75,7 +75,7 @@ back in. Omitting the address inside a tty7 shell means "this pane" / "this workspace". An explicit address always wins over the environment. -## Running a command: two shapes +## Running a command: three shapes ### Blocking, with a real exit code @@ -95,6 +95,28 @@ and the JSON object last — the combined stream is *not* parseable as JSON, so read the last line. And the pane is 120 columns wide with no way to change it, so output that assumes a wider terminal wraps. +### Blocking, in a pane you already have + +```bash +tty7 exec "$PANE" --timeout 900 -- cargo test # prints its output, exits with its code +``` + +`exec` types the line at the pane's shell prompt — its cwd, its environment, +its history — waits for the prompt to come back, prints what the command +printed (as text, like `capture --plain`; `--raw` keeps the escapes) and exits +with the command's own code. It is how you run the next command in a pane you +set up earlier (a `cd`, an activated venv, an `ssh` session with tty7's shell +integration on the far end) without the send / wait / capture / guess dance. + +What it needs, and says when it doesn't have: the pane's shell must have tty7's +shell integration (a pane that has never sent a prompt mark is refused), and +the pane must be sitting at a prompt (a busy one is refused — `wait --until +free` first). The words after `--` become one shell line, so `&&`, pipes and +redirections work if you quote them: `tty7 exec %3 -- 'make && ./run'`. Always +give `--timeout` when nobody is watching: a line the shell never runs — an +unclosed quote waiting on `dquote>` — has no end to wait for. On timeout it +exits 124 and leaves the command running. + ### Non-blocking: a pane you talk to over time This is the one that makes tty7 worth reaching for. Get a pane, send it work, @@ -135,6 +157,18 @@ for Alt. Repeat it for a sequence; text and keys compose, text first. Typing `^C` as text does nothing — it arrives as two characters; `--key C-c` is the real interrupt. +Two things text on the command line cannot do. **Secrets**: `tty7 send %3 +"$TOKEN"` puts the token in `ps` for every local user and in your shell +history — pipe it instead, `printf %s "$TOKEN" | tty7 send %3 --stdin --enter` +(or `--from-file PATH`); the bytes go to the pane exactly as read and are never +echoed in `--json`. Echo on the far side is the program's business: `docker +login --password-stdin`, `ssh-add` and `gpg` turn it off themselves. +**Multi-line text**: a newline on a pty *is* Enter, so each line runs as it +arrives. `--paste` sends it the way a paste into the window does — framed in +bracketed paste when the pane has switched that on, so a shell or an agent's +input box takes the whole block as text; `--json` reports `"bracketed"` so you +know which it got. + **A brand-new pane can swallow the Enter.** A shell still working through its startup files — a prompt framework, `fastfetch`, anything that paints on login — takes the text you send but loses the carriage return that follows it, and the @@ -176,8 +210,9 @@ whole ring, which for a pane that was never resized is the same thing. `--plain` gives you the screen, and a screen is a rectangle: whatever scrolled past the top of a long build log is gone, and the exit code was never on screen -at all. So when what you want is the *answer* rather than the view, have the -shell write it somewhere clean: +at all. When the pane is a shell sitting at its prompt, `tty7 exec` (above) +hands back both. Otherwise — the pane is busy, or its shell has no tty7 +integration — have the shell write the answer somewhere clean: ```bash tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter @@ -278,7 +313,7 @@ output on success but never suppresses errors. The panes on this machine are the user's real work, and some of them are other coding agents mid-task. Treat anything you did not create as read-only: -- **Never `send` into a pane you didn't open.** Keystrokes into another agent's +- **Never `send` or `exec` into a pane you didn't open.** Keystrokes into another agent's pane, or into a shell the user is typing in, land in the middle of whatever is happening there. Check `tty7 agents` before you touch a pane. This goes double for `--key`: a stray `C-c` kills somebody's work. diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md index 4ed7ac13..9c42c734 100644 --- a/skills/tty7/references/commands.md +++ b/skills/tty7/references/commands.md @@ -30,7 +30,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_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `exec`, `capture`, `procs`, `wait`, `pane close`. | | `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. | @@ -44,13 +44,13 @@ Outside a tty7 shell the address-taking verbs fail with | 0 | success | | 1 | the command failed; the reason is one line on stderr, prefixed `tty7:` | | 2 | usage error (clap) — unknown verb, missing argument, bad type | -| 124 | `tty7 wait` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" | +| 124 | `tty7 wait` or `tty7 exec` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" | | 141 | Unix only: the reader hung up (`| head -1`) and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. | -| *other* | only from `tty7 run`, which passes the child's exit code through | +| *other* | only from `tty7 run` and `tty7 exec`, which pass the command's exit code through | -If `run` cannot learn the child's code it prints a note to stderr and exits 1 -with `"exit_code_known": false` in the JSON — that is how you tell a real 1 -from a stand-in. +If `run` or `exec` cannot learn the command's code it prints a note to stderr +and exits 1 with `"exit_code_known": false` in the JSON — that is how you tell +a real 1 from a stand-in. ## Top-level verbs @@ -85,6 +85,41 @@ the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo. JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed output. The combined stream is not valid JSON; read the last line. +### `tty7 exec [%PANE] [--timeout SECS] [--raw] -- CMD...` +Runs `CMD` in an existing pane's shell and blocks until its prompt comes back: +the words after `--` are joined with spaces and typed at the prompt as one line +followed by Enter, so the shell interprets them — quote `&&`, `|` and `>` to +keep them from your own shell. Prints what the command printed and exits with +its exit code. The pane (default `$TTY7_PANE`) keeps its cwd, environment and +history, which is the difference from `run`: `run` makes a new pane for an +argv, `exec` uses a shell you already have. + +The shell integration's OSC 133 marks bound the command: the output is what the +pane printed between the command starting (`C`) and finishing (`D`, which also +carries the exit code). Refused, with nothing typed: + +- a pane whose shell has sent no prompt mark — no integration, so nothing would + ever say the command ended; `tty7 doctor`, or fall back to `send` + `wait + --until free` +- a pane that is not at a prompt — something already runs there (your own pane + always is: it is running `tty7 exec`) +- a line holding a control character (newline, tab, ESC), which the line editor + would act on rather than type — join commands with `;` / `&&` + +| Flag | | +|---|---| +| `--timeout SECS` | Give up after SECS, exit 124. The command is left running; `send %N --key C-c` stops it. With no timeout it waits for as long as the command runs | +| `--raw` | The output as the pane received it, escapes intact. By default it is replayed through a terminal to text, the way `capture --plain` reads it — which also erases zsh's PROMPT_SP, drawn before the finish mark | + +A line the shell never starts — an unclosed quote waiting on a continuation +prompt — never finishes; the timeout message says that is the likely cause. +A line the shell rejects outright (a syntax error) exits 1 with +`"exit_code_known": false` and the shell's complaint as the output. Output past +8 MiB is dropped from the front, with a note on stderr. + +JSON: `{"pane","command","exit","exit_code_known","timed_out","pane_exited","output","bytes","elapsed_ms"}` +— `output` is the printed form, `bytes` the raw length. + ### `tty7 new [PATH] [--open]` Creates a workspace plus its first tab and shell, at `PATH` if given. Prints the workspace id. JSON: `{"id","pane","opened"}`. @@ -100,7 +135,7 @@ pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the 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]…` +### `tty7 send [%PANE] [TEXT | --stdin | --from-file PATH] [--paste] [--enter] [--key KEY]…` Types `TEXT` into the pane as keystrokes; `--enter` is shorthand for `--key enter` — it appends CR to the text, or presses Enter on its own when there is none, so `tty7 send %42 --enter` runs whatever pane 42 already has typed. With @@ -115,7 +150,28 @@ that still doesn't parse (`%3x`) is an address error, never text for your own pane — while text that merely starts with `%` (`%s/foo/bar/`, `%!sort`) types as given, as does anything unmarked that is not a plain number (`3x`, `+5`). To type an address-shaped string, name the pane as well: `tty7 send %42 %3x`. -JSON: `{"pane","sent","enter","keys"}`. +JSON: `{"pane","sent","source","bytes","paste","bracketed","bracketed_paste_mode","enter","keys"}` +— `source` is `argument`, `stdin`, `file` or `none`. + +`--stdin` / `--from-file PATH` take the text from there instead of an argument +— for a secret, which as an argument sits in `ps` for every local user and in +your shell history. The bytes go to the pane exactly as read (no escape +interpretation, no newline conversion, not required to be UTF-8), and `sent` is +`null` in the JSON so they are not echoed into a log either. With either, the +one positional is the pane and must be a valid address. Use `printf %s`, not +`echo`, unless you mean the trailing newline — on a pty that newline is Enter. +Whether the far side echoes the secret is up to the program reading it, not +tty7: `docker login --password-stdin`, `ssh-add` and `gpg` turn echo off. + +`--paste` sends the text as the GUI sends a paste. When the pane has switched on +bracketed paste (mode 2004 — shells at their prompt and most TUIs do), the text +is wrapped in `ESC[200~ … ESC[201~` with CRLF folded to LF and every ESC byte +stripped, so the text cannot end the paste early; multi-line text then lands as +one block instead of running a line at a time. When the pane has not, the text +goes unframed with each line break sent as Enter — what a paste into such a pane +has always done — and a note goes to stderr. `bracketed` says which was sent; +`bracketed_paste_mode` is what the pane reported (`null` from a server too old +to say). `--enter` after `--paste` submits the pasted block. `--key` presses a key instead of typing characters — the arrow keys a permission prompt wants, the `escape` that closes a TUI, the `C-c` that stops a @@ -196,9 +252,13 @@ 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","addr"}]}`, +JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name","addr"}],"context":{…}}`, where `addr` is the address the socket is bound to (`*`, `0.0.0.0`, `127.0.0.1`, -`[::1]`, or a specific interface). +`[::1]`, or a specific interface). `context` is what the pane says about itself: +`local_pty`, `remote` (the host it is pointed at), `at_prompt` (from its shell +integration's marks; absent until the first one), `remote_prompt_seen`, and +`bracketed_paste` (whether the pane has mode 2004 on — what `send --paste` +reads). 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 diff --git a/src/core/agent_prompt.rs b/src/core/agent_prompt.rs index d1fb5e69..50e4187b 100644 --- a/src/core/agent_prompt.rs +++ b/src/core/agent_prompt.rs @@ -51,9 +51,8 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option } pub fn submit_bytes(prompt: &str) -> Vec { - let mut bytes = b"\x1b[200~".to_vec(); - bytes.extend(prompt.bytes().filter(|&b| b != 0x1b)); - bytes.extend_from_slice(b"\x1b[201~\r"); + let mut bytes = tty7_core::core::paste::bracket(prompt.as_bytes()); + bytes.push(b'\r'); bytes } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index cca61631..4d00bd1f 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -850,15 +850,7 @@ fn ring_system_bell() -> bool { } fn paste_bytes(text: &str, bracketed: bool) -> Vec { - if bracketed { - let text = text.replace("\r\n", "\n"); - let mut bytes = b"\x1b[200~".to_vec(); - bytes.extend(text.bytes().filter(|&b| b != 0x1b)); - bytes.extend_from_slice(b"\x1b[201~"); - bytes - } else { - text.replace("\r\n", "\r").replace('\n', "\r").into_bytes() - } + tty7_core::core::paste::paste_bytes(text.as_bytes(), bracketed) } /// A line the shell can be handed byte for byte, as if it had been typed at its From de15f9b0ab1a048cc0aa29345171dc4e4b47d41e Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:34:38 +0800 Subject: [PATCH 07/20] feat(ssh): keep saved hosts in servers.json and add an SSH tab title setting (#952) Saved SSH hosts and their usage counts move out of config.json into servers.json beside it, so config.json can be synced between machines without carrying a server list (#911). An older config.json is split on first load: servers.json is written first (0600), then only the two keys are removed from config.json, leaving every other key as it was. When both files hold hosts, servers.json wins and the stale copy falls out of config.json at its next save. A servers.json that does not parse is kept aside and blocks saves, as config.json does; hand edits hot-reload. Settings -> Window & Tabs -> SSH tab title (`ssh_tab_title`) pins an SSH tab to the profile name (saved host name, ~/.ssh/config alias, or the address typed for a quick connect) or the hostname, on the OSC title's rung of the existing label ladder: a renamed tab still wins, OSC titles are still tracked, local panes are untouched (#726). --- CHANGELOG.md | 25 ++ crates/tty7-core/src/core/config.rs | 489 +++++++++++++++++++++++++++- docs/customization/settings.mdx | 7 +- docs/reference/configuration.mdx | 26 +- docs/reference/privacy.mdx | 3 +- docs/remote/ssh.mdx | 6 +- docs/window/tabs-and-splits.mdx | 6 + src/main.rs | 11 +- src/terminal/view.rs | 41 ++- src/ui/app.rs | 14 +- src/ui/i18n/en.rs | 10 + src/ui/i18n/ja.rs | 10 + src/ui/i18n/mod.rs | 6 + src/ui/i18n/zh.rs | 10 + src/ui/settings.rs | 40 ++- src/ui/ssh_connect.rs | 111 +++++++ src/ui/tab_strip.rs | 78 +++++ 17 files changed, 852 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d870f6f5..2086e6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **SSH tabs can be named after the host instead of whatever the remote shell + titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is + *Dynamic* (the default, and what tty7 always did), *Profile name* — the saved + host's name, the alias for a `~/.ssh/config` host, the address typed for a + quick connect — or *Hostname*, the address dialled. Only the tab's name is + pinned: OSC 0/2 titles are still tracked and come back the moment it is + *Dynamic* again, a tab you renamed keeps its name, a split tab follows the + pane in front as before, and an ended session still says so. The key is + `ssh_tab_title` (`dynamic`, `profile-name`, `hostname`). + +### Changed + +- **Saved SSH hosts live in `servers.json`, beside `config.json`** (#911), so + `config.json` can be synced between machines for its colours and keys + without carrying a list of servers. `ssh_profiles` and + `ssh_profile_frecency` move across by themselves the first time a new build + reads an older `config.json`: `servers.json` is written first (mode `0600`), + and only once it has landed are the two keys taken out of `config.json` — + nothing else in that file is touched, including keys this build does not + know. When both files hold hosts, `servers.json` wins and the stale copy in + `config.json` is dropped at its next save. A `servers.json` that cannot be + parsed is kept aside as `servers.json.corrupt` and saving is refused until it + is repaired, the same rule `config.json` has. Hand edits to `servers.json` + hot-reload. Passwords and passphrases stay in the OS keychain. + - **`tty7 exec` runs a command in a pane that already exists and hands back its result** (#839). `run` makes a new pane and `send` does not wait, so every script that wanted a command's exit code from a shell it already had ended up diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 07bc28b0..456c7700 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -213,6 +213,11 @@ pub struct Config { pub new_tab_position: NewTabPosition, #[serde(default, deserialize_with = "de_lenient")] pub tab_bar_position: TabBarPosition, + /// What an SSH pane's tab is called (#726). Only the name the tab shows: + /// the titles the remote side sets are still read and kept, and win again + /// the moment this is back on [`SshTabTitle::Dynamic`]. + #[serde(default, deserialize_with = "de_lenient")] + pub ssh_tab_title: SshTabTitle, #[serde(default = "default_sidebar_width")] pub sidebar_width: f32, #[serde(default)] @@ -383,13 +388,21 @@ pub struct Config { #[serde(default)] pub env: HashMap, - #[serde(default)] + /// The saved SSH hosts. They live in [`SERVERS_FILE`], not in + /// `config.json` (#911): people sync `config.json` between machines for its + /// colours and keys, and a list of servers is not something every machine + /// should carry. Still read from `config.json` so [`Config::load`] can + /// move an older file's hosts across, never written back to it. + #[serde(default, skip_serializing)] pub ssh_profiles: Vec, #[serde(default = "default_true")] pub verify_host_keys: bool, #[serde(default)] pub ssh_warn_on_close: bool, - #[serde(default)] + /// How often each saved host is used, keyed by its id — which means + /// nothing without the hosts it counts, so it lives in [`SERVERS_FILE`] + /// beside them. + #[serde(default, skip_serializing)] pub ssh_profile_frecency: HashMap, #[serde(default)] @@ -418,6 +431,45 @@ pub struct Config { /// permanent data loss (#537). Cleared only by a load that parses. #[serde(skip)] pub quarantined: bool, + /// [`SERVERS_FILE`] is there but could not be read or parsed. The hosts + /// in hand are not the ones in it, so [`Config::save`] refuses to run + /// rather than write them over it — the same rule, for the same reason, + /// as [`Self::quarantined`]. + #[serde(skip)] + pub servers_unreadable: bool, +} + +/// Where the saved SSH hosts live, beside `config.json` (#911). +pub const SERVERS_FILE: &str = "servers.json"; + +/// What [`SERVERS_FILE`] holds. The keys are the ones `config.json` used to +/// carry, so a hand-moved block pastes straight across. +#[derive(Debug, Default, PartialEq, Deserialize)] +#[serde(default)] +struct ServersFile { + ssh_profiles: Vec, + ssh_profile_frecency: HashMap, +} + +#[derive(Serialize)] +struct ServersFileRef<'a> { + ssh_profiles: &'a [crate::core::ssh_profile::SshProfile], + ssh_profile_frecency: &'a HashMap, +} + +/// Whether a `config.json` still carries the keys that moved to +/// [`SERVERS_FILE`] — the cue for the one-time move. +#[derive(Deserialize)] +struct LegacyServerKeys { + #[serde(default)] + ssh_profiles: Option, + #[serde(default)] + ssh_profile_frecency: Option, +} + +fn has_legacy_server_keys(text: &str) -> bool { + serde_json::from_str::(strip_bom(text)) + .is_ok_and(|keys| keys.ssh_profiles.is_some() || keys.ssh_profile_frecency.is_some()) } #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -495,6 +547,21 @@ pub enum TabBarPosition { Left, } +/// Where an SSH pane's tab takes its name from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SshTabTitle { + /// Whatever the remote shell or program titles itself, falling back to + /// the host's name until it says anything — what tty7 always did. + #[default] + Dynamic, + /// The saved host's name, the alias for a `~/.ssh/config` host, the + /// address typed for a quick connect. + ProfileName, + /// The address the connection dialled. + Hostname, +} + /// Native window backdrop material for the Windows GUI. Other platforms retain /// the value for config synchronization but do not use it for rendering. /// `Auto` keeps the legacy behavior where theme blur decides between blurred @@ -673,6 +740,7 @@ impl Default for Config { scrollback_limit: 10_000, new_tab_position: NewTabPosition::AfterCurrent, tab_bar_position: TabBarPosition::Left, + ssh_tab_title: SshTabTitle::Dynamic, sidebar_width: default_sidebar_width(), sidebar_collapsed: false, right_panel_visible: false, @@ -727,6 +795,7 @@ impl Default for Config { restore_agent_sessions: true, per_pane_history: false, quarantined: false, + servers_unreadable: false, } } } @@ -773,13 +842,42 @@ impl Config { /// app onto the result needs the outcome to keep a broken file from /// evicting the settings the app is running on. pub fn load_with_outcome() -> (Self, LoadOutcome) { - let Some(path) = Self::path() else { - return (Config::default(), LoadOutcome::Absent); - }; - let text = match std::fs::read_to_string(&path) { + match Self::path() { + Some(path) => Self::load_from(&path), + None => (Config::default(), LoadOutcome::Absent), + } + } + + /// [`Config::load_with_outcome`] for the `config.json` at `path`, with the + /// saved hosts read from the [`SERVERS_FILE`] beside it. + /// + /// A `config.json` from before the split still holds the hosts itself. + /// When there is no [`SERVERS_FILE`] yet they are moved into one, once: + /// the new file is written first and `config.json` rewritten without them + /// only after that has landed, so a failure anywhere leaves them where + /// they were. When both files are there the new one wins and the copy in + /// `config.json` is not read — it is what an older tty7 wrote after the + /// move, or a synced file brought in, and it falls out of `config.json` + /// at its next save. + pub(crate) fn load_from(path: &Path) -> (Self, LoadOutcome) { + let (mut cfg, outcome, text) = Self::load_config_file(path); + let servers = path.with_file_name(SERVERS_FILE); + let found = cfg.load_servers(&servers); + if !found + && let Some(text) = text + && has_legacy_server_keys(&text) + { + cfg.move_servers_out(&text, path, &servers); + } + (cfg, outcome) + } + + /// `config.json` alone, with the text it parsed from. + fn load_config_file(path: &Path) -> (Self, LoadOutcome, Option) { + let text = match std::fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return (Config::default(), LoadOutcome::Absent); + return (Config::default(), LoadOutcome::Absent, None); } Err(e) => { // Unreadable is not unparseable, but the rule is the same: @@ -792,13 +890,13 @@ impl Config { ); let mut cfg = Config::default(); cfg.quarantined = true; - return (cfg, LoadOutcome::Unreadable); + return (cfg, LoadOutcome::Unreadable, None); } }; match serde_json::from_str::(strip_bom(&text)) { Ok(mut cfg) => { cfg.sanitize(); - (cfg, LoadOutcome::Parsed) + (cfg, LoadOutcome::Parsed, Some(text)) } Err(e) => { // The next `save` overwrites this file wholesale, so handing @@ -810,14 +908,83 @@ impl Config { "failed to parse config at {}: {e}; keeping it aside and using defaults", path.display() ); - quarantine(&path); + quarantine(path); let mut cfg = Config::default(); cfg.quarantined = true; - (cfg, LoadOutcome::Quarantined) + (cfg, LoadOutcome::Quarantined, None) } } } + /// Takes the hosts from the [`SERVERS_FILE`] at `path`, if there is one. + /// Returns whether there was — a file that is there but broken counts, so + /// that nothing is moved over the top of it. + fn load_servers(&mut self, path: &Path) -> bool { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return false, + Err(e) => { + log::warn!( + "failed to read {}: {e}; saved SSH hosts unavailable, writes suppressed", + path.display() + ); + self.servers_unreadable = true; + return true; + } + }; + match serde_json::from_str::(strip_bom(&text)) { + Ok(servers) => { + self.ssh_profiles = servers.ssh_profiles; + self.ssh_profile_frecency = servers.ssh_profile_frecency; + } + Err(e) => { + log::warn!( + "failed to parse {}: {e}; keeping it aside, writes suppressed", + path.display() + ); + quarantine(path); + self.servers_unreadable = true; + } + } + true + } + + /// The one-time move of the hosts out of `config.json` (#911). New file + /// first: until it has landed, `config.json` is the only copy. + /// + /// `config.json` is then rewritten from its own `text` with just those + /// two keys taken out, not from `self` the way a save writes it. Nobody + /// asked for this write, so it should change nothing else: a hand-kept + /// file stays the handful of keys it was rather than growing every + /// default, and a key this build does not know — one a newer tty7 on a + /// synced machine wrote — survives. + fn move_servers_out(&self, text: &str, config: &Path, servers: &Path) { + if let Err(e) = self.write_servers(servers) { + log::warn!( + "could not move SSH hosts to {}: {e}; leaving them in config.json", + servers.display() + ); + return; + } + let rewrite = || -> std::io::Result<()> { + let mut value: serde_json::Value = + serde_json::from_str(strip_bom(text)).map_err(std::io::Error::other)?; + if let Some(object) = value.as_object_mut() { + object.remove("ssh_profiles"); + object.remove("ssh_profile_frecency"); + } + let text = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?; + write_atomic(config, text.as_bytes()) + }; + match rewrite() { + Ok(()) => log::info!("moved SSH hosts from config.json to {}", servers.display()), + Err(e) => log::warn!( + "moved SSH hosts to {}, but could not rewrite config.json: {e}", + servers.display() + ), + } + } + /// What a clicked file link should open in, with the pre-setting default /// filled in for a `Config` that never went through [`Self::sanitize`]. pub fn file_open_mode(&self) -> LinkFileOpen { @@ -905,18 +1072,62 @@ impl Config { /// Persist without hiding a failure from an interactive settings editor. pub fn try_save(&self) -> std::io::Result<()> { + let path = Self::path() + .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?; + self.try_save_to(&path) + } + + /// [`Config::try_save`] for the `config.json` at `path`, and the + /// [`SERVERS_FILE`] beside it. + pub(crate) fn try_save_to(&self, path: &Path) -> std::io::Result<()> { if self.quarantined { return Err(std::io::Error::other( "the existing configuration could not be read; repair it before saving", )); } - let path = Self::path() - .ok_or_else(|| std::io::Error::other("configuration directory is unavailable"))?; + if self.servers_unreadable { + return Err(std::io::Error::other(format!( + "{SERVERS_FILE} could not be read; repair it before saving" + ))); + } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } + // Hosts first, for the same reason the move out of `config.json` + // writes them first: a `config.json` that lands without them while + // their own file failed to would have dropped them. + self.write_servers(&path.with_file_name(SERVERS_FILE))?; + self.write_config(path) + } + + fn write_config(&self, path: &Path) -> std::io::Result<()> { let text = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; - write_atomic(&path, text.as_bytes()) + write_atomic(path, text.as_bytes()) + } + + /// Writes the hosts to their own file — unless it already says exactly + /// this, which is nearly every save: a dragged divider has nothing to do + /// with the hosts, and rewriting the file anyway would wake the watcher a + /// second time. No hosts and no file stays no file. + fn write_servers(&self, path: &Path) -> std::io::Result<()> { + let on_disk = match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str::(strip_bom(&text)).ok(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(ServersFile::default()), + Err(_) => None, + }; + if on_disk.as_ref().is_some_and(|held| { + held.ssh_profiles == self.ssh_profiles + && held.ssh_profile_frecency == self.ssh_profile_frecency + }) { + return Ok(()); + } + let text = serde_json::to_string_pretty(&ServersFileRef { + ssh_profiles: &self.ssh_profiles, + ssh_profile_frecency: &self.ssh_profile_frecency, + }) + .map_err(std::io::Error::other)?; + // Host names, users and key paths: nobody else's business. + write_atomic_private(path, text.as_bytes()) } fn path() -> Option { @@ -1436,8 +1647,12 @@ mod tests { last_used: 42, }, ); - let json = serde_json::to_string(&cfg).unwrap(); - let back: Config = serde_json::from_str(&json).unwrap(); + // The count travels in the servers file now (#911), so the round + // trip is through disk rather than through `config.json`'s serde. + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + cfg.try_save_to(&config).unwrap(); + let (back, _) = Config::load_from(&config); assert!(back.ssh_warn_on_close); assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); } @@ -2559,4 +2774,246 @@ mod tests { std::fs::write(exe_dir.path().join(PORTABLE_DATA_DIR), "").unwrap(); assert_eq!(portable_data_dir(exe_dir.path()), None); } + + fn host(name: &str, address: &str) -> crate::core::ssh_profile::SshProfile { + let mut profile = crate::core::ssh_profile::SshProfile::new(name); + profile.host = address.to_string(); + profile.user = "deploy".to_string(); + profile + } + + /// A `config.json` from before #911, holding hosts, a usage count, a key + /// this build has never heard of, and the handful of settings the user + /// actually wrote. + fn legacy_config(profile: &crate::core::ssh_profile::SshProfile) -> String { + let usage = serde_json::json!({ profile.id.to_string(): { "count": 3, "last_used": 5 } }); + serde_json::to_string_pretty(&serde_json::json!({ + "font_size": 18.0, + "a_key_from_a_newer_build": { "x": 1 }, + "ssh_profiles": [profile], + "ssh_profile_frecency": usage, + "keybindings": { "SplitRight": "cmd-d" }, + })) + .unwrap() + } + + #[test] + fn the_hosts_move_out_of_config_json_once_and_nothing_else_moves() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let profile = host("prod-web", "10.0.0.5"); + std::fs::write(&config, legacy_config(&profile)).unwrap(); + + let (cfg, outcome) = Config::load_from(&config); + assert_eq!(outcome, LoadOutcome::Parsed); + assert_eq!(cfg.ssh_profiles, vec![profile.clone()]); + assert_eq!(cfg.ssh_profile_frecency[&profile.id].count, 3); + assert_eq!(cfg.font_size, 18.0); + + let moved: ServersFile = + serde_json::from_str(&std::fs::read_to_string(&servers).unwrap()).unwrap(); + assert_eq!(moved.ssh_profiles, vec![profile.clone()]); + assert_eq!(moved.ssh_profile_frecency[&profile.id].count, 3); + + let left: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + let left = left.as_object().unwrap(); + assert!(!left.contains_key("ssh_profiles")); + assert!(!left.contains_key("ssh_profile_frecency")); + // The rest of the file is exactly what it was: nothing the user did + // not write appears, and nothing this build cannot read disappears. + assert_eq!(left["font_size"], serde_json::json!(18.0)); + assert_eq!( + left["a_key_from_a_newer_build"], + serde_json::json!({ "x": 1 }) + ); + assert_eq!( + left["keybindings"], + serde_json::json!({ "SplitRight": "cmd-d" }) + ); + assert_eq!(left.len(), 3, "the move must not expand the file: {left:?}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&servers).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // Once is once: a second load reads the new file and writes nothing. + let config_after = std::fs::read(&config).unwrap(); + let servers_after = std::fs::read(&servers).unwrap(); + let (again, _) = Config::load_from(&config); + assert_eq!(again.ssh_profiles, vec![profile]); + assert_eq!(std::fs::read(&config).unwrap(), config_after); + assert_eq!(std::fs::read(&servers).unwrap(), servers_after); + } + + #[test] + fn with_both_files_present_the_servers_file_wins() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let current = host("current", "10.0.0.1"); + let stale = host("stale", "10.0.0.2"); + let cfg = Config { + ssh_profiles: vec![current.clone()], + ..Config::default() + }; + cfg.try_save_to(&config).unwrap(); + // An older tty7, or a synced file, puts hosts back in config.json. + std::fs::write(&config, legacy_config(&stale)).unwrap(); + let servers_before = std::fs::read(&servers).unwrap(); + + let (loaded, _) = Config::load_from(&config); + assert_eq!(loaded.ssh_profiles, vec![current.clone()]); + assert!(loaded.ssh_profile_frecency.is_empty()); + assert_eq!(std::fs::read(&servers).unwrap(), servers_before); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&stale), + "loading is not the moment to rewrite config.json" + ); + + // The stale copy falls out at the next ordinary save. + loaded.try_save_to(&config).unwrap(); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profiles"), "{text}"); + assert_eq!(Config::load_from(&config).0.ssh_profiles, vec![current]); + } + + #[test] + fn a_config_json_without_hosts_is_left_alone() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{\"font_size\": 18}").unwrap(); + + let (cfg, _) = Config::load_from(&config); + assert!(cfg.ssh_profiles.is_empty()); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + "{\"font_size\": 18}" + ); + assert!(!dir.path().join(SERVERS_FILE).exists()); + } + + #[test] + fn an_empty_hosts_key_is_dropped_without_making_a_servers_file() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + std::fs::write(&config, "{\"font_size\": 18, \"ssh_profiles\": []}").unwrap(); + + Config::load_from(&config); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profiles"), "{text}"); + assert!(text.contains("font_size"), "{text}"); + assert!(!dir.path().join(SERVERS_FILE).exists()); + } + + #[test] + fn saving_writes_the_hosts_to_their_own_file_and_nowhere_else() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + + // No hosts, no file. + Config::default().try_save_to(&config).unwrap(); + assert!(!servers.exists()); + + let profile = host("prod-web", "10.0.0.5"); + let mut cfg = Config { + ssh_profiles: vec![profile.clone()], + ..Config::default() + }; + cfg.ssh_profile_frecency.insert( + profile.id, + ProfileUsage { + count: 2, + last_used: 9, + }, + ); + cfg.try_save_to(&config).unwrap(); + let text = std::fs::read_to_string(&config).unwrap(); + assert!(!text.contains("ssh_profile"), "{text}"); + assert!(!text.contains("10.0.0.5"), "{text}"); + + let (loaded, _) = Config::load_from(&config); + assert_eq!(loaded.ssh_profiles, vec![profile]); + assert_eq!(loaded.ssh_profile_frecency, cfg.ssh_profile_frecency); + + // Deleting the last host empties the file rather than leaving it be. + let mut emptied = loaded.clone(); + emptied.ssh_profiles.clear(); + emptied.ssh_profile_frecency.clear(); + emptied.try_save_to(&config).unwrap(); + assert!(Config::load_from(&config).0.ssh_profiles.is_empty()); + } + + #[test] + fn a_broken_servers_file_is_kept_aside_and_never_overwritten() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config.json"); + let servers = dir.path().join(SERVERS_FILE); + let profile = host("prod-web", "10.0.0.5"); + std::fs::write(&config, legacy_config(&profile)).unwrap(); + std::fs::write(&servers, "{ nope").unwrap(); + + let (cfg, outcome) = Config::load_from(&config); + assert_eq!(outcome, LoadOutcome::Parsed, "config.json itself is fine"); + assert!(cfg.servers_unreadable); + assert_eq!( + std::fs::read_to_string(servers.with_extension("json.corrupt")).unwrap(), + "{ nope" + ); + // Nothing moved on top of it, and nothing saves over either file. + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&profile) + ); + assert!(cfg.try_save_to(&config).is_err()); + assert_eq!(std::fs::read_to_string(&servers).unwrap(), "{ nope"); + assert_eq!( + std::fs::read_to_string(&config).unwrap(), + legacy_config(&profile) + ); + } + + #[test] + fn the_hosts_never_serialize_into_config_json_but_an_old_file_still_reads() { + let mut cfg: Config = serde_json::from_str( + r#"{"ssh_profiles":[{"name":"a","host":"h"}],"verify_host_keys":false}"#, + ) + .unwrap(); + assert_eq!(cfg.ssh_profiles.len(), 1); + assert!(!cfg.verify_host_keys); + cfg.ssh_profile_frecency + .insert(cfg.ssh_profiles[0].id, ProfileUsage::default()); + let value = serde_json::to_value(&cfg).unwrap(); + assert!(value.get("ssh_profiles").is_none()); + assert!(value.get("ssh_profile_frecency").is_none()); + // The SSH preferences are settings like any other and stay put. + assert_eq!(value["verify_host_keys"], serde_json::json!(false)); + } + + #[test] + fn ssh_tab_title_defaults_to_dynamic_and_round_trips_leniently() { + let cfg: Config = serde_json::from_str("{}").unwrap(); + assert_eq!(cfg.ssh_tab_title, SshTabTitle::Dynamic); + for (raw, mode) in [ + ("\"dynamic\"", SshTabTitle::Dynamic), + ("\"profile-name\"", SshTabTitle::ProfileName), + ("\"hostname\"", SshTabTitle::Hostname), + ("\"sideways\"", SshTabTitle::Dynamic), + ("7", SshTabTitle::Dynamic), + ] { + let cfg: Config = + serde_json::from_str(&format!("{{\"ssh_tab_title\": {raw}, \"font_size\": 20}}")) + .unwrap(); + assert_eq!(cfg.ssh_tab_title, mode, "{raw}"); + assert_eq!(cfg.font_size, 20.0, "a bad value must not cost the file"); + let back: Config = serde_json::from_value(serde_json::to_value(&cfg).unwrap()).unwrap(); + assert_eq!(back.ssh_tab_title, mode); + } + } } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 05f96cf9..eb06a642 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -31,7 +31,8 @@ section something is in. Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard. - Tab position and placement, sidebar grouping, and diff previews. + Tab position and placement, sidebar grouping, diff previews, and what SSH + tabs are called. Hosts, connection defaults, security, authentication, proxies, and forwarding. @@ -69,6 +70,10 @@ Everything the Settings window writes goes to one file: | macOS / Linux | `~/.config/tty7/config.json` | | Windows | `%APPDATA%\tty7\config.json` | +The one exception is your saved SSH hosts, which live beside it in +`servers.json` — so `config.json` can be synced between machines without +carrying your servers. [servers.json →](/reference/configuration#ssh) + Set `TTY7_CONFIG_DIR` to point the whole directory — config, themes, state — somewhere else. diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index aae869b6..33429065 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -92,6 +92,7 @@ their id from the file name. [More about themes →](/customization/themes) |---|---|---|---| | `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). | | `new_tab_position` | enum | `"after-current"` | Or `end`. | +| `ssh_tab_title` | enum | `"dynamic"` | What an SSH tab is called: `dynamic` (the title the remote side sets), `profile-name` (the saved host's name, a `~/.ssh/config` alias, or the address typed for a quick connect), or `hostname` (the address dialled). A tab you renamed keeps its name. | | `sidebar_grouping` | enum | `"repo"` | Or `repo-or-directory` to group non-repo tabs by their folder, or `none` for a flat list. | | `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. | | `sidebar_width` | number | `220` | Pixels (100–2000). | @@ -186,10 +187,28 @@ angle brackets, and a tab: | Key | Type | Default | | |---|---|---|---| -| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. | | `verify_host_keys` | bool | `true` | | | `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. | +### servers.json + +The saved hosts are not in `config.json`: they live in `servers.json` in the +same directory, so `config.json` can be synced between machines without taking +your list of servers along. The file is written with mode `0600` and read as +forgivingly as `config.json`; if it cannot be parsed, tty7 keeps a copy at +`servers.json.corrupt` and saves nothing until it is repaired. + +| Key | Type | Default | | +|---|---|---|---| +| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. | +| `ssh_profile_frecency` | object | `{}` | How often and how recently each host is used, for ranking. Written by the app. | + +An older `config.json` that still holds `ssh_profiles` is split the first time +a newer tty7 reads it: `servers.json` is written first, and only then are the +two keys removed from `config.json`, leaving the rest of it as it was. If both +files hold hosts, `servers.json` wins, and the copy in `config.json` is dropped +the next time settings are saved. + Each object in `ssh_profiles` can also set: | Key | Type | Default | | @@ -209,9 +228,8 @@ Each object in `ssh_profiles` can also set: ## Keys tty7 manages itself -`ssh_profile_frecency` and `command_frecency` record how often and how recently -you use a profile or command, so the pickers can rank them. They are written by -the app; there is no reason to edit them. +`command_frecency` records how often and how recently you run a command, so the +palette can rank it. It is written by the app; there is no reason to edit it. If the file cannot be parsed, tty7 starts on defaults, keeps your original at diff --git a/docs/reference/privacy.mdx b/docs/reference/privacy.mdx index 9b405502..c1de9221 100644 --- a/docs/reference/privacy.mdx +++ b/docs/reference/privacy.mdx @@ -55,6 +55,7 @@ Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies) | | | |---|---| | Settings, themes, window state | `~/.config/tty7/` (`%APPDATA%\tty7\` on Windows) | -| SSH passwords and key passphrases | The **OS keychain** — never `config.json`, never plain text on disk | +| Saved SSH hosts | `/servers.json`, mode `0600` on Unix. Addresses, users and key paths — no secrets. | +| SSH passwords and key passphrases | The **OS keychain** — never `config.json` or `servers.json`, never plain text on disk | | Pane scrollback tails | `/scrollback/*.bin`, mode `0600` on Unix and behind the config directory's ACL on Windows. 256 KiB per pane, kept only until something can no longer ask for it: closing a pane deletes its file at once, a restore consumes it, and a periodic pass collects the rest. | | Shell history | Your shell's own file, exactly as before — unless you turned on per-pane history, which merges back into it. | diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx index 138d5b96..681e8bb2 100644 --- a/docs/remote/ssh.mdx +++ b/docs/remote/ssh.mdx @@ -75,8 +75,10 @@ on the tab it was opened on, not on whichever pane is focused. Saving one opens on the whole live connection — its proxy, keys and forwards as well as its address — so the host that lands is the one you were already on. -Passwords and key passphrases go in the **OS keychain**, never in -`config.json` and never on disk in plain text. **Forget Password** in a +Saved hosts are kept in `servers.json`, beside `config.json` rather than in +it, so a `config.json` you sync between machines does not carry them. +Passwords and key passphrases go in the **OS keychain**, never in either file +and never on disk in plain text. **Forget Password** in a profile's menu removes the stored one. Deleting a profile drops its keychain credentials and forgets the remote diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx index dea2382a..7a6ef61f 100644 --- a/docs/window/tabs-and-splits.mdx +++ b/docs/window/tabs-and-splits.mdx @@ -23,6 +23,12 @@ A new tab always opens in the current pane's directory. Where it lands in the list is **Settings → Window & Tabs → New tab position** — *After current* by default, or *At end*. +An SSH tab is named by whatever the remote shell titles itself, like any other. +To keep it on the host instead, set **Settings → Window & Tabs → SSH tab +title** to *Profile name* — the saved host's name, the `~/.ssh/config` alias, or +the address you typed — or *Hostname*. A tab you renamed keeps its name either +way, and a split tab still follows the pane in front. + Right-click a tab for the rest: rename, split right or down, a new worktree tab, copy the working directory, copy the session id, close · close others · close to the right, and — when a coding agent is running there — mark unread and fork its diff --git a/src/main.rs b/src/main.rs index 76c9559d..7c9f73a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,10 +48,13 @@ fn spawn_config_watcher(cx: &mut App) { if !tty7_core::host::is_content_change(&event.kind) { return; } - let hit = event - .paths - .iter() - .any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p)); + // The saved SSH hosts live beside config.json in a file of their own + // (#911), and a hand edit there has to land the same way. + let hit = event.paths.iter().any(|p| { + p.file_name() == watched_file.file_name() + || p.file_name() == Some(std::ffi::OsStr::new(crate::core::config::SERVERS_FILE)) + || is_theme_file(p) + }); if hit { let _ = tx.try_send(()); } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 4d00bd1f..5c810f08 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1766,6 +1766,35 @@ impl TerminalView { stated_title(&self.title) } + /// The title this pane gives its tab: [`Self::stated_title`], unless this + /// is an SSH pane and Settings pins its tab to the host's name instead + /// (#726). The pane's own title is untouched either way — OSC 0/2 keep + /// landing in it, and it is back on the tab the moment the setting is. + /// + /// A pane that has ended still says so: the pinned name takes the same + /// suffix the pane's own title would have. + pub(crate) fn tab_title(&self, cx: &App) -> Option { + let pinned = self.ssh_spec.as_deref().and_then(|spec| { + let cfg = cx.try_global::()?; + crate::ui::ssh_connect::pinned_ssh_title(cfg.ssh_tab_title, spec, &cfg.ssh_profiles) + }); + match pinned { + Some(name) if self.terminal.exited => Some(self.ended_title(&name)), + Some(name) => Some(name), + None => self.stated_title().map(str::to_string), + } + } + + /// `name` with the suffix that says how this pane ended. + fn ended_title(&self, name: &str) -> String { + let key = if self.workspace().is_some() && !self.terminal.child_exited() { + L10nKey::PaneTitleDisconnected + } else { + L10nKey::PaneTitleProcessExited + }; + t_fmt(key, &[("title", name)]) + } + /// Sets how opaque the pane wants this terminal painted; the pane leaf /// calls this every frame while rendering, and the terminal element /// blends its colours toward the window background during paint (see @@ -2249,17 +2278,7 @@ impl TerminalView { self.pending_title = None; // 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)], - ) - }; + self.title = self.ended_title(&self.default_title); if self.terminal.child_exited() { cx.emit(ChildExited); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 51e5b132..791a0162 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -612,7 +612,10 @@ impl Tab { // name tty7 chose for it deliberately, and `stated_title` hands // those up as the title the pane is showing. title: crate::terminal::view::DEFAULT_TITLE.to_string(), - osc_title: leaf.stated_title().map(str::to_string), + // On the title's rung, not a new one above it: a name given to the + // tab still beats an SSH host pinned by Settings (#726), and the + // pinned host beats everything the pane could say about itself. + osc_title: leaf.tab_title(cx), cwd: leaf.cwd().map(|p| p.display().to_string()), agent: leaf.agent(), status: leaf.agent_session().map(|s| s.status), @@ -3461,6 +3464,14 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on); } + pub(crate) fn set_ssh_tab_title( + &mut self, + mode: crate::core::config::SshTabTitle, + cx: &mut Context, + ) { + self.update_config(cx, |cfg| cfg.ssh_tab_title = mode); + } + pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context) { let next = match cx.global::().tab_bar_position { TabBarPosition::Top => TabBarPosition::Left, @@ -5819,6 +5830,7 @@ impl Tty7App { L10nKey::SettingsDiffPreviewFromCounts => { self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx) } + L10nKey::SettingsSshTabTitle => self.set_ssh_tab_title(defaults.ssh_tab_title, cx), L10nKey::SettingsNotifyOnCommandFinish => { self.set_notify_mode(defaults.notify_on_command_finish, cx) } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6013f4e6..63778e02 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -620,6 +620,13 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsDiffPreviewFromCountsDesc => { "Click a row's +N −N to open the working-tree diff in an overlay. Off leaves the counts visible, just not clickable." } + L10nKey::SettingsSshTabTitle => "SSH tab title", + L10nKey::SettingsSshTabTitleDesc => { + "What an SSH tab is called. Dynamic follows the title the remote side sets; the other two pin the tab to the host. A tab you renamed keeps its name." + } + L10nKey::SettingsSshTabTitleDynamic => "Dynamic", + L10nKey::SettingsSshTabTitleProfileName => "Profile name", + L10nKey::SettingsSshTabTitleHostname => "Hostname", L10nKey::DocumentDock => "Dock beside terminal", L10nKey::DocumentFill => "Fill window", L10nKey::SettingsNotifications => "Notifications", @@ -943,6 +950,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchSidebarGroupingKeywords => { "tabs group repo repository git scratch header sidebar flat folder directory cwd" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "ssh tab title name host hostname profile alias pin fixed osc remote" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "double click word url path select semantic bracket email" } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 028e45a5..e49d05a6 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -628,6 +628,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "行の +N −N をクリックすると、オーバーレイでワーキングツリーの Diff を開きます。オフならカウントは表示されたまま、クリックだけできません" } + L10nKey::SettingsSshTabTitle => "SSH タブのタイトル", + L10nKey::SettingsSshTabTitleDesc => { + "SSH タブに表示する名前。「動的」はリモート側が設定したタイトルに従い、ほかの二つはタブをホストに固定します。名前を変更したタブはその名前のまま" + } + L10nKey::SettingsSshTabTitleDynamic => "動的", + L10nKey::SettingsSshTabTitleProfileName => "プロファイル名", + L10nKey::SettingsSshTabTitleHostname => "ホスト名", L10nKey::DocumentDock => "ターミナルの隣にドック", L10nKey::DocumentFill => "ウィンドウ全体", L10nKey::SettingsNotifications => "通知", @@ -1005,6 +1012,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchSidebarGroupingKeywords => { "タブ グループ リポジトリ git スクラッチ ヘッダー サイドバー フラット フォルダ ディレクトリ sidebar grouping tabs repo repository git scratch header flat folder directory" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "ダブルクリック 単語 url パス 選択 セマンティック 括弧 メール smart selection double click word url path bracket email" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index b8b60db2..45903235 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -498,6 +498,11 @@ l10n_keys! { SettingsSidebarGroupingDesc, SettingsDiffPreviewFromCounts, SettingsDiffPreviewFromCountsDesc, + SettingsSshTabTitle, + SettingsSshTabTitleDesc, + SettingsSshTabTitleDynamic, + SettingsSshTabTitleProfileName, + SettingsSshTabTitleHostname, DocumentDock, DocumentFill, SettingsNotifications, @@ -685,6 +690,7 @@ l10n_keys! { SettingsSearchScrollbackKeywords, SettingsSearchShowTrayIconKeywords, SettingsSearchSidebarGroupingKeywords, + SettingsSearchSshTabTitleKeywords, SettingsSearchSmartSelectionKeywords, SettingsSearchStartInKeywords, SettingsSearchSyncWithSystemKeywords, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index f9c59eb0..d27a7f7f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -549,6 +549,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiffPreviewFromCountsDesc => { "点击行上的 +N −N 在浮层中打开 worktree diff。关闭后计数仍显示,只是不可点击。" } + L10nKey::SettingsSshTabTitle => "SSH 标签页标题", + L10nKey::SettingsSshTabTitleDesc => { + "SSH 标签页显示的名字。“动态”跟随远端设置的标题;另外两项把标签页固定为主机。手动重命名过的标签页保留其名字。" + } + L10nKey::SettingsSshTabTitleDynamic => "动态", + L10nKey::SettingsSshTabTitleProfileName => "配置名称", + L10nKey::SettingsSshTabTitleHostname => "主机名", L10nKey::DocumentDock => "停靠在终端旁", L10nKey::DocumentFill => "铺满窗口", L10nKey::SettingsNotifications => "通知", @@ -896,6 +903,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchSidebarGroupingKeywords => { "侧栏分组 标签页 分组 仓库 git 侧栏 文件夹 目录 sidebar grouping tabs repo repository folder directory" } + L10nKey::SettingsSearchSshTabTitleKeywords => { + "SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin" + } L10nKey::SettingsSearchSmartSelectionKeywords => { "智能选择 双击 选择 单词 URL 路径 邮箱 括号 smart selection double click" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 6e66e41e..d1361975 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, - TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, + SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, }; use crate::core::keychain::{ CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, @@ -839,6 +839,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsDiffPreviewFromCounts, keywords: SettingsSearchDiffPreviewFromCountsKeywords, }, + SearchEntry { + section: WindowTabs, + title: SettingsSshTabTitle, + keywords: SettingsSearchSshTabTitleKeywords, + }, SearchEntry { section: General, title: SettingsNotifyOnCommandFinish, @@ -898,6 +903,7 @@ impl SearchEntry { L10nKey::SettingsTabBarPosition => "tab_bar_position", L10nKey::SettingsSidebarGrouping => "sidebar_grouping", L10nKey::SettingsDiffPreviewFromCounts => "sidebar_diff_preview", + L10nKey::SettingsSshTabTitle => "ssh_tab_title", L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish", L10nKey::SettingsNotifyThreshold => "notify_threshold_secs", L10nKey::SettingsTerminalBell => "bell", @@ -1007,6 +1013,7 @@ impl SearchEntry { L10nKey::SettingsTabBarPosition => t(L10nKey::SettingsTabBarPositionDesc), L10nKey::SettingsSidebarGrouping => t(L10nKey::SettingsSidebarGroupingDesc), L10nKey::SettingsDiffPreviewFromCounts => t(L10nKey::SettingsDiffPreviewFromCountsDesc), + L10nKey::SettingsSshTabTitle => t(L10nKey::SettingsSshTabTitleDesc), L10nKey::SettingsNotifyOnCommandFinish => t(L10nKey::SettingsNotifyOnCommandFinishDesc), L10nKey::SettingsNotifyThreshold => t(L10nKey::SettingsNotifyThresholdDesc), L10nKey::SettingsAppHttpProxy => t(L10nKey::SettingsAppHttpProxyDesc), @@ -1050,6 +1057,7 @@ impl SearchEntry { L10nKey::SettingsDiffPreviewFromCounts => { cfg.sidebar_diff_preview != defaults.sidebar_diff_preview } + L10nKey::SettingsSshTabTitle => cfg.ssh_tab_title != defaults.ssh_tab_title, L10nKey::SettingsNotifyOnCommandFinish => { cfg.notify_on_command_finish != defaults.notify_on_command_finish } @@ -7659,6 +7667,11 @@ impl Tty7App { TabBarPosition::Left => 1, }; let sidebar_diff_preview = cfg.sidebar_diff_preview; + let ssh_tab_title_idx = match cfg.ssh_tab_title { + SshTabTitle::Dynamic => 0, + SshTabTitle::ProfileName => 1, + SshTabTitle::Hostname => 2, + }; let sidebar_grouping_idx = match cfg.sidebar_grouping { crate::core::config::SidebarGrouping::Repo => 0, crate::core::config::SidebarGrouping::RepoOrDirectory => 1, @@ -7790,6 +7803,25 @@ impl Tty7App { }, ); + let ssh_tab_title_radio = self.segmented( + "wt-ssh-tab-title", + &[ + t(L10nKey::SettingsSshTabTitleDynamic), + t(L10nKey::SettingsSshTabTitleProfileName), + t(L10nKey::SettingsSshTabTitleHostname), + ], + ssh_tab_title_idx, + cx, + |this, ix, _w, cx| { + let mode = match ix { + 0 => SshTabTitle::Dynamic, + 1 => SshTabTitle::ProfileName, + _ => SshTabTitle::Hostname, + }; + this.set_ssh_tab_title(mode, cx); + }, + ); + v_flex() .when(general, |v| { v.child(self.section_header(t(L10nKey::SettingsWindow), cx)) @@ -7845,6 +7877,12 @@ impl Tty7App { sidebar_diff_switch, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsSshTabTitle), + t(L10nKey::SettingsSshTabTitleDesc), + ssh_tab_title_radio, + cx, + )) }) .when(general, |v| { v.child(self.section_rule(cx)) diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index c780c7bd..533ba60f 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -461,6 +461,38 @@ fn build_spec_inner( } } +/// The name an SSH pane's tab is pinned to under `mode` (#726), or `None` for +/// [`SshTabTitle::Dynamic`], which leaves the tab to the title the pane is +/// showing. +/// +/// Built on the same evidence as the name the pane wears before the remote +/// side speaks — [`NativeSshSpec::display_name`], set above — so the pinned +/// name and that one only ever differ where the saved host has been renamed +/// since the pane dialled: the live name wins there, looked up by +/// `profile_id`. An alias from `~/.ssh/config` and a quick connect are not +/// saved hosts, and their `display_name` is the alias and the address typed. +pub(crate) fn pinned_ssh_title( + mode: crate::core::config::SshTabTitle, + spec: &NativeSshSpec, + profiles: &[SshProfile], +) -> Option { + use crate::core::config::SshTabTitle; + + let non_blank = |s: &str| Some(s.trim().to_string()).filter(|s| !s.is_empty()); + match mode { + SshTabTitle::Dynamic => None, + SshTabTitle::ProfileName => spec + .profile_id + .as_deref() + .and_then(|id| Uuid::parse_str(id).ok()) + .and_then(|id| profiles.iter().find(|p| p.id == id)) + .and_then(|p| non_blank(&p.name)) + .or_else(|| spec.display_name.as_deref().and_then(non_blank)) + .or_else(|| non_blank(&spec.host)), + SshTabTitle::Hostname => non_blank(&spec.host), + } +} + /// What a tab's host row opens when it is taken. /// /// The two halves are not the same form. A saved host is already a record, so @@ -1061,4 +1093,83 @@ mod tests { SshProxy::Http { .. } )); } + + /// #726's three sources under each mode: the saved host's own name, the + /// alias for a `~/.ssh/config` host, and the address for a quick connect; + /// `Hostname` always the address dialled, and `Dynamic` nothing at all. + #[test] + fn a_pinned_ssh_title_comes_from_the_source_the_pane_was_opened_from() { + use crate::core::config::SshTabTitle::{Dynamic, Hostname, ProfileName}; + let store = InMemoryCredentialStore::new(); + + let mut saved = profile("prod-web", "10.0.0.5", "deploy"); + let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true); + let profiles = vec![saved.clone()]; + assert_eq!(pinned_ssh_title(Dynamic, &spec, &profiles), None); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("prod-web") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("10.0.0.5") + ); + // Renamed after it dialled: the tab follows the saved host. + saved.name = "prod-api".into(); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &[saved.clone()]).as_deref(), + Some("prod-api") + ); + // A saved host nobody named reads as its address. + saved.name = " ".into(); + let spec = build_native_ssh_spec(&saved, std::slice::from_ref(&saved), &store, true); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &[saved]).as_deref(), + Some("deploy@10.0.0.5") + ); + + let root = tempfile::tempdir().unwrap(); + let ssh = root.path().join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + "Host bastion\n HostName 192.168.1.9\n User ops\n", + ) + .unwrap(); + let alias = crate::core::ssh_config::resolve_alias_to_profile_from( + ssh.join("config"), + root.path(), + "bastion", + ) + .unwrap(); + let spec = native_spec_from_transient_profile( + &alias.profile, + alias.proxy_jump, + &store, + true, + &|_| None, + ); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("bastion") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("192.168.1.9") + ); + + // What `quick_connect` builds for an address that is no alias. + let mut typed = SshProfile::new("10.0.0.7"); + typed.host = "10.0.0.7".into(); + typed.user = "root".into(); + let spec = build_native_ssh_spec(&typed, &profiles, &store, true); + assert_eq!( + pinned_ssh_title(ProfileName, &spec, &profiles).as_deref(), + Some("10.0.0.7") + ); + assert_eq!( + pinned_ssh_title(Hostname, &spec, &profiles).as_deref(), + Some("10.0.0.7") + ); + } } diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index a7d38484..769ea1cc 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -2434,6 +2434,84 @@ mod ssh_host_row_tests { assert_eq!(spec.port, 2222, "a non-default port is part of the address"); }); } + + /// #726 end to end: the setting reaches the name the strip draws, on the + /// title's rung — below a name the user gave the tab, above whatever the + /// remote shell titled itself — and only for an SSH pane. + #[gpui::test] + fn the_ssh_tab_title_setting_pins_only_ssh_tabs_and_only_their_label(cx: &mut TestAppContext) { + use crate::core::config::SshTabTitle; + + set_locale("en"); + let (app, mut vcx) = harness(cx); + let saved = uuid::Uuid::new_v4(); + let set_mode = |cx: &mut gpui::App, mode: SshTabTitle| { + let mut cfg = cx.global::().clone(); + cfg.ssh_tab_title = mode; + cx.set_global(cfg); + }; + + let (_ends, ssh) = app.update_in(&mut vcx, |app, window, cx| { + let mut cfg = cx.global::().clone(); + let mut profile = SshProfile::new("prod-web"); + profile.id = saved; + profile.user = "me".to_string(); + profile.host = "build-box".to_string(); + cfg.ssh_profiles = vec![profile]; + cx.set_global(cfg); + + let (local, a) = quiet_test_pane(1, window, cx); + let (ssh, b) = quiet_test_ssh_pane_of(2, Some(saved), window, cx); + // Both have titled themselves over OSC 0/2 the way a shell does. + local.update(cx, |v, _| v.title = "me@laptop:/work/here".into()); + ssh.update(cx, |v, _| v.title = "me@build-box:/srv/app".into()); + for view in [local, ssh.clone()] { + app.tabs.push(Tab::new(Pane::leaf(PaneSlot::Ready(view)))); + } + app.active = 0; + ((a, b), ssh) + }); + vcx.background_executor.run_until_parked(); + + app.update_in(&mut vcx, |app, window, cx| { + let label = + |app: &crate::ui::app::Tty7App, i: usize, window: &gpui::Window, cx: &gpui::App| { + app.tab_label(&app.tabs[i], i, Some(window), cx) + }; + let dynamic_local = label(app, 0, window, cx); + let dynamic_ssh = label(app, 1, window, cx); + assert_eq!(dynamic_ssh, "/srv/app", "Dynamic is what tty7 always did"); + + set_mode(cx, SshTabTitle::ProfileName); + assert_eq!(label(app, 1, window, cx), "prod-web"); + assert_eq!( + label(app, 0, window, cx), + dynamic_local, + "a local tab is untouched" + ); + assert_eq!( + ssh.read(cx).title, + "me@build-box:/srv/app", + "the remote title is still tracked underneath" + ); + + set_mode(cx, SshTabTitle::Hostname); + assert_eq!(label(app, 1, window, cx), "build-box"); + + // A name the user gave the tab outranks the setting. + app.tabs[1].name = Some("mine".into()); + assert_eq!(label(app, 1, window, cx), "mine"); + app.tabs[1].name = None; + + // An ended session keeps saying so under the pinned name. + ssh.update(cx, |v, _| v.terminal.exited = true); + assert_eq!(label(app, 1, window, cx), "build-box — process exited"); + ssh.update(cx, |v, _| v.terminal.exited = false); + + set_mode(cx, SshTabTitle::Dynamic); + assert_eq!(label(app, 1, window, cx), dynamic_ssh); + }); + } } #[cfg(test)] From 9f034558c0166e86974a74e95aff26d0d44c23c3 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:38:00 +0800 Subject: [PATCH 08/20] feat(ssh): switch a port forward off without losing its rule (#953) A forward could only be removed, so pointing one local port at another remote target meant deleting the rule and retyping the other one (#439). Forward rules gain an `enabled` flag (serde default on, so saved profiles and older peers keep every rule live) and the daemon a SetForwardEnabled op for panes and workspaces. Off releases the listener and keeps the entry; on rebinds it from the rule it was made from, and is refused by name when another switched-on forward of the same owner holds the port. The Ports panel and the Settings rules editor each get a switch; a switch flipped in the panel on a rule from a saved host is written back to that host. Closes #439 --- CHANGELOG.md | 12 + assets/icons/power.svg | 1 + crates/tty7-core/src/core/ssh_profile.rs | 31 ++ crates/tty7-core/src/daemon/protocol.rs | 91 +++++ crates/tty7-core/src/daemon/server.rs | 25 ++ crates/tty7-core/src/daemon/ssh/forward.rs | 402 ++++++++++++++++++- crates/tty7-core/src/daemon/ssh/mod.rs | 13 + crates/tty7-core/src/daemon/ssh/workspace.rs | 7 + docs/remote/port-forwarding.mdx | 18 + src/core/ssh_config.rs | 1 + src/terminal/remote.rs | 23 ++ src/ui/app.rs | 122 +++++- src/ui/assets.rs | 1 + src/ui/forwards.rs | 111 ++++- src/ui/i18n/en.rs | 4 + src/ui/i18n/ja.rs | 4 + src/ui/i18n/mod.rs | 4 + src/ui/i18n/zh.rs | 4 + src/ui/right_panel.rs | 35 +- src/ui/settings.rs | 32 +- src/ui/ssh_connect.rs | 13 +- 21 files changed, 929 insertions(+), 25 deletions(-) create mode 100644 assets/icons/power.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 2086e6ca..8fa5974d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A port forward can be switched off without losing its rule** (#439). A + forward could only be removed, so pointing one local port at a different + remote target meant deleting the rule and typing the other one in again. Each + forward in the Ports section, and each rule under Settings → SSH → Port + forwarding, now has a switch: off releases the listener and keeps the rule in + the list, on binds it again from the rule it was made from. Switching one on + while another switched-on forward holds its port is refused with a notice + naming that forward, rather than failing at bind time. A switch flipped in the + panel on a rule that came from a saved host is written back to that host, so + the next connection opens the same set; a rule saved switched off is listed + but not opened. Profiles saved before this load with every rule on. + - **SSH tabs can be named after the host instead of whatever the remote shell titles itself** (#726). **Settings → Window & Tabs → SSH tab title** is *Dynamic* (the default, and what tty7 always did), *Profile name* — the saved diff --git a/assets/icons/power.svg b/assets/icons/power.svg new file mode 100644 index 00000000..16fc83f7 --- /dev/null +++ b/assets/icons/power.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 97a43d5c..651aaea0 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -153,6 +153,16 @@ pub struct ForwardRule { pub bind: HostPort, pub target: HostPort, pub description: String, + /// Whether the rule is opened with the connection. A switched-off rule + /// keeps its whole configuration and simply binds nothing, so one local + /// port can be pointed at different targets by switching rules rather + /// than retyping them. + /// + /// Missing from every profile saved before the switch existed, and those + /// rules were all live: the container's `#[serde(default)]` fills it from + /// `Default`, which says `true`, or upgrading would silently turn every + /// saved forward off. + pub enabled: bool, } impl Default for ForwardRule { @@ -162,6 +172,7 @@ impl Default for ForwardRule { bind: HostPort::default(), target: HostPort::default(), description: String::new(), + enabled: true, } } } @@ -569,6 +580,7 @@ mod tests { bind: HostPort::new("127.0.0.1", 8080), target: HostPort::new("10.0.0.1", 80), description: "web".to_string(), + enabled: false, }]; original.socks_proxy = Some(HostPort::new("proxy", 1080)); original.algorithms.kex = vec!["curve25519-sha256".to_string()]; @@ -597,6 +609,25 @@ mod tests { let m: AuthMode = serde_json::from_str("\"agent\"").unwrap(); assert_eq!(m, AuthMode::Agent); } + + #[test] + fn a_forward_rule_saved_before_the_switch_existed_loads_enabled() { + let old = r#"{"name":"box","host":"h","forwards":[ + {"kind":"local","bind":{"host":"127.0.0.1","port":8080}, + "target":{"host":"srv","port":80},"description":"web"} + ]}"#; + let p: SshProfile = serde_json::from_str(old).unwrap(); + assert_eq!(p.forwards.len(), 1); + assert!(p.forwards[0].enabled, "an old rule must stay live"); + assert!(ForwardRule::default().enabled); + + let mut off = p.forwards[0].clone(); + off.enabled = false; + let back: ForwardRule = + serde_json::from_str(&serde_json::to_string(&off).unwrap()).unwrap(); + assert!(!back.enabled, "a switched-off rule stays off across a save"); + assert_eq!(back, off); + } } fn new_id() -> Uuid { diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 9d3517db..fad170f6 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -181,6 +181,10 @@ pub enum WorkspaceOp { RemoveForward { forward_id: u64, }, + SetForwardEnabled { + forward_id: u64, + enabled: bool, + }, ListForwards, TeardownForwards, SftpList { @@ -261,6 +265,13 @@ pub struct SshForwardRule { pub target_port: u16, #[serde(default)] pub description: Option, + /// A switched-off rule is registered but binds nothing. Defaults to on, + /// and is not a `PROTOCOL_VERSION` bump: a peer that predates it never + /// sends it and every rule it knows is live, while one that receives it + /// ignores it — the worst case is an older far end opening a rule the + /// user switched off, which is what it always did. + #[serde(default = "default_true")] + pub enabled: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -420,6 +431,13 @@ pub struct ManagedForward { #[serde(default)] pub description: Option, pub status: ForwardStatus, + /// Whether the forward is switched on. A field rather than a + /// `ForwardStatus` variant for the reason `loop_exit_status` gives: an + /// older peer fails the whole frame on a variant it has never heard of, + /// but skips a field it does not know. A switched-off forward's status is + /// an `Error` saying so, which is what such a peer then draws. + #[serde(default = "default_true")] + pub enabled: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -901,6 +919,11 @@ pub enum ClientMsg { pane_id: u64, forward_id: u64, }, + SetForwardEnabled { + pane_id: u64, + forward_id: u64, + enabled: bool, + }, ListForwards { pane_id: u64, }, @@ -996,6 +1019,7 @@ mod kind { pub const ADD_FORWARD: u8 = 20; pub const REMOVE_FORWARD: u8 = 21; pub const LIST_FORWARDS: u8 = 22; + pub const SET_FORWARD_ENABLED: u8 = 23; pub const VERSION: u8 = 40; pub const QUERY_PROCS: u8 = 50; pub const ON_WORKSPACE: u8 = 52; @@ -1269,6 +1293,15 @@ impl ClientMsg { pane_id, forward_id, } => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?), + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } => write_frame( + w, + kind::SET_FORWARD_ENABLED, + &to_json(&(pane_id, forward_id, enabled))?, + ), ClientMsg::QueryProcs { pane_id } => { write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?) } @@ -1399,6 +1432,14 @@ impl ClientMsg { forward_id, } } + kind::SET_FORWARD_ENABLED => { + let (pane_id, forward_id, enabled) = from_json(&payload)?; + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } + } kind::LIST_FORWARDS => ClientMsg::ListForwards { pane_id: from_json(&payload)?, }, @@ -1767,6 +1808,7 @@ mod tests { target_host: "10.0.0.5".into(), target_port: 80, description: Some("web".into()), + enabled: true, }, }, ClientMsg::AddForward { @@ -1778,12 +1820,18 @@ mod tests { target_host: String::new(), target_port: 0, description: None, + enabled: false, }, }, ClientMsg::RemoveForward { pane_id: 7, forward_id: 3, }, + ClientMsg::SetForwardEnabled { + pane_id: 7, + forward_id: 3, + enabled: false, + }, ClientMsg::ListForwards { pane_id: 7 }, ClientMsg::Version, ]; @@ -1943,6 +1991,7 @@ mod tests { target_port: 80, description: Some("web".into()), status: ForwardStatus::Listening, + enabled: true, }, ManagedForward { id: 2, @@ -1954,6 +2003,7 @@ mod tests { target_port: 3000, description: None, status: ForwardStatus::Error("bind refused".into()), + enabled: false, }, ]), DaemonMsg::Version(DaemonVersion { @@ -2394,6 +2444,7 @@ mod tests { target_host: "127.0.0.1".into(), target_port: 80, description: Some("web".into()), + enabled: true, }], keepalive_interval_s: Some(30), keepalive_count_max: Some(3), @@ -2480,9 +2531,14 @@ mod tests { target_host: "127.0.0.1".into(), target_port: 5432, description: Some("db".into()), + enabled: true, }, }, WorkspaceOp::RemoveForward { forward_id: 4 }, + WorkspaceOp::SetForwardEnabled { + forward_id: 4, + enabled: true, + }, WorkspaceOp::ListForwards, WorkspaceOp::TeardownForwards, WorkspaceOp::SftpList { @@ -2711,4 +2767,39 @@ mod tests { "the session daemon must not advertise a dialect it cannot serve" ); } + + /// `enabled` crosses the same peers as `rejected` above and gets the same + /// treatment: a rule or a forward from a peer that predates the switch is + /// a live one, and a peer that predates it reads a frame carrying it. + #[test] + fn a_forward_enabled_flag_decodes_from_a_peer_that_never_sends_it() { + let rule: SshForwardRule = serde_json::from_str( + r#"{"kind":"local","bind_host":"127.0.0.1","bind_port":8080, + "target_host":"srv","target_port":80}"#, + ) + .unwrap(); + assert!(rule.enabled); + + let managed: ManagedForward = serde_json::from_str( + r#"{"id":1,"pane_id":7,"kind":"local","bind_host":"127.0.0.1", + "bind_port":8080,"status":"listening"}"#, + ) + .unwrap(); + assert!(managed.enabled); + + #[derive(Deserialize)] + struct LegacyManagedForward { + id: u64, + status: ForwardStatus, + } + let off = serde_json::to_string(&ManagedForward { + enabled: false, + status: ForwardStatus::Error("disabled".into()), + ..managed + }) + .unwrap(); + let legacy: LegacyManagedForward = serde_json::from_str(&off).unwrap(); + assert_eq!(legacy.id, 1); + assert_eq!(legacy.status, ForwardStatus::Error("disabled".into())); + } } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index d2a43c73..f0da41de 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -1035,6 +1035,31 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(()) } + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } => { + let mut w = write_stream; + // Only switching on needs the connection; switching off has to + // work on a pane whose connection is already gone. + let conn = match forward_pane_connection(®istry, pane_id) { + Ok(conn) => Some(conn), + Err(e) if enabled => { + DaemonMsg::Error(e).encode(&mut w)?; + return Ok(()); + } + Err(_) => None, + }; + match crate::daemon::ssh::SshManager::global() + .set_forward_enabled(pane_id, conn, forward_id, enabled) + { + Ok(list) => DaemonMsg::ForwardList(list).encode(&mut w)?, + Err(e) => DaemonMsg::Error(e).encode(&mut w)?, + } + Ok(()) + } + ClientMsg::QueryProcs { pane_id } => { let mut w = write_stream; let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default(); diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs index 726ff9f1..e6d055ef 100644 --- a/crates/tty7-core/src/daemon/ssh/forward.rs +++ b/crates/tty7-core/src/daemon/ssh/forward.rs @@ -257,17 +257,39 @@ fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus { )))) } +/// What a switched-off forward reports. An `Error` for the same reason as +/// `loop_exit_status`, so an older peer reading it draws a stopped forward +/// with a word of explanation; a current one reads `enabled` instead. +fn disabled_status() -> SharedStatus { + Arc::new(Mutex::new(ForwardStatus::Error("disabled".to_string()))) +} + +/// Whether two forwards would want the same listening socket: the same port, +/// on the same side of the connection. Local and dynamic forwards both listen +/// here; a remote forward listens on the far host. +fn same_socket(a: SshForwardKind, a_port: u16, b: SshForwardKind, b_port: u16) -> bool { + let far = |k| k == SshForwardKind::Remote; + a_port != 0 && a_port == b_port && far(a) == far(b) +} + struct ForwardEntry { id: u64, kind: SshForwardKind, bind_host: String, bind_port: u16, + /// The port the rule asked for, which `bind_port` is not when that was 0 + /// and the OS picked one. Switching the forward back on asks again for + /// this, not for whatever it happened to get last time. + requested_port: u16, target_host: String, target_port: u16, description: Option, status: SharedStatus, cancel: ForwardCancel, auto_local: bool, + /// Switched off: the entry, and the rule in it, stay listed, but nothing + /// is bound and `cancel` is `None`. + enabled: bool, } #[derive(Clone, PartialEq, Eq, Hash, Debug)] @@ -288,6 +310,19 @@ impl ForwardEntry { target_port: self.target_port, description: self.description.clone(), status: self.status.lock().unwrap().clone(), + enabled: self.enabled, + } + } + + fn rule(&self) -> SshForwardRule { + SshForwardRule { + kind: self.kind, + bind_host: self.bind_host.clone(), + bind_port: self.requested_port, + target_host: self.target_host.clone(), + target_port: self.target_port, + description: self.description.clone(), + enabled: self.enabled, } } } @@ -318,6 +353,23 @@ impl SshForwardRegistry { .await } + pub async fn set_enabled( + &self, + pane_id: u64, + conn: Option>, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.set_enabled_owned( + &ForwardOwner::Pane(pane_id), + pane_id, + conn, + forward_id, + enabled, + ) + .await + } + pub async fn teardown_pane(&self, pane_id: u64) { self.teardown_owned(&ForwardOwner::Pane(pane_id)).await; } @@ -347,6 +399,24 @@ impl SshForwardRegistry { .await } + pub async fn set_enabled_workspace( + &self, + workspace: WorkspaceId, + view_pane: u64, + conn: Arc, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.set_enabled_owned( + &ForwardOwner::Workspace(workspace), + view_pane, + Some(conn), + forward_id, + enabled, + ) + .await + } + pub async fn teardown_workspace(&self, workspace: WorkspaceId) { self.teardown_owned(&ForwardOwner::Workspace(workspace)) .await; @@ -360,22 +430,24 @@ impl SshForwardRegistry { rule: &SshForwardRule, ) -> ManagedForward { let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let (bind_port, status, cancel) = match rule.kind { - SshForwardKind::Local => self.start_local(&conn, rule).await, - SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await, - SshForwardKind::Remote => self.start_remote(&conn, rule).await, + let (bind_port, status, cancel) = if rule.enabled { + self.start(&conn, rule).await + } else { + (rule.bind_port, disabled_status(), ForwardCancel::None) }; let entry = ForwardEntry { id, kind: rule.kind, bind_host: rule.bind_host.clone(), bind_port, + requested_port: rule.bind_port, target_host: rule.target_host.clone(), target_port: rule.target_port, description: rule.description.clone(), status, cancel, auto_local: false, + enabled: rule.enabled, }; let managed = entry.to_managed(view_pane); self.owners @@ -413,20 +485,145 @@ impl SshForwardRegistry { }) }; if let Some(entry) = removed { - Self::cancel_entry(entry).await; + Self::cancel(entry.cancel).await; } self.list_owned(owner, view_pane) } + /// Switch one forward off or back on, keeping its entry — and so its + /// place in the list and the rule it was made from — either way. + /// + /// Switching on refuses, rather than fails at bind time with whatever the + /// OS says, when another switched-on forward of the same owner already + /// holds the port: the answer then names that forward, which is the one + /// the user has to switch off first. A forward that cannot be started for + /// any other reason stays off, with the reason as the error. + async fn set_enabled_owned( + &self, + owner: &ForwardOwner, + view_pane: u64, + conn: Option>, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + if !enabled { + let cancel = { + let mut owners = self.owners.lock().unwrap(); + let entry = Self::entry_mut(&mut owners, owner, forward_id)?; + entry.enabled = false; + entry.status = disabled_status(); + std::mem::replace(&mut entry.cancel, ForwardCancel::None) + }; + Self::cancel(cancel).await; + return Ok(self.list_owned(owner, view_pane)); + } + + let Some(rule) = self.enable_plan(owner, forward_id)? else { + return Ok(self.list_owned(owner, view_pane)); + }; + let Some(conn) = conn else { + return Err("the SSH connection is gone — reconnect and try again".to_string()); + }; + let (bound, status, cancel) = self.start(&conn, &rule).await; + if let Some(e) = bind_error(&status) { + Self::cancel(cancel).await; + return Err(e); + } + if let Some(cancel) = self.finish_enable(owner, forward_id, bound, status, cancel) { + // Removed while it was being started: nobody wants what was bound. + Self::cancel(cancel).await; + } + Ok(self.list_owned(owner, view_pane)) + } + + fn entry_mut<'a>( + owners: &'a mut HashMap>, + owner: &ForwardOwner, + forward_id: u64, + ) -> Result<&'a mut ForwardEntry, String> { + owners + .get_mut(owner) + .and_then(|entries| entries.iter_mut().find(|e| e.id == forward_id)) + .ok_or_else(|| format!("no forward {forward_id}")) + } + + /// The rule to start for switching `forward_id` on, `None` when it is on + /// already, or why it may not be switched on. + fn enable_plan( + &self, + owner: &ForwardOwner, + forward_id: u64, + ) -> Result, String> { + let mut owners = self.owners.lock().unwrap(); + let entry = Self::entry_mut(&mut owners, owner, forward_id)?; + if entry.enabled { + return Ok(None); + } + let rule = SshForwardRule { + enabled: true, + ..entry.rule() + }; + let holder = owners.get(owner).into_iter().flatten().find(|e| { + e.id != forward_id + && e.enabled + && same_socket(e.kind, e.bind_port, rule.kind, rule.bind_port) + }); + if let Some(h) = holder { + let what = match h.kind { + SshForwardKind::Dynamic => "a SOCKS forward".to_string(), + _ => format!("the forward to {}:{}", h.target_host, h.target_port), + }; + return Err(format!( + "port {} is already used by {what}; switch that one off first", + rule.bind_port + )); + } + Ok(Some(rule)) + } + + /// Record a started forward as switched on. Hands `cancel` back when the + /// entry is no longer there to take it. + fn finish_enable( + &self, + owner: &ForwardOwner, + forward_id: u64, + bound: u16, + status: SharedStatus, + cancel: ForwardCancel, + ) -> Option { + let mut owners = self.owners.lock().unwrap(); + let Ok(entry) = Self::entry_mut(&mut owners, owner, forward_id) else { + return Some(cancel); + }; + entry.enabled = true; + entry.bind_port = bound; + entry.status = status; + // Anything already in the slot is a second switch-on that raced this + // one; it is replaced, and has to be stopped rather than leaked. + Some(std::mem::replace(&mut entry.cancel, cancel)) + } + + async fn start( + &self, + conn: &Arc, + rule: &SshForwardRule, + ) -> (u16, SharedStatus, ForwardCancel) { + match rule.kind { + SshForwardKind::Local => self.start_local(conn, rule).await, + SshForwardKind::Dynamic => self.start_dynamic(conn, rule).await, + SshForwardKind::Remote => self.start_remote(conn, rule).await, + } + } + async fn teardown_owned(&self, owner: &ForwardOwner) { let entries = self.owners.lock().unwrap().remove(owner); for entry in entries.into_iter().flatten() { - Self::cancel_entry(entry).await; + Self::cancel(entry.cancel).await; } } - async fn cancel_entry(entry: ForwardEntry) { - match entry.cancel { + async fn cancel(cancel: ForwardCancel) { + match cancel { ForwardCancel::Task(handle) => { handle.abort(); let _ = handle.await; @@ -634,6 +831,7 @@ impl SshForwardRegistry { target_host: remote_host.to_string(), target_port: remote_port, description: Some(format!("localhost link → :{remote_port}")), + enabled: true, }; let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (mut bind_port, mut status, mut cancel) = self.start_local(&conn, &rule).await; @@ -658,12 +856,14 @@ impl SshForwardRegistry { kind: SshForwardKind::Local, bind_host: rule.bind_host.clone(), bind_port, + requested_port: rule.bind_port, target_host: rule.target_host.clone(), target_port: rule.target_port, description: rule.description.clone(), status, cancel, auto_local: true, + enabled: true, }; self.owners .lock() @@ -735,6 +935,20 @@ impl SshManager { ) } + pub fn set_workspace_forward_enabled( + &self, + workspace: WorkspaceId, + view_pane: u64, + conn: Arc, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.runtime.block_on( + self.forwards + .set_enabled_workspace(workspace, view_pane, conn, forward_id, enabled), + ) + } + pub fn list_workspace_forwards( &self, workspace: WorkspaceId, @@ -941,12 +1155,14 @@ mod tests { kind: SshForwardKind::Local, bind_host: "127.0.0.1".into(), bind_port: port, + requested_port: port, target_host: "h".into(), target_port: 80, description: None, status: Arc::new(Mutex::new(ForwardStatus::Listening)), cancel: ForwardCancel::Task(task), auto_local: false, + enabled: true, } }; { @@ -986,12 +1202,14 @@ mod tests { kind: SshForwardKind::Local, bind_host: "127.0.0.1".into(), bind_port: port, + requested_port: port, target_host: "h".into(), target_port: 80, description: None, status: Arc::new(Mutex::new(ForwardStatus::Listening)), cancel: ForwardCancel::Task(handle), auto_local: false, + enabled: true, } } @@ -1047,12 +1265,14 @@ mod tests { kind: SshForwardKind::Local, bind_host: "127.0.0.1".into(), bind_port: port, + requested_port: port, target_host: "127.0.0.1".into(), target_port: 3000, description: None, status: Arc::new(Mutex::new(ForwardStatus::Listening)), cancel: ForwardCancel::Task(handle), auto_local: true, + enabled: true, }; reg.owners .lock() @@ -1143,4 +1363,170 @@ mod tests { ); assert_eq!(table.lookup("", 0), None); } + + fn push_disabled( + reg: &SshForwardRegistry, + owner: ForwardOwner, + id: u64, + kind: SshForwardKind, + port: u16, + ) { + reg.owners + .lock() + .unwrap() + .entry(owner) + .or_default() + .push(ForwardEntry { + id, + kind, + bind_host: "127.0.0.1".into(), + bind_port: port, + requested_port: port, + target_host: "srv-b".into(), + target_port: 80, + description: None, + status: disabled_status(), + cancel: ForwardCancel::None, + auto_local: false, + enabled: false, + }); + } + + /// Switching a forward off has to release what it bound — that is the + /// point, so the port can be taken by another rule — while leaving the + /// entry, and the rule in it, exactly where it was. + #[tokio::test] + async fn switching_a_forward_off_frees_its_socket_and_keeps_its_rule() { + let reg = SshForwardRegistry::default(); + let guard = push_listener(®, ForwardOwner::Pane(7), 3).await; + let port = reg.list(7)[0].bind_port; + + let list = reg.set_enabled(7, None, 3, false).await.unwrap(); + assert_eq!(list.len(), 1, "the forward is still listed"); + assert_eq!(list[0].id, 3); + assert!(!list[0].enabled); + assert_eq!(list[0].bind_port, port); + assert_eq!( + (list[0].target_host.as_str(), list[0].target_port), + ("127.0.0.1", 3000) + ); + assert!(matches!(list[0].status, ForwardStatus::Error(_))); + assert_eq!(Arc::strong_count(&guard), 1, "…and its listener is gone"); + TcpListener::bind(("127.0.0.1", port)) + .await + .expect("the port is free for someone else"); + + // Twice is the same as once, and a forward nobody has is an error. + assert_eq!(reg.set_enabled(7, None, 3, false).await.unwrap(), list); + assert!(reg.set_enabled(7, None, 99, false).await.is_err()); + + reg.teardown_pane(7).await; + assert!(reg.list(7).is_empty()); + } + + /// Two rules on one local port, pointed at different targets, is the + /// whole use of the switch. Turning the second on while the first still + /// holds the port is refused by name, not left to fail at bind time. + #[tokio::test] + async fn switching_on_a_forward_whose_port_is_held_is_refused_by_name() { + let reg = SshForwardRegistry::default(); + push_listener(®, ForwardOwner::Pane(7), 0).await; + let port = reg.list(7)[0].bind_port; + push_disabled(®, ForwardOwner::Pane(7), 1, SshForwardKind::Local, port); + + let err = reg.set_enabled(7, None, 1, true).await.unwrap_err(); + assert!(err.contains(&port.to_string()), "{err}"); + assert!(err.contains("127.0.0.1:3000"), "names the holder: {err}"); + assert!(!reg.list(7)[1].enabled, "the refused forward stays off"); + + // A SOCKS forward listens here too, so it collides the same way. + push_disabled( + ®, + ForwardOwner::Pane(7), + 2, + SshForwardKind::Dynamic, + port, + ); + assert!(reg.set_enabled(7, None, 2, true).await.is_err()); + + // Once the holder is off, the only thing left in the way is the + // missing connection — which is said, and changes nothing. + reg.set_enabled(7, None, 0, false).await.unwrap(); + let err = reg.set_enabled(7, None, 1, true).await.unwrap_err(); + assert!(err.contains("connection"), "{err}"); + assert!(reg.list(7).iter().all(|m| !m.enabled)); + + // Another owner's forwards are not this one's business. + push_disabled(®, ForwardOwner::Pane(8), 0, SshForwardKind::Local, port); + assert!( + reg.enable_plan(&ForwardOwner::Pane(8), 0) + .unwrap() + .is_some() + ); + } + + #[test] + fn only_the_same_port_on_the_same_side_collides() { + use SshForwardKind::{Dynamic, Local, Remote}; + assert!(same_socket(Local, 8080, Local, 8080)); + assert!(same_socket(Local, 8080, Dynamic, 8080)); + assert!(same_socket(Remote, 8080, Remote, 8080)); + assert!(!same_socket(Local, 8080, Remote, 8080), "different hosts"); + assert!(!same_socket(Local, 8080, Local, 8081)); + assert!( + !same_socket(Local, 0, Local, 0), + "0 asks the OS for a fresh one" + ); + } + + /// Switching back on asks for the port the rule asked for, not the one + /// the OS last picked for it, and records what it got this time. + #[tokio::test] + async fn switching_on_starts_from_the_rule_and_records_the_new_listener() { + let reg = SshForwardRegistry::default(); + push_disabled(®, ForwardOwner::Pane(7), 0, SshForwardKind::Local, 0); + reg.owners + .lock() + .unwrap() + .get_mut(&ForwardOwner::Pane(7)) + .unwrap()[0] + .bind_port = 41000; + + let rule = reg + .enable_plan(&ForwardOwner::Pane(7), 0) + .unwrap() + .expect("an off forward has something to start"); + assert_eq!(rule.bind_port, 0); + assert!(rule.enabled); + assert_eq!((rule.target_host.as_str(), rule.target_port), ("srv-b", 80)); + + let guard = Arc::new(()); + let held = guard.clone(); + let task = tokio::spawn(async move { + let _held = held; + std::future::pending::<()>().await + }); + let status: SharedStatus = Arc::new(Mutex::new(ForwardStatus::Listening)); + let leftover = reg.finish_enable( + &ForwardOwner::Pane(7), + 0, + 42000, + status, + ForwardCancel::Task(task), + ); + assert!(matches!(leftover, Some(ForwardCancel::None))); + let now = ®.list(7)[0]; + assert!(now.enabled); + assert_eq!(now.bind_port, 42000); + assert_eq!(now.status, ForwardStatus::Listening); + assert!( + reg.enable_plan(&ForwardOwner::Pane(7), 0) + .unwrap() + .is_none(), + "an on forward has nothing to start" + ); + + reg.teardown_pane(7).await; + assert_eq!(Arc::strong_count(&guard), 1, "the new listener is owned"); + } } diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index 6cfddade..937c06ef 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -115,6 +115,19 @@ impl SshManager { .block_on(self.forwards.remove(pane_id, forward_id)) } + pub fn set_forward_enabled( + &self, + pane_id: u64, + conn: Option>, + forward_id: u64, + enabled: bool, + ) -> Result, String> { + self.runtime.block_on( + self.forwards + .set_enabled(pane_id, conn, forward_id, enabled), + ) + } + pub fn list_forwards(&self, pane_id: u64) -> Vec { self.forwards.list(pane_id) } diff --git a/crates/tty7-core/src/daemon/ssh/workspace.rs b/crates/tty7-core/src/daemon/ssh/workspace.rs index e022de4e..657faffb 100644 --- a/crates/tty7-core/src/daemon/ssh/workspace.rs +++ b/crates/tty7-core/src/daemon/ssh/workspace.rs @@ -33,6 +33,13 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg { WorkspaceOp::RemoveForward { forward_id } => { DaemonMsg::ForwardList(mgr.remove_workspace_forward(ws, view, *forward_id)) } + WorkspaceOp::SetForwardEnabled { + forward_id, + enabled, + } => match mgr.set_workspace_forward_enabled(ws, view, conn, *forward_id, *enabled) { + Ok(list) => DaemonMsg::ForwardList(list), + Err(e) => DaemonMsg::Error(e), + }, WorkspaceOp::ListForwards => DaemonMsg::ForwardList(mgr.list_workspace_forwards(ws, view)), WorkspaceOp::TeardownForwards => { mgr.teardown_workspace_forwards(ws); diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx index 4002d9cb..1b4c424f 100644 --- a/docs/remote/port-forwarding.mdx +++ b/docs/remote/port-forwarding.mdx @@ -67,6 +67,24 @@ need it about as often as you need `ssh -R`. These live only as long as the session unless you save them into the profile. +## Switching a rule off + +Every forward in the Ports section has a switch. Off, the forward lets go of +its port but keeps its whole rule — kind, ports, target, description — so it +can be switched back on without retyping anything. That is how one local port +is pointed at different places: keep one rule per target on the same port, and +switch on the one you want. + +Only one of them can hold the port at a time. Switching one on while another +still has its port is refused, and the notice names the forward to switch off +first. + +Rules from a profile carry their switch into it, so the next connection opens +the same set. The same switch sits beside each rule under **Settings → SSH →** +a profile **→ Port forwarding**; a rule saved switched off is listed but not +opened. Rules added mid-session are still gone when the session ends, on or +off. + The ports section diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index c98b452f..101e5ea2 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -883,6 +883,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option { bind, target, description: String::new(), + enabled: true, }) } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index b319c5c2..340596d7 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -2296,6 +2296,29 @@ impl RemoteTerminal { .ok() } + /// The list after switching one forward on or off, or why it was not + /// switched — the daemon's own refusal, which names what is in the way. + pub fn set_forward_enabled( + pane_id: u64, + forward_id: u64, + enabled: bool, + ) -> anyhow::Result> { + let mut stream = connect()?; + ClientMsg::SetForwardEnabled { + pane_id, + forward_id, + enabled, + } + .encode(&mut stream)?; + match DaemonMsg::read(&mut stream)? { + DaemonMsg::ForwardList(list) => Ok(list), + DaemonMsg::Error(msg) => Err(anyhow::anyhow!(msg)), + other => Err(anyhow::anyhow!( + "unexpected reply to SetForwardEnabled: {other:?}" + )), + } + } + pub fn list_forwards(pane_id: u64) -> Vec { fn query(pane_id: u64) -> anyhow::Result> { let mut stream = connect()?; diff --git a/src/ui/app.rs b/src/ui/app.rs index 791a0162..81c51245 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3052,7 +3052,7 @@ impl Tty7App { cx: &mut Context, ) { let fields = self.managed_forward_fields(cx); - let Some(rule) = fields.collect() else { + let Some(mut rule) = fields.collect() else { // Add is disabled while the fields do not make a rule and the form // already says what is missing, so there is nothing to do here and // nothing left to explain. @@ -3060,6 +3060,10 @@ impl Tty7App { }; let route = self.forward_route(pane_id, cx); let previous = self.loopback_panel.mf_editing.clone(); + // Editing a switched-off rule edits it; it does not switch it on. + if let Some(old) = &previous { + rule.enabled = old.enabled; + } // A saved edit is a replace, and the rule being replaced has to come // out first: the ordinary edit keeps the bind port, and the far side // really does bind it, so adding first would collide with the very @@ -3163,10 +3167,14 @@ impl Tty7App { let Some(list) = route.add(rule) else { return PlaceOutcome::Unreachable(t(L10nKey::ForwardRequestFailed).to_string()); }; - let broken = added_forward(&before, &list).and_then(|added| match &added.status { - ForwardStatus::Error(msg) => Some((added.id, msg.clone())), - ForwardStatus::Listening => None, - }); + // A switched-off rule reports an error status because it binds + // nothing, and that is what it was asked to do. + let broken = added_forward(&before, &list) + .filter(|added| added.enabled) + .and_then(|added| match &added.status { + ForwardStatus::Error(msg) => Some((added.id, msg.clone())), + ForwardStatus::Listening => None, + }); self.loopback_panel.managed = list; let Some((id, msg)) = broken else { return PlaceOutcome::Placed; @@ -3254,6 +3262,84 @@ impl Tty7App { cx.notify(); } + /// Switch one forward on or off, and carry the choice into the saved host + /// the rule came from, if it came from one — so the next connection opens + /// the same set of rules instead of the ones that were saved before. + pub(crate) fn set_managed_forward_enabled( + &mut self, + pane_id: u64, + forward_id: u64, + enabled: bool, + window: &mut Window, + cx: &mut Context, + ) { + let route = self.forward_route(pane_id, cx); + let forward = self + .loopback_panel + .managed + .iter() + .find(|m| m.id == forward_id) + .cloned(); + match route.set_enabled(forward_id, enabled) { + Ok(list) => { + self.loopback_panel.managed = list; + if let Some(forward) = forward { + self.remember_forward_enabled(pane_id, &forward, enabled, cx); + } + } + Err(e) => { + window.push_notification( + t_fmt(L10nKey::ForwardSwitchFailed, &[("error", &e.to_string())]), + cx, + ); + // The refusal changed nothing, but the panel may be behind + // whatever did change — ask rather than guess. + self.loopback_panel.managed = route.list(); + } + } + cx.notify(); + } + + fn remember_forward_enabled( + &mut self, + pane_id: u64, + forward: &crate::daemon::protocol::ManagedForward, + enabled: bool, + cx: &mut Context, + ) { + let profile_id = self + .tabs + .iter() + .flat_map(|tab| tab.pane.terminals()) + .find_map(|leaf| { + let view = leaf.read(cx); + (view.pane_id == pane_id).then(|| view.ssh_spec()?.profile_id.clone())? + }) + .and_then(|id| uuid::Uuid::parse_str(&id).ok()); + let Some(profile_id) = profile_id else { + return; + }; + let saved = cx + .global::() + .ssh_profiles + .iter() + .find(|p| p.id == profile_id) + .and_then(|p| crate::ui::forwards::saved_rule_index(&p.forwards, forward, enabled)); + let Some(idx) = saved else { + return; + }; + self.update_config(cx, |cfg| { + if let Some(rule) = cfg + .ssh_profiles + .iter_mut() + .find(|p| p.id == profile_id) + .and_then(|p| p.forwards.get_mut(idx)) + { + rule.enabled = enabled; + } + }); + } + pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context) { // Whatever the panel would let this pane forward, which is a wider set // than "a connected native-ssh pane": a pane in a remote workspace @@ -8233,6 +8319,31 @@ impl ForwardRoute { } } + pub(crate) fn set_enabled( + &self, + forward_id: u64, + enabled: bool, + ) -> anyhow::Result> { + let Some(req) = + self.workspace_op(crate::daemon::protocol::WorkspaceOp::SetForwardEnabled { + forward_id, + enabled, + }) + else { + return crate::terminal::RemoteTerminal::set_forward_enabled( + self.pane_id, + forward_id, + enabled, + ); + }; + match crate::terminal::RemoteTerminal::on_workspace(req)? { + crate::daemon::protocol::DaemonMsg::ForwardList(list) => Ok(list), + other => Err(anyhow::anyhow!( + "unexpected reply to SetForwardEnabled: {other:?}" + )), + } + } + pub(crate) fn remove( &self, forward_id: u64, @@ -11849,6 +11960,7 @@ mod managed_forward_gpui_tests { target_port: 80, description: None, status: ForwardStatus::Listening, + enabled: true, } } diff --git a/src/ui/assets.rs b/src/ui/assets.rs index a35f0329..1731232b 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -47,6 +47,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"), "icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"), "icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"), + "icons/power.svg" => include_bytes!("../../assets/icons/power.svg"), "icons/list-tree.svg" => include_bytes!("../../assets/icons/list-tree.svg"), "icons/list-flat.svg" => include_bytes!("../../assets/icons/list-flat.svg"), "icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"), diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 3257016d..0b00cb10 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -3,6 +3,7 @@ use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, h_flex, v_flex}; +use crate::core::ssh_profile::ForwardRule; use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind, SshForwardRule}; use crate::terminal::view::TerminalView; use crate::ui::app::{CONTENT_INSET, Tty7App}; @@ -70,6 +71,7 @@ impl ForwardFields { target_host, target_port, description: (!description.is_empty()).then(|| description.to_string()), + enabled: true, }) } @@ -93,6 +95,7 @@ impl ForwardFields { // left over from a trip through the advanced form is not something // this rule was given. description: None, + enabled: true, }) } @@ -140,9 +143,34 @@ pub(crate) fn rule_of(forward: &ManagedForward) -> SshForwardRule { target_host: forward.target_host.clone(), target_port: forward.target_port, description: forward.description.clone(), + enabled: forward.enabled, } } +/// Which of a saved host's rules a live forward was opened from, among +/// those not already switched to `enabled` — the one a switch in the panel +/// has to be written back to. +/// +/// Matched on the whole mapping, since a live forward carries no reference to +/// the rule it came from. A saved rule always names its port, so the port the +/// forward bound is the one the rule asked for. A forward added in the panel +/// matches nothing and stays as temporary as it always was. +pub(crate) fn saved_rule_index( + rules: &[ForwardRule], + forward: &ManagedForward, + enabled: bool, +) -> Option { + rules.iter().position(|r| { + let saved = crate::ui::ssh_connect::map_forward(r); + saved.enabled != enabled + && saved.kind == forward.kind + && saved.bind_host == forward.bind_host + && saved.bind_port == forward.bind_port + && saved.target_host == forward.target_host + && saved.target_port == forward.target_port + }) +} + impl Tty7App { pub(crate) fn render_ssh_status_strip( &self, @@ -254,18 +282,23 @@ impl Tty7App { SshForwardKind::Remote => "R", SshForwardKind::Dynamic => "D", }; - let errored = matches!(forward.status, ForwardStatus::Error(_)); + // A switched-off forward has an error status only because it binds + // nothing; it is drawn faded, not red. + let off = !forward.enabled; + let errored = !off && matches!(forward.status, ForwardStatus::Error(_)); let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") { forward.bind_port.to_string() } else { format!("{}:{}", forward.bind_host, forward.bind_port) }; + let mapping = match forward.kind { + SshForwardKind::Dynamic => "SOCKS".to_string(), + _ => format!("→ {}:{}", forward.target_host, forward.target_port), + }; let tail = match &forward.status { + _ if off => mapping, ForwardStatus::Error(msg) => msg.clone(), - ForwardStatus::Listening => match forward.kind { - SshForwardKind::Dynamic => "SOCKS".to_string(), - _ => format!("→ {}:{}", forward.target_host, forward.target_port), - }, + ForwardStatus::Listening => mapping, }; let pane_id = forward.pane_id; let forward_id = forward.id; @@ -295,6 +328,7 @@ impl Tty7App { .flex_1() .min_w_0() .gap(px(1.)) + .when(off, |col| col.opacity(0.5)) .child( h_flex() .items_center() @@ -326,6 +360,33 @@ impl Tty7App { ) }), ) + // Always shown, unlike Remove beside it: whether a forward is on is + // state to read at a glance, not an action to go looking for. + .child( + div() + .flex_shrink_0() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + crate::ui::theme::switch( + ("panel-forward-enabled", forward_id as usize), + cx, + ) + .checked(forward.enabled) + .xsmall() + .tooltip(if off { + t(L10nKey::ForwardTooltipTurnOn) + } else { + t(L10nKey::ForwardTooltipTurnOff) + }) + .on_click(cx.listener( + move |this, on: &bool, window, cx| { + this.set_managed_forward_enabled( + pane_id, forward_id, *on, window, cx, + ) + }, + )), + ), + ) .child( div() .flex_shrink_0() @@ -586,6 +647,7 @@ mod tests { target_port: 80, description: Some("the staging box".to_string()), status: ForwardStatus::Listening, + enabled: true, } } @@ -718,4 +780,43 @@ mod tests { "nothing was added, so there is nothing to point at" ); } + + #[test] + fn a_switched_off_forward_keeps_its_switch_through_an_edit() { + let mut off = managed(3, 8080); + off.enabled = false; + assert!(!rule_of(&off).enabled); + assert!(rule_of(&managed(3, 8080)).enabled); + } + + /// The panel's switch is written back to the saved host only for the rule + /// the forward was opened from, and a rule added in the panel — which no + /// saved rule describes — is left as temporary as it always was. + #[test] + fn a_switch_is_written_back_to_the_saved_rule_it_came_from() { + use crate::core::ssh_profile::{ForwardKind, HostPort}; + let saved = |target: &str, enabled: bool| ForwardRule { + kind: ForwardKind::Local, + bind: HostPort::new("127.0.0.1", 8080), + target: HostPort::new(target, 80), + description: "the staging box".to_string(), + enabled, + }; + let rules = vec![saved("10.0.0.9", true), saved("10.0.0.5", true)]; + let live = managed(3, 8080); + assert_eq!(saved_rule_index(&rules, &live, false), Some(1)); + assert_eq!( + saved_rule_index(&rules, &live, true), + None, + "already on: nothing to write" + ); + + let mut elsewhere = managed(3, 8081); + elsewhere.description = None; + assert_eq!(saved_rule_index(&rules, &elsewhere, false), None); + + let mut remote = managed(3, 8080); + remote.kind = SshForwardKind::Remote; + assert_eq!(saved_rule_index(&rules, &remote, false), None); + } } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 63778e02..93a7e79a 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1032,6 +1032,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SshEditProfile => "Edit connection…", L10nKey::ForwardTooltipAdd => "Add forward", L10nKey::ForwardTooltipRemove => "Remove", + L10nKey::ForwardTooltipTurnOn => "Turn on", + L10nKey::ForwardTooltipTurnOff => "Turn off — the rule is kept", + L10nKey::ForwardSwitchFailed => "Couldn't switch the forward — {error}", + L10nKey::SettingsFwdEnabled => "Open this rule with the connection", L10nKey::ForwardLocal => "Local", L10nKey::ForwardRemote => "Remote", L10nKey::ForwardDynamic => "Dynamic", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index e49d05a6..1be8a435 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1098,6 +1098,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SshEditProfile => "接続を編集…", L10nKey::ForwardTooltipAdd => "フォワードを追加", L10nKey::ForwardTooltipRemove => "削除", + L10nKey::ForwardTooltipTurnOn => "オンにする", + L10nKey::ForwardTooltipTurnOff => "オフにする — ルールは残ります", + L10nKey::ForwardSwitchFailed => "フォワードを切り替えられませんでした — {error}", + L10nKey::SettingsFwdEnabled => "接続時にこのルールを開く", L10nKey::ForwardLocal => "ローカル", L10nKey::ForwardRemote => "リモート", L10nKey::ForwardDynamic => "ダイナミック", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 45903235..196ff2c2 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -745,6 +745,10 @@ l10n_keys! { SshEditProfile, ForwardTooltipAdd, ForwardTooltipRemove, + ForwardTooltipTurnOn, + ForwardTooltipTurnOff, + ForwardSwitchFailed, + SettingsFwdEnabled, ForwardLocal, ForwardRemote, ForwardDynamic, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index d27a7f7f..c28fab39 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -987,6 +987,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SshEditProfile => "编辑连接…", L10nKey::ForwardTooltipAdd => "添加转发", L10nKey::ForwardTooltipRemove => "移除", + L10nKey::ForwardTooltipTurnOn => "开启", + L10nKey::ForwardTooltipTurnOff => "关闭——规则会保留", + L10nKey::ForwardSwitchFailed => "无法切换这条转发——{error}", + L10nKey::SettingsFwdEnabled => "连接时开启这条规则", L10nKey::ForwardLocal => "本地", L10nKey::ForwardRemote => "远程", L10nKey::ForwardDynamic => "动态", diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index d96dbb32..aabd3795 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -112,8 +112,11 @@ pub(crate) const ROW_INSET: f32 = 6.; /// Local forwards only, and only those aimed at the far host's own loopback: /// a forward to some third machine happens to carry the same number, and /// pairing it with the port row would claim it leads somewhere it does not. +/// Nor does a switched-off forward: it leads nowhere until it is switched back +/// on, and its own row is where that switch is. pub(crate) fn forwards_port(m: &ManagedForward, port: u16) -> bool { - m.kind == crate::daemon::protocol::SshForwardKind::Local + m.enabled + && m.kind == crate::daemon::protocol::SshForwardKind::Local && m.target_port == port && crate::daemon::protocol::PortEntry::reaches_loopback(&m.target_host) } @@ -1353,8 +1356,26 @@ impl Tty7App { }), ); if let Some(f) = forward { - tiles_wide += 1; + tiles_wide += 2; let forward_id = f.id; + // Switched off, the forward stops speaking for this row and + // comes out as a row of its own with the switch to turn it + // back on. + actions = actions.child( + crate::ui::tab_strip::chrome_tile_sized( + Button::new(("panel-port-switch-off", i)) + .icon(Icon::empty().path("icons/power.svg")), + TILE_SIZE_XS, + TILE_GLYPH_XS, + false, + cx, + ) + .rounded(px(4.)) + .tooltip(t(L10nKey::ForwardTooltipTurnOff)) + .on_click(cx.listener(move |this, _, window, cx| { + this.set_managed_forward_enabled(pane_id, forward_id, false, window, cx) + })), + ); actions = actions.child( self.info_tile( ("panel-port-unforward", i), @@ -1970,6 +1991,7 @@ mod tests { target_port, description: None, status: ForwardStatus::Listening, + enabled: true, } } @@ -2001,6 +2023,15 @@ mod tests { ); } + /// A switched-off forward reaches nothing, so it must not take over the + /// port row — its own row is the only place its switch is. + #[test] + fn a_switched_off_forward_is_not_the_way_to_a_port() { + let mut off = forward(SshForwardKind::Local, "localhost", 3000); + off.enabled = false; + assert!(!forwards_port(&off, 3000)); + } + fn diff(added: u32, removed: u32, open: bool) -> InfoRow { InfoRow { label: "changes", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index d1361975..6e6ecdf5 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1562,6 +1562,7 @@ pub(crate) struct ForwardRuleForm { pub(crate) target_host: Entity, pub(crate) target_port: Entity, pub(crate) description: Entity, + pub(crate) enabled: bool, } impl ForwardRuleForm { @@ -1584,6 +1585,7 @@ impl ForwardRuleForm { bind, target, description: val(&self.description), + enabled: self.enabled, }) } @@ -2027,6 +2029,7 @@ fn seed_forward_row( &rule.description, t(L10nKey::ForwardDescriptionPlaceholder), ), + enabled: rule.enabled, } } @@ -6235,7 +6238,7 @@ impl Tty7App { let count = form .forwards .iter() - .filter(|r| r.collect(cx).is_some()) + .filter(|r| r.enabled && r.collect(cx).is_some()) .count(); let summary = match count { 0 => t(L10nKey::SettingsNoneSummary).to_string(), @@ -6380,6 +6383,20 @@ impl Tty7App { .flex_1() .min_w(px(80.)) .child(Input::new(&row.description).xsmall()); + let enabled = div().flex_shrink_0().child( + crate::ui::theme::switch(("ssh-fwd-enabled", idx), cx) + .checked(row.enabled) + .xsmall() + .tooltip(t(L10nKey::SettingsFwdEnabled)) + .on_click(cx.listener(move |this, on: &bool, _w, cx| { + if let Some(f) = this.ssh_form_mut() + && let Some(r) = f.forwards.get_mut(idx) + { + r.enabled = *on; + cx.notify(); + } + })), + ); let remove = crate::ui::tab_strip::hit_target( Button::new(("ssh-fwd-remove", idx)) .icon(Icon::new(IconName::Close)) @@ -6396,6 +6413,7 @@ impl Tty7App { h_flex() .gap_2() .items_center() + .child(enabled) .child(kind_switch) .child(description) .child(remove), @@ -6404,9 +6422,15 @@ impl Tty7App { true => mapping(v_flex().gap_1().items_start()), false => mapping(h_flex().gap_2().items_center()), }), - false => mapping(h_flex().gap_2().items_center().child(kind_switch)) - .child(description) - .child(remove), + false => mapping( + h_flex() + .gap_2() + .items_center() + .child(enabled) + .child(kind_switch), + ) + .child(description) + .child(remove), }; v_flex() diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 533ba60f..e1bf4210 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -606,6 +606,7 @@ fn unmap_forward(rule: &SshForwardRule) -> ForwardRule { bind: HostPort::new(rule.bind_host.clone(), rule.bind_port), target: HostPort::new(rule.target_host.clone(), rule.target_port), description: rule.description.clone().unwrap_or_default(), + enabled: rule.enabled, } } @@ -649,7 +650,7 @@ fn map_proxy(profile: &SshProfile) -> SshProxy { SshProxy::None } -fn map_forward(rule: &ForwardRule) -> SshForwardRule { +pub(crate) fn map_forward(rule: &ForwardRule) -> SshForwardRule { SshForwardRule { kind: match rule.kind { ForwardKind::Local => SshForwardKind::Local, @@ -661,6 +662,7 @@ fn map_forward(rule: &ForwardRule) -> SshForwardRule { target_host: rule.target.host.clone(), target_port: rule.target.port, description: (!rule.description.is_empty()).then(|| rule.description.clone()), + enabled: rule.enabled, } } @@ -855,7 +857,16 @@ mod tests { bind: HostPort::new("localhost", 8080), target: HostPort::new("127.0.0.1", 80), description: "web".into(), + enabled: true, }]; + // A switched-off rule survives the trip through a live spec switched + // off, or reopening a host from its connection would turn it back on. + p.forwards.push(ForwardRule { + bind: HostPort::new("localhost", 8080), + target: HostPort::new("10.0.0.7", 80), + enabled: false, + ..ForwardRule::default() + }); let spec = build_native_ssh_spec(&p, &[], &store, true); let back = profile_from_live_spec(&spec); From bd0dd22bfa4e43646cf4c79c25d31943c76d3ec5 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:41:12 +0800 Subject: [PATCH 09/20] feat(tabs): hibernate a tab to free its memory and wake it later (#954) A tab can be put to sleep from its context menu or the command palette: its panes are stopped (screens kept on disk), the tab keeps its place in the sidebar, and selecting it wakes it through the same restore a reboot runs, resuming a supported agent's session. The sleep mark lives on the machine tree, so it survives app and daemon restarts. Closes #762 --- CHANGELOG.md | 13 + crates/tty7-cli/src/commands.rs | 34 ++ crates/tty7-cli/src/output.rs | 36 ++- crates/tty7-cli/src/testbed.rs | 3 + crates/tty7-core/src/core/machine.rs | 148 +++++++++ crates/tty7-core/src/core/session.rs | 10 + crates/tty7-core/src/daemon/control.rs | 15 + crates/tty7-core/src/daemon/server.rs | 20 ++ crates/tty7-core/src/host/server.rs | 185 +++++++++++ docs/cli/reference.mdx | 4 +- docs/reference/keyboard-shortcuts.mdx | 2 +- src/core/actions.rs | 1 + src/ui/app.rs | 413 ++++++++++++++++++++++++- src/ui/home.rs | 14 + src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 6 + src/ui/i18n/mod.rs | 6 + src/ui/i18n/zh.rs | 6 + src/ui/keymap.rs | 6 + src/ui/local_link.rs | 9 + src/ui/machine_mirror.rs | 1 + src/ui/palette.rs | 5 + src/ui/tab_sidebar.rs | 16 +- src/ui/tab_strip.rs | 62 +++- src/ui/tree_sync.rs | 370 +++++++++++++++++++++- 25 files changed, 1378 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa5974d..ea729d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Tabs can be put to sleep, and woken where they were** (#762). Right-click a + tab → Hibernate, or "Hibernate Tab" in the command palette, stops every + process in the tab to give its memory back while the tab keeps its place in + the sidebar, faded and marked with a moon. Selecting it wakes it — or + right-click → Wake to warm it up without switching. Waking is the restore a + reboot already runs: each pane comes back in its old directory and shell, + opens on the screen it left, and a supported agent with a captured session + is resumed. A sleeping tab stays asleep across app and daemon restarts; the + tab on screen is always awake, so the last awake tab cannot be put to sleep. + `tty7 pane ls` shows a sleeping tab's panes as `asleep`, and `tab ls`/`pane + ls --json` carry a `hibernated` flag. Offered only where the machine's server + can do it (this build or newer). + - **A port forward can be switched off without losing its rule** (#439). A forward could only be removed, so pointing one local port at a different remote target meant deleting the rule and typing the other one in again. Each diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 1cd5f935..d7bea737 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -944,6 +944,7 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R "agent": view.agent.map(|a| a.display_name()), "group": tab.sidebar_group, "panes": tab.root.pane_ids(), + "hibernated": tab.hibernated, }) }) .collect(); @@ -1147,6 +1148,7 @@ fn pane_ls(explicit: Option<&str>, all: bool, backend: &mut dyn Backend) -> Resu "tab": tab.id.to_string(), "cwd": record.and_then(|r| r.cwd.clone()), "live": record.map(|r| r.live), + "hibernated": tab.hibernated, })); } } @@ -4716,6 +4718,38 @@ mod tests { ); } + #[test] + fn tab_ls_and_pane_ls_say_which_tabs_are_asleep() { + let mut backend = mock(); + backend.machine.workspaces[0].tabs[1].hibernated = true; + + let Outcome::Report(tabs) = run_cli( + &["tty7", "tab", "ls", "api"], + &Context::default(), + &mut backend, + ) else { + panic!("tab ls must report"); + }; + let tabs = tabs.json["tabs"].as_array().expect("tabs").clone(); + assert_eq!(tabs[0]["hibernated"], false); + assert_eq!(tabs[1]["hibernated"], true); + + let Outcome::Report(panes) = run_cli( + &["tty7", "pane", "ls", "api"], + &Context::default(), + &mut backend, + ) else { + panic!("pane ls must report"); + }; + let panes = panes.json["panes"].as_array().expect("panes").clone(); + let asleep: Vec = panes + .iter() + .filter(|p| p["hibernated"] == true) + .map(|p| p["pane"].as_u64().unwrap()) + .collect(); + assert_eq!(asleep, vec![2, 3]); + } + fn exec_run(end: ExecEnd, output: &[u8]) -> crate::exec::ExecRun { crate::exec::ExecRun { end, diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index d92ab7ef..c165a3f7 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -140,9 +140,14 @@ pub fn pane_table(machine: &Machine, only: Option) -> String { record .and_then(|r| r.cwd.clone()) .unwrap_or_else(|| "-".to_string()), - record - .map(|r| if r.live { "yes" } else { "no" }.to_string()) - .unwrap_or_else(|| "?".to_string()), + // Not running, and meant not to be: a sleeping tab's panes + // are waiting for their tab to be woken, not dead. + match record { + _ if tab.hibernated => "asleep".to_string(), + Some(r) if r.live => "yes".to_string(), + Some(_) => "no".to_string(), + None => "?".to_string(), + }, ]); } } @@ -648,4 +653,29 @@ mod tests { procs_tables(&empty) ); } + + /// A sleeping tab's panes are not running and not dead either: the table + /// says which, so nobody reads a tab put to sleep as one that crashed. + #[test] + fn the_pane_table_calls_a_sleeping_tabs_panes_asleep() { + let mut m = two_workspace_machine(); + m.workspaces[0].tabs[1].hibernated = true; + for pane in &mut m.panes { + if pane.id == 2 || pane.id == 3 { + pane.live = false; + } + } + let rendered = pane_table(&m, None); + let row = |pane: &str| { + rendered + .lines() + .find(|l| l.starts_with(pane)) + .unwrap_or_else(|| panic!("no row for {pane}: {rendered}")) + .to_string() + }; + assert!(row("%1 ").ends_with("yes"), "{rendered}"); + assert!(row("%2 ").ends_with("asleep"), "{rendered}"); + assert!(row("%3 ").ends_with("asleep"), "{rendered}"); + assert!(row("%5 ").ends_with("yes"), "{rendered}"); + } } diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 22cf5c24..a24aaf75 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -12,6 +12,7 @@ pub fn two_workspace_machine() -> Machine { name: Some("build".into()), sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }, Tab { id: TabId::new(), @@ -23,6 +24,7 @@ pub fn two_workspace_machine() -> Machine { a: Box::new(PaneNode::Leaf { pane: 2 }), b: Box::new(PaneNode::Leaf { pane: 3 }), }, + hibernated: false, }, ], active_tab: None, @@ -37,6 +39,7 @@ pub fn two_workspace_machine() -> Machine { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 5 }, + hibernated: false, }], active_tab: None, attachment: None, diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 2a0fdb29..201c0141 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -158,6 +158,19 @@ pub struct Tab { #[serde(default)] pub sidebar_group: Option, pub root: PaneNode, + /// The tab was put to sleep: its panes were stopped to give their memory + /// back, and it stays in the workspace to be woken later (#762). + /// + /// The panes keep their ids in `root` and their records in + /// [`Machine::panes`] — cwd, shell, agent session — because waking is a + /// restore of exactly those panes, through the same path a reboot takes. + /// That is also what keeps the sweeps off them: a pane the tree still + /// names is one whose stored screen and history are kept. + /// + /// Left out of the document while false, so a tree written by this build + /// reads the same to one that predates the field. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hibernated: bool, } impl Tab { @@ -167,6 +180,7 @@ impl Tab { name: None, sidebar_group: None, root: PaneNode::Leaf { pane }, + hibernated: false, } } } @@ -747,6 +761,43 @@ impl MachineStore { }) } + /// Put a tab to sleep, or mark it awake again. Answers the panes the tab + /// holds, which for a tab going to sleep are the ones whoever asked is now + /// expected to stop — see [`ControlRequest::TabSetHibernated`]. + /// + /// Only the mark changes here. The panes stay in the tab and in the pane + /// list, so the facts a wake needs survive the processes: waking hands the + /// same ids to the ordinary restore, which spawns successors and swaps + /// them in with `pane_replace`. Setting the mark a tab already has is not + /// a change and raises no delta. + /// + /// The delta is `TabRestructured` carrying the whole tab rather than a + /// variant of its own, so a client that predates the mark still decodes + /// it — it reads the same tab back, minus a field it does not know. + /// + /// [`ControlRequest::TabSetHibernated`]: crate::daemon::control::ControlRequest::TabSetHibernated + pub fn tab_set_hibernated( + &self, + workspace: WorkspaceId, + tab: TabId, + hibernated: bool, + origin: Option, + ) -> io::Result> { + self.mutate(origin, |m| { + let t = find_tab(m, workspace, tab)?; + let panes = t.root.pane_ids(); + if t.hibernated == hibernated { + return Ok((panes, Vec::new())); + } + t.hibernated = hibernated; + let delta = LayoutDelta::TabRestructured { + tab: t.clone(), + pane: None, + }; + Ok((panes, vec![(workspace, delta)])) + }) + } + pub fn tab_set_group( &self, workspace: WorkspaceId, @@ -2801,4 +2852,101 @@ mod tests { assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![3]); assert!(machine.panes.is_empty()); } + + /// A tab put to sleep keeps its panes — ids in the layout, records in the + /// pane list — because waking is a restore of exactly those. Only the + /// orphan collection a close runs could take them, and a sleeping tab + /// still names them. + #[test] + fn a_sleeping_tab_keeps_its_panes_and_says_so_in_a_delta() { + let (store, _dir, ws, tab) = store_with_tab(); + store + .pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/work"), false, None) + .unwrap(); + let (_sub, heard) = recorded(&store); + + let panes = store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + + assert_eq!(panes, vec![1, 2]); + let m = store.machine(); + let asleep = &m.workspaces[0].tabs[0]; + assert!(asleep.hibernated); + assert_eq!(asleep.root.pane_ids(), vec![1, 2]); + assert!(m.panes.iter().any(|p| p.id == 1) && m.panes.iter().any(|p| p.id == 2)); + let heard = heard.lock().unwrap(); + assert_eq!(heard.len(), 1); + match &heard[0].1 { + LayoutDelta::TabRestructured { tab: t, pane: None } => { + assert_eq!(t.id, tab.id); + assert!(t.hibernated, "the delta carries the mark"); + } + other => panic!("expected TabRestructured, heard {other:?}"), + } + } + + #[test] + fn setting_the_mark_a_tab_already_has_raises_nothing() { + let (store, _dir, ws, tab) = store_with_tab(); + let (_sub, heard) = recorded(&store); + store.tab_set_hibernated(ws, tab.id, false, None).unwrap(); + assert!(heard.lock().unwrap().is_empty()); + + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + assert_eq!(heard.lock().unwrap().len(), 1); + + store.tab_set_hibernated(ws, tab.id, false, None).unwrap(); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + assert_eq!(heard.lock().unwrap().len(), 2); + } + + #[test] + fn hibernating_a_tab_that_is_not_there_is_refused() { + let (store, _dir, ws, _tab) = store_with_tab(); + let err = store + .tab_set_hibernated(ws, TabId::new(), true, None) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + /// Asleep has to outlive the daemon: a restart reads the tree back from + /// disk, and a tab that came back awake would be respawned on the spot — + /// every process the user put to sleep, started again unasked. + #[test] + fn a_sleeping_tab_is_still_asleep_after_the_tree_is_read_back() { + let (store, dir, ws, tab) = store_with_tab(); + let other = store + .tab_create(ws, None, seed(5, "/else"), None, None) + .unwrap(); + store.tab_set_hibernated(ws, tab.id, true, None).unwrap(); + drop(store); + + let reopened = MachineStore::open(dir.path().join(MACHINE_FILE)); + let m = reopened.machine(); + let tabs = &m.workspaces[0].tabs; + assert!(tabs.iter().find(|t| t.id == tab.id).unwrap().hibernated); + assert!(!tabs.iter().find(|t| t.id == other.id).unwrap().hibernated); + let record = m.panes.iter().find(|p| p.id == 1).unwrap(); + assert_eq!(record.cwd.as_deref(), Some("/work")); + assert!(!record.live); + } + + /// Awake is the default and stays out of the document, so a tree this + /// build writes for a tab that never slept reads the same to one that + /// predates the mark, and a tree from before it decodes as awake. + #[test] + fn the_mark_is_only_written_while_it_is_set() { + let tab = Tab::leaf(4); + let json = serde_json::to_string(&tab).unwrap(); + assert!(!json.contains("hibernated"), "{json}"); + let back: Tab = serde_json::from_str(&json).unwrap(); + assert!(!back.hibernated); + + let asleep = Tab { + hibernated: true, + ..Tab::leaf(4) + }; + let back: Tab = serde_json::from_str(&serde_json::to_string(&asleep).unwrap()).unwrap(); + assert!(back.hibernated); + } } diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index f316a2af..1deeeca4 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -54,6 +54,16 @@ pub struct SessionTab { pub sidebar_group: Option, #[serde(skip)] pub tree_id: Option, + /// Asleep in the tree: comes back as a placeholder holding `pane`, and + /// nothing is spawned for it until it is woken. See + /// [`Tab::hibernated`](crate::core::machine::Tab::hibernated). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hibernated: bool, + /// What a sleeping tab is called while nothing in it runs to say — read + /// off the tree's pane records, the same way the switcher names a tab of + /// a window it does not own. + #[serde(skip)] + pub asleep_view: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 136a3164..1543d8ca 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -141,6 +141,10 @@ pub mod feature { /// simply unknown here: the pane lives in the peer's registry, and the /// local daemon this client would otherwise ask has never heard of it. pub const PANE_PROCS: &str = "pane-procs"; + /// The peer can put a tab to sleep: mark it in the tree and stop its panes + /// while keeping their screens (#762). Needs both a tree and panes, so a + /// peer serving either alone does not say it. + pub const TAB_HIBERNATE: &str = "tab-hibernate"; } pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; @@ -292,6 +296,16 @@ pub enum ControlRequest { tab: TabId, group: Option, }, + /// Put a tab to sleep or mark it awake. Going to sleep, the peer also + /// stops every pane the tab holds, keeping each one's screen on disk for + /// the wake to restore; waking only clears the mark, because the panes a + /// wake brings up are spawned by the client like any restored pane. + /// Answers the tab's panes. Gated on [`feature::TAB_HIBERNATE`]. + TabSetHibernated { + workspace: WorkspaceId, + tab: TabId, + hibernated: bool, + }, PaneSplit { workspace: WorkspaceId, pane: u64, @@ -399,6 +413,7 @@ impl ControlRequest { | TabRename { .. } | TabMove { .. } | TabSetGroup { .. } + | TabSetHibernated { .. } | PaneSplit { .. } | PaneClose { .. } | PaneSetRatio { .. } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index f0da41de..de4c4f5f 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -105,6 +105,10 @@ impl crate::host::server::PaneDirectory for Registry { self.get(pane_id).map(|p| p.procs()).unwrap_or_default() } + fn hibernate_pane(&self, pane_id: u64) { + hibernate_pane(self, pane_id); + } + fn agent_states(&self) -> Vec { let panes: Vec> = self.panes.lock().unwrap().values().cloned().collect(); let mut states: Vec<_> = panes.iter().filter_map(|p| p.agent_state()).collect(); @@ -276,6 +280,22 @@ fn kill_pane(registry: &Registry, pane_id: u64) { crate::daemon::scrollback::forget(pane_id); } +/// Stop a pane whose tab is going to sleep, keeping its screen. +/// +/// [`kill_pane`] with the other half of the bargain: the screen is written one +/// last time, exactly, and left on disk rather than dropped, so the pane that +/// wakes in this one's place opens showing it — the same restore a daemon that +/// died unannounced gets, asked for the same way. The tree still names this +/// pane, which is what keeps the sweeps off the file until then. +fn hibernate_pane(registry: &Registry, pane_id: u64) { + let Some(pane) = registry.remove(pane_id) else { + return; + }; + let (segments, title, _) = pane.scrollback_snapshot(); + crate::daemon::scrollback::save(pane_id, &segments, title.as_deref()); + pane.kill(); +} + fn ssh_connection_for( registry: &Registry, pane_id: u64, diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 6913a5e8..ebcc73ab 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -34,6 +34,10 @@ pub trait PaneDirectory: Send + Sync { /// is this process and not the client's own daemon: the client asks over /// the control link precisely because the processes are here. fn pane_procs(&self, pane_id: u64) -> crate::daemon::protocol::PaneProcs; + /// Stop a pane for a tab going to sleep: the processes go, the pane's last + /// screen stays on disk for the wake to open with. Unlike closing a pane, + /// which drops that screen because nobody will ask for it again. + fn hibernate_pane(&self, pane_id: u64); } #[derive(Clone, Default)] @@ -383,6 +387,12 @@ fn handshake( if services.machine.is_some() { features.push(feature::MACHINE_TREE.to_string()); } + // A sleeping tab is a mark in the tree *and* stopped panes; a peer that can + // do only half of that would leave a tab asleep with its shells running, + // or its shells stopped with nothing saying the tab is meant to wake. + if services.machine.is_some() && services.panes.is_some() { + features.push(feature::TAB_HIBERNATE.to_string()); + } // The pane daemon's features ride along, same as `protocol_version` above: // a pane connection answers exactly one message, so a client that wanted // them from `ClientMsg::Version` would pay a whole routed connection — for @@ -777,6 +787,34 @@ fn run_request( .tab_set_group(workspace, tab, group, conn.machine_origin)?; (ReplyOk::Unit, Vec::new()) } + ControlRequest::TabSetHibernated { + workspace, + tab, + hibernated, + } => { + let Some(directory) = conn.panes.as_ref() else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "this peer serves no panes, so it cannot put a tab to sleep", + )); + }; + let panes = conn.machine()?.tab_set_hibernated( + workspace, + tab, + hibernated, + conn.machine_origin, + )?; + // After the mark, not before: a pane stopped under a tab the tree + // does not yet call asleep is, to anyone reading the tree in + // between, a pane that died — and a restore reading it that way + // would spawn it straight back. + if hibernated { + for pane in &panes { + directory.hibernate_pane(*pane); + } + } + (ReplyOk::Panes(panes), Vec::new()) + } ControlRequest::PaneSplit { workspace, pane, @@ -1716,6 +1754,8 @@ mod aggregate_tests { } } + fn hibernate_pane(&self, _pane_id: u64) {} + fn agent_states(&self) -> Vec { vec![PaneAgentState { pane_id: 7, @@ -1896,6 +1936,151 @@ mod aggregate_tests { "whatever links exist are named; none are blank" ); } + + /// Records which panes were stopped for a sleeping tab, and in what order + /// relative to the tree: each entry carries whether the tree already + /// called the tab asleep when the pane was stopped. + struct SleepRecorder { + store: Arc, + stopped: std::sync::Mutex>, + } + + impl PaneDirectory for SleepRecorder { + fn pane_count(&self) -> u64 { + 0 + } + + fn panes(&self) -> Vec { + Vec::new() + } + + fn pane_procs(&self, _pane_id: u64) -> crate::daemon::protocol::PaneProcs { + Default::default() + } + + fn agent_states(&self) -> Vec { + Vec::new() + } + + fn hibernate_pane(&self, pane_id: u64) { + let asleep = self.store.machine().workspaces.iter().any(|w| { + w.tabs + .iter() + .any(|t| t.hibernated && t.root.contains(pane_id)) + }); + self.stopped.lock().unwrap().push((pane_id, asleep)); + } + } + + fn seed(pane: u64) -> machine::PaneSeed { + machine::PaneSeed { + pane, + cwd: Some("/repo".into()), + ssh_spec: None, + agent: None, + shell: None, + } + } + + /// Going to sleep stops every pane of the tab — after the tree says the + /// tab is asleep, so nobody reading the tree in between takes the stopped + /// panes for dead ones — and keeps them in the tree for the wake. Waking + /// only clears the mark: the client brings the panes back. + #[test] + fn a_tab_put_to_sleep_stops_its_panes_and_keeps_them_in_the_tree() { + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + let ws = store.workspace_create(None, None, None).unwrap(); + let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap(); + store + .pane_split( + ws.id, + 7, + machine::Axis::Horizontal, + 0.5, + seed(8), + false, + None, + ) + .unwrap(); + let other = store.tab_create(ws.id, None, seed(9), None, None).unwrap(); + let recorder = Arc::new(SleepRecorder { + store: Arc::clone(&store), + stopped: std::sync::Mutex::new(Vec::new()), + }); + let client = client_with(Services { + machine: Some(Arc::clone(&store)), + attachments: Arc::new(AttachRegistry::default()), + panes: Some(recorder.clone()), + }); + assert!(client.hello().has_feature(feature::TAB_HIBERNATE)); + + let ReplyOk::Panes(panes) = client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: true, + }) + .unwrap() + else { + panic!("TabSetHibernated must answer with the tab's panes"); + }; + assert_eq!(panes, vec![7, 8]); + assert_eq!( + *recorder.stopped.lock().unwrap(), + vec![(7, true), (8, true)], + "both panes stopped, each after the tree marked the tab" + ); + let machine = store.machine(); + let asleep = &machine.workspaces[0].tabs; + assert!(asleep.iter().find(|t| t.id == tab.id).unwrap().hibernated); + assert!(!asleep.iter().find(|t| t.id == other.id).unwrap().hibernated); + for pane in [7, 8] { + assert!( + machine.panes.iter().any(|p| p.id == pane), + "pane {pane}'s record is what the wake restores from" + ); + } + + recorder.stopped.lock().unwrap().clear(); + let ReplyOk::Panes(_) = client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: false, + }) + .unwrap() + else { + panic!("TabSetHibernated must answer with the tab's panes"); + }; + assert!(recorder.stopped.lock().unwrap().is_empty()); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + } + + /// A peer with a tree and no panes could mark a tab asleep and leave its + /// shells running. It does not offer to, and refuses if asked anyway — + /// before the mark, so the tree is not left claiming a sleep that never + /// happened. + #[test] + fn a_peer_serving_no_panes_cannot_put_a_tab_to_sleep() { + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + let ws = store.workspace_create(None, None, None).unwrap(); + let tab = store.tab_create(ws.id, None, seed(7), None, None).unwrap(); + let client = client_with(Services::with_machine(Arc::clone(&store))); + assert!(client.hello().has_feature(feature::MACHINE_TREE)); + assert!(!client.hello().has_feature(feature::TAB_HIBERNATE)); + assert!( + client + .call(ControlRequest::TabSetHibernated { + workspace: ws.id, + tab: tab.id, + hibernated: true, + }) + .is_err() + ); + assert!(!store.machine().workspaces[0].tabs[0].hibernated); + } } #[cfg(test)] diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 6c0fac7f..ea365f54 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -324,7 +324,7 @@ resolve it immediately before use. A full tab UUID also works: `@`. | Command | Effect | JSON | |---|---|---| -| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…]}]}` | +| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","panes":[…],"hibernated"}]}` | | `tab new [WORKSPACE] [--cwd DIR]` | Add a tab with a fresh shell | `{"tab","pane"}` | | `tab new [WORKSPACE] --pane %PANE` | Add a tab around a pane already running | `{"tab","pane"}` | | `tab close @TAB` | Close the tab and every pane in it | `{"closed"}` | @@ -355,7 +355,7 @@ a stand-in. | Command | Effect | JSON | |---|---|---| -| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag | `{"panes":[…]}` | +| `pane ls [WORKSPACE]` | Panes with their workspace, tab, cwd, live flag (`asleep` for a hibernated tab's panes) | `{"panes":[…]}` | | `pane ls --all` | The server's whole pane registry, including orphans | `{"panes":[…],"orphans":N}` | | `pane split …` | Identical to top-level `split` | `{"pane"}` | | `pane close [%PANE…]` | Close panes; their shells are hung up | `{"closed":[…]}` | diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index 8013f2b1..c9ea38c2 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -107,7 +107,7 @@ Keybindings**: | Group | Actions | |---|---| -| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `ToggleTabSidebar` | +| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | | Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | | Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | | Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` | diff --git a/src/core/actions.rs b/src/core/actions.rs index 836838a9..c8e81210 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -30,6 +30,7 @@ actions!( RevealLinkUnderPointer, CopyLinkPathUnderPointer, MarkTabUnread, + HibernateTab, ForkAgentSession, ForkAgentSessionRight, ForkAgentSessionLeft, diff --git a/src/ui/app.rs b/src/ui/app.rs index 81c51245..33d8bde4 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -439,6 +439,25 @@ pub struct Tab { /// the tab no longer holds are dropped as the next one is written, so a /// closed pane leaves nothing behind either. focus_origin: std::collections::HashMap<(gpui::EntityId, Dir), gpui::EntityId>, + /// Set while the tab is asleep (#762): its panes were stopped and `pane` + /// is `Pane::Empty`. What is kept instead is what it takes to bring them + /// back — see [`Asleep`]. + pub(crate) asleep: Option, +} + +/// A sleeping tab: nothing of it runs, and everything a wake needs is here. +/// +/// `layout` is the tab as the restore path reads a tab — pane ids, cwds, +/// shells, agent sessions — so waking is that restore, run on one tab: each +/// pane's id asks the daemon for the screen it stored when the pane was +/// stopped, and an agent with a known session is resumed the way a reboot +/// resumes it. `view` is what the tab is called in the meantime, read off +/// the panes as they went to sleep (or off the tree's records, after a +/// restart), since there is no terminal left to ask. +pub(crate) struct Asleep { + pub(crate) layout: SessionPane, + pub(crate) view: tty7_core::core::tab_view::TabView, + pub(crate) home: Option, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -463,6 +482,7 @@ impl Tab { tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, } } @@ -484,9 +504,25 @@ impl Tab { tree_id: std::cell::Cell::new(tree.id), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, } } + pub(crate) fn is_asleep(&self) -> bool { + self.asleep.is_some() + } + + /// The layout a sleeping tab will wake into; `None` for a tab that is awake. + pub(crate) fn asleep_layout(&self) -> Option<&SessionPane> { + self.asleep.as_ref().map(|a| &a.layout) + } + + /// The agent a sleeping tab was running when it went to sleep, so its row + /// still says what it is while nothing in it can. + pub(crate) fn asleep_agent(&self) -> Option { + self.asleep.as_ref().and_then(|a| a.view.agent) + } + fn focus_target(&self) -> Option { match self.last_focused { Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id), @@ -585,6 +621,14 @@ impl Tab { Option, ) { let name = self.name.clone(); + if let Some(asleep) = &self.asleep { + let view = tty7_core::core::tab_view::TabView { + id: self.tree_id.get(), + name, + ..asleep.view.clone() + }; + return (view, asleep.home.clone()); + } let Some(leaf) = self.title_leaf(window, cx) else { return ( tty7_core::core::tab_view::TabView { @@ -1854,6 +1898,7 @@ impl Tty7App { self.tabs = tabs; self.active = active; self.maximized = None; + self.wake_active_if_asleep(window, cx); self.save_session(cx); crate::ui::windows::refresh_menu(cx); self.focus_active(window, cx); @@ -1897,6 +1942,7 @@ impl Tty7App { tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, }, ); self.active = insert_at; @@ -4799,6 +4845,13 @@ impl Tty7App { } pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + // A tab is woken by being looked at, the way a browser reloads a + // discarded tab when it is selected: a tab on screen is always a + // running one, so there is no third state to draw. A wake that fails + // leaves the tab asleep and the window where it was. + if self.tabs.get(index).is_some_and(Tab::is_asleep) && !self.wake_tab(index, window, cx) { + return; + } if index < self.tabs.len() && index != self.active { self.remember_active_pane(window, cx); // Zoom rides with its tab (#599): stash the outgoing tab's zoom @@ -4831,6 +4884,146 @@ impl Tty7App { } } + /// Whether tab `index` can be put to sleep right now (#762). + /// + /// Not while it is connecting: a pane still on its way has no id the + /// machine knows, so it would be lost rather than stopped. Not with a + /// native SSH pane in a remote window either — that pane runs on this + /// computer, beyond the reach of the machine asked to stop the tab. Not on + /// a machine whose server cannot do it. And not the one tab left awake: + /// the window has to have something on screen, and looking at a tab is + /// what wakes it. + pub(crate) fn can_hibernate_tab(&self, index: usize, cx: &App) -> bool { + let Some(tab) = self.tabs.get(index) else { + return false; + }; + if tab.is_asleep() || !crate::ui::tree_sync::can_hibernate_on(cx, self.workspace) { + return false; + } + let remote = WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()); + let leaves = tab.pane.leaves(); + let all_hibernatable = !leaves.is_empty() + && leaves.iter().all(|leaf| match leaf { + PaneSlot::Ready(view) => !(remote && view.read(cx).ssh_spec().is_some()), + PaneSlot::Connecting(_) => false, + }); + all_hibernatable && self.awake_tab_besides(index).is_some() + } + + /// The tab to show instead of `index` when that one goes to sleep: the + /// awake tab used most recently, the nearest one on a tie. + fn awake_tab_besides(&self, index: usize) -> Option { + (0..self.tabs.len()) + .filter(|&i| i != index && !self.tabs[i].is_asleep()) + .max_by_key(|&i| { + ( + self.tabs[i].last_used.get(), + std::cmp::Reverse(i.abs_diff(index)), + ) + }) + } + + /// Put tab `index` to sleep: stop its panes, keep its place (#762). + /// + /// The window lets go of the panes first and the machine stops them after, + /// from the sync this ends with. That order is what keeps a stopped pane + /// from reading as one that died: by the time its shell exits there is no + /// view left here to take the exit as a reason to close the pane, or the + /// tab with it. The machine, for its part, marks the tab before it stops + /// anything, so a restore reading the tree in between never takes the + /// panes for dead ones and spawns them straight back. + pub(crate) fn hibernate_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + if !self.can_hibernate_tab(index, cx) { + return; + } + if index == self.active { + let Some(next) = self.awake_tab_besides(index) else { + return; + }; + self.activate(next, window, cx); + } + self.put_to_sleep(index, window, cx); + } + + /// The part of [`Self::hibernate_tab`] that does not ask whether it may: + /// the tab lets go of its panes and keeps what a wake needs instead. + fn put_to_sleep(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get(index).filter(|t| !t.is_asleep()) else { + return; + }; + let (mut view, home) = tab.label_view(Some(window), cx); + view.live = false; + view.status = None; + let layout = pane_to_session(&tab.pane, cx); + let tab = &mut self.tabs[index]; + let panes = std::mem::replace(&mut tab.pane, Pane::Empty); + tab.zoomed = None; + tab.last_focused = None; + tab.focus_origin.clear(); + tab.asleep = Some(Asleep { layout, view, home }); + drop(panes); + self.save_session(cx); + cx.notify(); + } + + /// Bring a sleeping tab back, through the restore every pane takes after a + /// reboot: each pane is spawned in its old one's place, opens on the screen + /// that one left behind, and resumes its agent session when it had one. + /// Returns whether the tab is awake now; a tab none of whose panes could + /// be started stays asleep, and says so. + pub(crate) fn wake_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let Some(asleep) = self.tabs.get_mut(index).and_then(|t| t.asleep.take()) else { + return true; + }; + let pane_ws = self.window_workspace(cx); + let layout = redial_native_ssh(&asleep.layout); + // No listing: every pane in here was stopped on purpose, so the attach + // each one tries first fails and falls through to the fresh spawn — + // unless the stop never landed, in which case attaching to the pane + // that is still running is exactly right. + let pane = session_to_pane( + pane_ws.as_ref(), + self.workspace, + &layout, + None, + self.font_size, + window, + cx, + ); + let tab = &mut self.tabs[index]; + let Some(pane) = pane else { + tab.asleep = Some(asleep); + window.push_notification(t(L10nKey::TabWakeFailed), cx); + return false; + }; + tab.pane = pane; + tab.last_focused = None; + self.save_session(cx); + cx.notify(); + true + } + + /// Wake the tab on screen if it is asleep — for the paths that land on a + /// tab without going through [`Self::activate`]: a neighbour taking over + /// from a closed tab, a window restored onto a sleeping one. + pub(crate) fn wake_active_if_asleep(&mut self, window: &mut Window, cx: &mut Context) { + if self.tabs.get(self.active).is_some_and(Tab::is_asleep) { + self.wake_tab(self.active, window, cx); + } + } + /// Whether tab `index` has a pane zoomed over hidden siblings — what the /// chrome marks so the state is readable without toggling it (#752). /// @@ -4935,6 +5128,7 @@ impl Tty7App { } else if index < self.active { self.active -= 1; } + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); self.save_session(cx); cx.notify(); @@ -5730,6 +5924,7 @@ impl Tty7App { CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx), CopyWorkingDirectory => self.copy_active_cwd(window, cx), MarkTabUnread => self.mark_tab_unread(self.active, cx), + HibernateTab => self.hibernate_tab(self.active, window, cx), ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx), CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx), RenameWorkspace => self.start_workspace_rename(window, cx), @@ -9042,6 +9237,9 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| { this.mark_tab_unread(this.active, cx) })) + .on_action(cx.listener(|this, _: &HibernateTab, window, cx| { + this.hibernate_tab(this.active, window, cx) + })) .on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| { this.fork_active_pane_session(ForkPlacement::NewTab, window, cx) })) @@ -9150,9 +9348,14 @@ fn mru_order(stamps: &[u64], active: usize) -> Vec { fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { SessionTab { name: tab.name.clone(), - pane: pane_to_session(&tab.pane, cx), + pane: match tab.asleep_layout() { + Some(layout) => layout.clone(), + None => pane_to_session(&tab.pane, cx), + }, sidebar_group: tab.sidebar_group.borrow().clone(), tree_id: None, + hibernated: false, + asleep_view: None, } } @@ -9327,7 +9530,19 @@ fn tabs_from_session( let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace)); let mut tabs: Vec = Vec::with_capacity(session.tabs.len()); let mut dropped = 0usize; - for st in &session.tabs { + let home = crate::ui::path_display::home_for_host( + cx, + workspace.map_or(HostId::LOCAL, |w| w.target.host_id()), + ); + for (index, st) in session.tabs.iter().enumerate() { + // Asleep stays asleep: nothing is spawned or attached for it, which is + // the point. The one exception is the tab the window opens onto — a + // tab on screen is awake, so that one is woken here, by the same + // restore every other tab is getting. + if st.hibernated && index != session.active { + tabs.push(asleep_tab(st, home.clone())); + continue; + } let Some(pane) = session_to_pane( workspace, owner, @@ -9359,12 +9574,96 @@ fn tabs_from_session( ), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), + asleep: None, }); } let active = session.active.min(tabs.len().saturating_sub(1)); (tabs, active, dropped) } +/// A tab restored asleep: its place, its name and its group, and nothing +/// running behind them. +fn asleep_tab(st: &SessionTab, home: Option) -> Tab { + let mut tab = Tab::new(Pane::Empty); + tab.name = st.name.clone(); + *tab.sidebar_group.borrow_mut() = st.sidebar_group.clone(); + if let Some(id) = st.tree_id { + tab.tree_id.set(id); + } + tab.asleep = Some(Asleep { + layout: st.pane.clone(), + view: st + .asleep_view + .clone() + .unwrap_or_else(|| view_of_layout(tab.tree_id.get(), &st.pane)), + home, + }); + tab +} + +/// What to call a sleeping tab when the tree had nothing better to say: the +/// first directory in its layout, and the agent in it if there was one. +fn view_of_layout( + id: tty7_core::core::machine::TabId, + layout: &SessionPane, +) -> tty7_core::core::tab_view::TabView { + fn leaves<'a>(pane: &'a SessionPane, out: &mut Vec<&'a SessionPane>) { + match pane { + SessionPane::Leaf { .. } => out.push(pane), + SessionPane::Split { a, b, .. } => { + leaves(a, out); + leaves(b, out); + } + } + } + let mut all = Vec::new(); + leaves(layout, &mut all); + let cwd = all.iter().find_map(|leaf| match leaf { + SessionPane::Leaf { cwd, .. } => cwd.as_ref().map(|p| p.display().to_string()), + SessionPane::Split { .. } => None, + }); + let agent = all.iter().find_map(|leaf| match leaf { + SessionPane::Leaf { agent, .. } => *agent, + SessionPane::Split { .. } => None, + }); + tty7_core::core::tab_view::TabView { + id, + name: None, + title: String::new(), + osc_title: None, + cwd, + agent, + status: None, + live: false, + panes: all.len(), + } +} + +/// A sleeping layout as a wake should read it: a native SSH pane dials its +/// host again rather than standing on its old id. That id belongs to a pane +/// whose connection is gone, and the restore reading it would put a local +/// shell in its place. +fn redial_native_ssh(layout: &SessionPane) -> SessionPane { + match layout { + SessionPane::Leaf { + ssh_spec: Some(_), .. + } => { + let mut leaf = layout.clone(); + if let SessionPane::Leaf { pane_id, .. } = &mut leaf { + *pane_id = None; + } + leaf + } + SessionPane::Leaf { .. } => layout.clone(), + SessionPane::Split { axis, ratio, a, b } => SessionPane::Split { + axis: *axis, + ratio: *ratio, + a: Box::new(redial_native_ssh(a)), + b: Box::new(redial_native_ssh(b)), + }, + } +} + fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool { !(window_is_remote && leaf_is_native_ssh) } @@ -11156,6 +11455,7 @@ mod ssh_rebuild_gpui_tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; app.apply_layout_delta( &LayoutDelta::TabRestructured { tab, pane: None }, @@ -12668,3 +12968,112 @@ mod tab_focus_memory_tests { }); } } + +#[cfg(test)] +mod hibernate_gpui_tests { + use gpui::TestAppContext; + + use crate::core::session::SessionPane; + use crate::ui::app::test_window::harness_with_tabs; + use crate::ui::pane::Pane; + + fn leaf_pane_id(pane: &SessionPane) -> Option { + match pane { + SessionPane::Leaf { pane_id, .. } => *pane_id, + SessionPane::Split { .. } => None, + } + } + + /// Asleep, a tab holds nothing running and still holds its place: the + /// pane id the wake restores from, the name it had on screen, and a seat in + /// the sync that tells the machine it is asleep rather than gone. + #[gpui::test] + fn a_sleeping_tab_keeps_its_place_its_pane_and_its_name(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + let pane_id = app.tabs[1] + .pane + .first_leaf() + .and_then(|slot| slot.terminal().cloned()) + .expect("tab 1 has a pane") + .read(cx) + .pane_id; + let (before, _) = app.tabs[1].label_view(Some(window), cx); + + app.put_to_sleep(1, window, cx); + + assert_eq!(app.tabs.len(), 2, "sleeping is not closing"); + let tab = &app.tabs[1]; + assert!(tab.is_asleep()); + assert!(matches!(tab.pane, Pane::Empty)); + assert!(tab.pane.terminals().is_empty(), "nothing is left running"); + assert_eq!(tab.asleep_layout().and_then(leaf_pane_id), Some(pane_id)); + let (after, _) = tab.label_view(Some(window), cx); + assert_eq!( + format!("{:?}", after.label()), + format!("{:?}", before.label()), + "it is called what it was called while awake" + ); + assert!(!after.live); + + let (desired, _, held) = crate::ui::tree_sync::desired_tabs(app, cx); + assert!(held.is_empty(), "a sleeping tab is not a tab gone missing"); + assert_eq!(desired.len(), 2); + assert!(!desired[0].hibernated); + assert!(desired[1].hibernated); + match &desired[1].root { + crate::ui::tree_sync::DesiredNode::Leaf { pane, .. } => assert_eq!(*pane, pane_id), + other => panic!("expected the one leaf, got {other:?}"), + } + }); + } + + /// Looking at a tab is what wakes it, so the window has to keep one awake + /// to look at; the tab shown in a sleeping one's place is the awake tab + /// used last. + #[gpui::test] + fn the_last_awake_tab_has_nothing_to_hand_the_window_to(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + + app.update_in(&mut vcx, |app, window, cx| { + app.tabs[1].last_used.set(5); + app.tabs[2].last_used.set(9); + assert_eq!(app.awake_tab_besides(0), Some(2), "the one used last"); + + app.put_to_sleep(2, window, cx); + assert_eq!(app.awake_tab_besides(0), Some(1)); + + app.put_to_sleep(1, window, cx); + assert_eq!(app.awake_tab_besides(0), None); + assert!( + !app.can_hibernate_tab(0, cx), + "the last awake tab stays awake" + ); + assert!( + !app.can_hibernate_tab(1, cx), + "nor is a sleeping tab put to sleep twice" + ); + }); + } + + /// Closing a sleeping tab closes it like any other, and what Reopen Closed + /// Tab gets back is the layout it slept with — not the empty stand-in. + #[gpui::test] + fn a_sleeping_tab_closes_into_what_it_would_have_woken_as(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update_in(&mut vcx, |app, window, cx| { + app.put_to_sleep(1, window, cx); + let layout_id = app.tabs[1].asleep_layout().and_then(leaf_pane_id); + assert!(layout_id.is_some()); + + app.close_tab(1, window, cx); + + assert_eq!(app.tabs.len(), 1); + let closed = app.closed.last().expect("the closed tab is remembered"); + assert_eq!(leaf_pane_id(&closed.pane), layout_id); + assert!(!closed.hibernated, "reopening brings it back awake"); + }); + } +} diff --git a/src/ui/home.rs b/src/ui/home.rs index 3f972977..6f2903de 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -495,6 +495,8 @@ mod tests { name: Some("build".into()), tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("build")); @@ -506,6 +508,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -514,6 +518,8 @@ mod tests { name: Some(" ".into()), tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/work/getty")), }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); @@ -525,6 +531,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: SessionPane::Split { axis: crate::core::session::SessionAxis::Horizontal, ratio: 0.5, @@ -541,6 +549,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(None), }; assert_eq!(closed_tab_label(&unnamed), None); @@ -548,6 +558,8 @@ mod tests { name: None, tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(Some("/")), }; assert_eq!(closed_tab_label(&root), None); @@ -559,6 +571,8 @@ mod tests { name: Some("a".repeat(40)), tree_id: None, sidebar_group: None, + hibernated: false, + asleep_view: None, pane: leaf(None), }; let label = closed_tab_label(&tab).unwrap(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 93a7e79a..a26c80ee 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1528,6 +1528,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdForkSession => "Fork Session", L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab", L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread", + L10nKey::CmdHibernateTab => "Hibernate Tab", + L10nKey::CmdHibernateTabSubtitle => { + "stop its processes to free memory; selecting it wakes it" + } L10nKey::CmdClosePaneTab => "Close Pane / Tab", L10nKey::CmdCloseWindow => "Close Window", L10nKey::CmdCloseWindowSubtitle => "shells keep running", @@ -1921,6 +1925,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsNoneLower => "none", L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool", L10nKey::TabContextMarkUnread => "Mark as Unread", + L10nKey::TabContextHibernate => "Hibernate", + L10nKey::TabContextWake => "Wake", + L10nKey::TabTooltipAsleep => "Hibernated — select to wake", + L10nKey::TabWakeFailed => "Could not wake the tab: none of its panes could be started", } } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 1be8a435..7d3dc051 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1592,6 +1592,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdForkSession => "セッションをフォーク", L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク", L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク", + L10nKey::CmdHibernateTab => "タブを休止", + L10nKey::CmdHibernateTabSubtitle => "プロセスを停止してメモリを解放。選択すると復帰", L10nKey::CmdClosePaneTab => "ペイン / タブを閉じる", L10nKey::CmdCloseWindow => "ウィンドウを閉じる", L10nKey::CmdCloseWindowSubtitle => "シェルは実行を継続", @@ -1997,6 +1999,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNoneLower => "なし", L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール", L10nKey::TabContextMarkUnread => "未読としてマーク", + L10nKey::TabContextHibernate => "休止", + L10nKey::TabContextWake => "復帰", + L10nKey::TabTooltipAsleep => "休止中 — 選択すると復帰", + L10nKey::TabWakeFailed => "タブを復帰できませんでした:どのペインも起動できません", }) } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 196ff2c2..9517a836 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1085,6 +1085,10 @@ l10n_keys! { TabContextCloseTab, TabContextCloseTabsBelow, TabContextMarkUnread, + TabContextHibernate, + TabContextWake, + TabTooltipAsleep, + TabWakeFailed, RemoteStripDisconnected, RemoteStripConnecting, RemoteStripReconnecting, @@ -1230,6 +1234,8 @@ l10n_keys! { CmdForkSession, CmdForkSessionSubtitle, CmdMarkTabAsUnread, + CmdHibernateTab, + CmdHibernateTabSubtitle, CmdClosePaneTab, CmdCloseWindow, CmdCloseWindowSubtitle, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index c28fab39..a501716e 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1435,6 +1435,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdForkSession => "Fork 会话", L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页", L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读", + L10nKey::CmdHibernateTab => "休眠标签页", + L10nKey::CmdHibernateTabSubtitle => "停止其中的进程以释放内存,选中即可唤醒", L10nKey::CmdClosePaneTab => "关闭窗格/标签页", L10nKey::CmdCloseWindow => "关闭窗口", L10nKey::CmdCloseWindowSubtitle => "shell 保持运行", @@ -1816,6 +1818,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNoneLower => "无", L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具", L10nKey::TabContextMarkUnread => "标记为未读", + L10nKey::TabContextHibernate => "休眠", + L10nKey::TabContextWake => "唤醒", + L10nKey::TabTooltipAsleep => "已休眠 — 选中即可唤醒", + L10nKey::TabWakeFailed => "无法唤醒标签页:其中的窗格都无法启动", }) } diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 6d3ec02f..e9c93fe8 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -325,6 +325,7 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("CloseTabsToTheRight", ""), ("CopyWorkingDirectory", ""), ("MarkTabUnread", ""), + ("HibernateTab", ""), ("ForkAgentSession", ""), ("ForkAgentSessionRight", ""), ("ForkAgentSessionLeft", ""), @@ -614,6 +615,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::TabsPanes, t(L10nKey::CmdMarkTabAsUnread).to_string(), ), + "HibernateTab" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdHibernateTab).to_string(), + ), "ReopenClosedTab" => ( CommandGroup::TabsPanes, t(L10nKey::CmdReopenClosedTab).to_string(), @@ -1359,6 +1364,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "CloseTabsToTheRight" => KeyBinding::new(keystroke, CloseTabsToTheRight, None), "CopyWorkingDirectory" => KeyBinding::new(keystroke, CopyWorkingDirectory, None), "MarkTabUnread" => KeyBinding::new(keystroke, MarkTabUnread, None), + "HibernateTab" => KeyBinding::new(keystroke, HibernateTab, None), "ForkAgentSession" => KeyBinding::new(keystroke, ForkAgentSession, None), "ForkAgentSessionRight" => KeyBinding::new(keystroke, ForkAgentSessionRight, None), "ForkAgentSessionLeft" => KeyBinding::new(keystroke, ForkAgentSessionLeft, None), diff --git a/src/ui/local_link.rs b/src/ui/local_link.rs index 8756fb6f..57e57c69 100644 --- a/src/ui/local_link.rs +++ b/src/ui/local_link.rs @@ -49,6 +49,15 @@ impl LocalLink { link.client.as_ref().filter(|c| c.is_connected()).cloned() } + /// Whether the local daemon advertised `feature` on its control hello — + /// [`HostLinks::peer_supports`](crate::ui::remote_connect::HostLinks::peer_supports) + /// for this machine. `false` while the link is down, for the same reason. + pub fn supports(cx: &App, feature: &str) -> bool { + cx.try_global::() + .and_then(|link| link.client.as_ref()) + .is_some_and(|c| c.is_connected() && c.hello().has_feature(feature)) + } + /// Drops the cached client without waiting for its reader to notice. /// /// `ControlClient::is_connected` only flips once the reader sees EOF, so diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 17daf72a..90245d01 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -935,6 +935,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }; assert!(apply( &mut machine, diff --git a/src/ui/palette.rs b/src/ui/palette.rs index a673e772..334edd3e 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -33,6 +33,7 @@ pub enum CommandKind { CloseTabsToTheRight, CopyWorkingDirectory, MarkTabUnread, + HibernateTab, ForkAgentSession, CopyAgentSessionId, ResetFontSize, @@ -141,6 +142,7 @@ impl CommandKind { CloseTabsToTheRight => "close-tabs-right", CopyWorkingDirectory => "copy-cwd", MarkTabUnread => "mark-tab-unread", + HibernateTab => "hibernate-tab", ForkAgentSession => "fork-agent-session", CopyAgentSessionId => "copy-agent-session-id", ResetFontSize => "reset-font-size", @@ -253,6 +255,7 @@ impl CommandKind { CloseTabsToTheRight => "CloseTabsToTheRight", CopyWorkingDirectory => "CopyWorkingDirectory", MarkTabUnread => "MarkTabUnread", + HibernateTab => "HibernateTab", ForkAgentSession => "ForkAgentSession", CopyAgentSessionId => "CopyAgentSessionId", ResetFontSize => "ResetFontSize", @@ -474,6 +477,8 @@ impl Command { Command::localized(L10nKey::CmdForkSession, ForkAgentSession) .with_subtitle(t(L10nKey::CmdForkSessionSubtitle)), Command::localized(L10nKey::CmdMarkTabAsUnread, MarkTabUnread), + Command::localized(L10nKey::CmdHibernateTab, HibernateTab) + .with_subtitle(t(L10nKey::CmdHibernateTabSubtitle)), Command::localized(L10nKey::CmdClosePaneTab, ClosePane), Command::localized(L10nKey::CmdCloseOtherTabs, CloseOtherTabs), Command::localized(L10nKey::CmdCloseTabsToTheRight, CloseTabsToTheRight), diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 0d6a7848..7ef995d4 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -463,7 +463,8 @@ impl Tty7App { let tab = &self.tabs[i]; let is_active = i == active; let ssh_dot = self.tab_ssh_dot(tab, cx); - let agent = tab.agent(cx); + let asleep = tab.is_asleep(); + let agent = tab.agent(cx).or_else(|| tab.asleep_agent()); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); let git_cwd = git_click(tab, window, cx); @@ -478,6 +479,13 @@ impl Tty7App { } else { 0. }; + // The sleep mark takes the zoom mark's size; a sleeping tab + // has no panes on screen to zoom, so the two never share a row. + let zoom_extra = if asleep { + zoom_extra + row_metrics::ZOOM + row_metrics::GAP + } else { + zoom_extra + }; // Elision is measured against this budget so the label and // branch never wrap or overflow into CSS truncation. let label_avail = @@ -889,6 +897,9 @@ impl Tty7App { s.text_color(cx.theme().sidebar_foreground) .hover(|s| s.bg(gpui::rgb(sf.hover))) }) + // Faded as well as marked, the way the strip's chip is: the + // tabs holding nothing should be the quiet ones in the column. + .when(asleep, |s| s.opacity(0.6)) .when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| { s.opacity(0.75) }) @@ -937,6 +948,9 @@ impl Tty7App { .when(zoomed, |row| { row.child(self.zoom_mark(("sidebar-zoom", i), cx)) }) + .when(asleep, |row| { + row.child(self.sleep_mark(("sidebar-asleep", i), cx)) + }) .child(label_region) .when(show_badges && badge_pos < 9, |row| { row.child( diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 769ea1cc..4f0acd3c 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -13,9 +13,10 @@ use unicode_segmentation::UnicodeSegmentation as _; use crate::core::actions::{ CloseActiveTab, CloseOtherTabs, CloseTabsToTheRight, CopyAgentSessionId, CopyWorkingDirectory, - ForkAgentSession, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, SelectWorkspace1, - SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, SelectWorkspace6, - SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, TogglePalette, + ForkAgentSession, HibernateTab, MarkTabUnread, NewWorktreeTab, OpenSettings, RenameTab, + SelectWorkspace1, SelectWorkspace2, SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, + SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, + TogglePalette, }; use crate::core::config::{Config, RightPanelTab, SidebarGrouping}; use crate::core::group_key::GroupKey; @@ -1486,6 +1487,24 @@ impl Tty7App { /// Drawn in the tab entry rather than on the pane so it reads from either /// tab surface, and so it says something about the tabs you are *not* /// looking at — the zoom outlives a switch away from them. + /// The mark a sleeping tab carries (#762), in the slot the zoom mark uses: + /// leading, beside the other state marks, where the pointer that comes to + /// read it does not cover it. + pub(crate) fn sleep_mark(&self, id: impl Into, cx: &App) -> gpui::AnyElement { + let tip = SharedString::from(t(L10nKey::TabTooltipAsleep)); + div() + .id(id) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .size(px(16.)) + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::Moon).size(px(11.))) + .tooltip(move |window, cx| Tooltip::new(tip.clone()).build(window, cx)) + .into_any_element() + } + pub(crate) fn zoom_mark(&self, id: impl Into, cx: &App) -> gpui::AnyElement { let tip = chord_tooltip(t(L10nKey::TabTooltipZoomed), "ToggleMaximizePane", cx); div() @@ -1647,6 +1666,34 @@ impl Tty7App { ); } + // Sleep and wake (#762). Waking is also what selecting the tab does; + // the item is here for waking one without leaving the tab on screen. + // Hibernate is offered only where the machine can do it, and greyed + // out for a tab it cannot be done to right now — the last one awake, + // or one still connecting — rather than vanishing from under the + // pointer that came looking for it. + if tab.is_some_and(|t| t.is_asleep()) { + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabContextWake)).on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.wake_tab(index, window, cx)); + } + })); + } else if crate::ui::tree_sync::can_hibernate_on(cx, this.workspace) { + menu = menu.item( + PopupMenuItem::new(t(L10nKey::TabContextHibernate)) + .action(Box::new(HibernateTab)) + .disabled(!this.can_hibernate_tab(index, cx)) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.hibernate_tab(index, window, cx)); + } + }), + ); + } + // Where this tab sits, and where it could be put instead. // // Laid out flat rather than behind a "Move to Group ▸" submenu: there @@ -1958,7 +2005,8 @@ impl Tty7App { let label = self.tab_label(tab, i, Some(window), cx); let full_title = self.tab_title_tooltip(tab, i, Some(window), cx); let ssh_dot = self.tab_ssh_dot(tab, cx); - let agent = tab.agent(cx); + let asleep = tab.is_asleep(); + let agent = tab.agent(cx).or_else(|| tab.asleep_agent()); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); let zoomed = self.tab_is_zoomed(i); @@ -2038,6 +2086,9 @@ impl Tty7App { s.text_color(cx.theme().muted_foreground) .hover(|s| s.bg(cx.theme().muted)) }) + // Faded as well as marked: a row of chips is read at a glance, + // and the tabs holding nothing should be the quiet ones. + .when(asleep, |s| s.opacity(0.6)) .when(dragged, |s| s.opacity(0.75)) .child( canvas( @@ -2096,6 +2147,9 @@ impl Tty7App { .when(zoomed, |chip| { chip.child(self.zoom_mark(("tab-zoom", i), cx)) }) + .when(asleep, |chip| { + chip.child(self.sleep_mark(("tab-asleep", i), cx)) + }) .child(label_region) .when(show_badges && i < 9, |chip| { chip.child( diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index be7f0ef3..b98e70b7 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -51,6 +51,18 @@ fn classify_tree_link(client: Option>) -> TreeLink { } } +/// Whether the machine holding `client_ws` can put one of its tabs to sleep. +/// A machine that cannot would take the mark and leave the shells running, or +/// refuse it outright, so a window never offers to on its behalf. +pub(crate) fn can_hibernate_on(cx: &App, client_ws: WorkspaceId) -> bool { + let feature = tty7_core::daemon::control::feature::TAB_HIBERNATE; + let host = WorkspaceStore::host_of(cx, client_ws); + match host.is_local() { + true => crate::ui::local_link::LocalLink::supports(cx, feature), + false => crate::ui::remote_connect::HostLinks::peer_supports(cx, host, feature), + } +} + fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { WorkspaceStore::all(cx) .get(client_ws) @@ -65,6 +77,9 @@ pub(crate) struct DesiredTab { pub name: Option, pub group: Option, pub root: DesiredNode, + /// The tab is asleep, and `root` is the layout it will wake into — the + /// same pane ids the machine already holds, none of them running. + pub hibernated: bool, } #[derive(Debug, Clone)] @@ -120,7 +135,15 @@ pub(crate) fn desired_tabs( let mut active = None; let mut held = Vec::new(); for (index, tab) in app.tabs.iter().enumerate() { - let Some(root) = desired_node(&tab.pane, remote, cx) else { + // A sleeping tab has nothing on screen to read its layout off, so it + // is read off what it will wake into: the same panes, by the same ids, + // which is what keeps the diff below from taking it for a tab that + // lost them all. + let root = match tab.asleep_layout() { + Some(layout) => desired_node_from_session(layout, remote), + None => desired_node(&tab.pane, remote, cx), + }; + let Some(root) = root else { if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) { held.push(tab.tree_id.get()); } @@ -135,11 +158,66 @@ pub(crate) fn desired_tabs( name: tab.name.clone(), group: tab.sidebar_group.borrow().as_ref().map(GroupKey::encode), root, + hibernated: tab.asleep_layout().is_some(), }); } (out, active, held) } +/// [`desired_node`] for a layout that is only a record — a sleeping tab's. +/// A leaf with no id is one the machine never had, and a native SSH leaf in a +/// remote window lives on this client rather than on the window's machine; +/// neither is the machine's to hold, exactly as for a live tab. +fn desired_node_from_session(layout: &SessionPane, remote_window: bool) -> Option { + match layout { + SessionPane::Leaf { + cwd, + pane_id, + shell, + ssh_spec, + agent, + agent_session_id, + agent_launch_argv, + } => { + let pane = (*pane_id)?; + if remote_window && ssh_spec.is_some() { + return None; + } + Some(DesiredNode::Leaf { + pane, + seed: PaneSeed { + pane, + cwd: cwd.as_ref().map(|p| p.to_string_lossy().into_owned()), + ssh_spec: ssh_spec.clone(), + agent: agent.map(|agent| AgentFacts { + agent, + session_id: agent_session_id.clone(), + launch_argv: agent_launch_argv.clone(), + status: None, + }), + shell: shell.clone(), + }, + }) + } + SessionPane::Split { axis, ratio, a, b } => { + let left = desired_node_from_session(a, remote_window); + let right = desired_node_from_session(b, remote_window); + match (left, right) { + (Some(a), Some(b)) => Some(DesiredNode::Split { + axis: match axis { + crate::core::session::SessionAxis::Horizontal => TreeAxis::Horizontal, + crate::core::session::SessionAxis::Vertical => TreeAxis::Vertical, + }, + ratio: *ratio, + a: Box::new(a), + b: Box::new(b), + }), + (one, other) => one.or(other), + } + } + } +} + fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool { match pane { Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(), @@ -504,6 +582,13 @@ fn create_tab( group: want.group.clone(), }); } + if want.hibernated { + ops.push(ControlRequest::TabSetHibernated { + workspace, + tab: want.id, + hibernated: true, + }); + } mirror.tabs.insert( index.min(mirror.tabs.len()), TreeTab { @@ -511,6 +596,7 @@ fn create_tab( name: want.name.clone(), sidebar_group: want.group.clone(), root, + hibernated: want.hibernated, }, ); mirror.active = Some(want.id); @@ -565,6 +651,18 @@ fn reconcile_tab( group: want.group.clone(), }); } + // Ahead of anything structural. Going to sleep changes nothing else + // about the tab; waking swaps every pane for its successor, which may + // take a close-and-recreate below — and the mark has to come off the + // tab that exists now, before that one is gone. + if tab.hibernated != want.hibernated { + tab.hibernated = want.hibernated; + ops.push(ControlRequest::TabSetHibernated { + workspace, + tab: want.id, + hibernated: want.hibernated, + }); + } } let desired_root = want.root.to_pane_node(); @@ -1584,14 +1682,18 @@ pub(crate) fn session_from_tree( ws: &tty7_core::core::machine::Workspace, panes: &[PaneRecord], ) -> Session { + let views = tty7_core::core::tab_view::tab_views_of(ws, panes); let tabs: Vec = ws .tabs .iter() - .map(|tab| SessionTab { + .zip(views) + .map(|(tab, view)| SessionTab { name: tab.name.clone(), tree_id: Some(tab.id), sidebar_group: tab.sidebar_group.as_deref().and_then(GroupKey::decode), pane: session_pane_from_node(&tab.root, panes), + hibernated: tab.hibernated, + asleep_view: tab.hibernated.then_some(view), }) .collect(); let active = ws @@ -2648,6 +2750,13 @@ impl Tty7App { } true } + // A tab arriving asleep, or one that someone else put to sleep or + // woke, is not a shape to graft onto this window: building it here + // would spawn what is meant to stay stopped, or leave running what + // was just stopped. Saying the delta did not apply re-pulls the + // tree, and the restore that follows is the one path that already + // knows what a sleeping tab is. + LayoutDelta::TabCreated { tab, .. } if tab.hibernated => false, LayoutDelta::TabCreated { at, tab } => { self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx) } @@ -2659,6 +2768,7 @@ impl Tty7App { .and_then(|id| index_of(&self.tabs, id)) .unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1))); self.maximized = None; + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); } true @@ -2690,6 +2800,7 @@ impl Tty7App { true } LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) { + Some(index) if tab.hibernated || self.tabs[index].is_asleep() => false, Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx), None => false, }, @@ -2716,6 +2827,7 @@ impl Tty7App { } self.maximized = None; self.active = index; + self.wake_active_if_asleep(window, cx); self.focus_active(window, cx); } @@ -3441,6 +3553,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; assert!( @@ -4079,12 +4192,14 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }, TreeTab { id: failed, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 2 }, + hibernated: false, }, ], active: Some(put_up), @@ -4169,12 +4284,14 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 11 }, + hibernated: false, }, TreeTab { id: theirs.1, name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 12 }, + hibernated: false, }, ], active: Some(theirs.0), @@ -4309,6 +4426,7 @@ mod tests { name: None, group: None, root, + hibernated: false, } } @@ -4973,6 +5091,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }; assert!(apply_to_mirror( &mut watcher, @@ -4998,6 +5117,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }, pane: None, }, @@ -5067,6 +5187,7 @@ mod tests { a: Box::new(PaneNode::Leaf { pane: 1 }), b: Box::new(PaneNode::Leaf { pane: 2 }), }, + hibernated: false, }], active_tab: Some(tab_id), ..Default::default() @@ -5157,6 +5278,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 7 }, + hibernated: false, }], active_tab: Some(tab_id), ..Default::default() @@ -5186,6 +5308,7 @@ mod tests { name: None, sidebar_group: None, root: PaneNode::Leaf { pane: 1 }, + hibernated: false, }], active_tab: Some(TabId::new()), ..Default::default() @@ -5215,4 +5338,247 @@ mod tests { "got {ops:?}" ); } + + fn asleep(id: TabId, root: DesiredNode) -> DesiredTab { + DesiredTab { + hibernated: true, + ..tab(id, root) + } + } + + /// Going to sleep changes nothing about the tab's shape — the same panes, + /// by the same ids — so the only thing the machine hears is the mark. A + /// tab that lost its panes would have been a close, and the close would + /// have taken every record the wake needs with it. + #[test] + fn a_tab_going_to_sleep_says_so_and_nothing_else() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let root = || split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)); + diff( + ws, + &mut mirror, + &[tab(id, root())], + Some(id), + SyncScope::Full, + &[], + ); + + let ops = diff( + ws, + &mut mirror, + &[asleep(id, root())], + Some(id), + SyncScope::Full, + &[], + ); + assert_eq!( + ops, + vec![ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: true, + }] + ); + assert!(mirror.tabs[0].hibernated); + assert!( + diff( + ws, + &mut mirror, + &[asleep(id, root())], + Some(id), + SyncScope::Full, + &[] + ) + .is_empty(), + "a tab that stays asleep is not told again" + ); + } + + /// Waking swaps each stopped pane for its successor. The mark comes off + /// first, while the tab it belongs to is certainly still the one the + /// machine has. + #[test] + fn a_waking_tab_clears_its_mark_before_its_panes_are_replaced() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[asleep(id, leaf(1))], + Some(id), + SyncScope::Full, + &[], + ); + + let want = vec![tab(id, leaf(9))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: false, + }, + ControlRequest::PaneReplace { + workspace: ws, + old: 1, + new: seed(9), + }, + ] + ); + assert!(!mirror.tabs[0].hibernated); + assert_converged(&mirror, &want); + } + + /// A window writing a sleeping tab onto a machine that lost it — a store + /// wiped under a remote workspace — writes it back asleep, not as a tab + /// the next restore would spawn. + #[test] + fn a_tab_created_asleep_is_marked_as_it_is_created() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let ops = diff( + ws, + &mut mirror, + &[asleep(id, leaf(4))], + Some(id), + SyncScope::Full, + &[], + ); + assert!( + matches!(ops.first(), Some(ControlRequest::TabCreate { .. })), + "{ops:?}" + ); + assert!( + ops.contains(&ControlRequest::TabSetHibernated { + workspace: ws, + tab: id, + hibernated: true, + }), + "{ops:?}" + ); + assert!(mirror.tabs[0].hibernated); + } + + /// The window reads a sleeping tab's layout off the record it kept, and + /// has to hand the machine the very tab the machine already holds: same + /// ids, same seeds, same shape. + #[test] + fn a_sleeping_layout_reads_back_as_the_tree_it_came_from() { + use crate::core::session::SessionAxis; + let leaf_of = |pane: u64, cwd: &str| SessionPane::Leaf { + cwd: Some(std::path::PathBuf::from(cwd)), + pane_id: Some(pane), + shell: None, + ssh_spec: None, + agent: Some(crate::core::cli_agent::CLIAgent::Claude), + agent_session_id: Some(format!("sess-{pane}")), + agent_launch_argv: None, + }; + let layout = SessionPane::Split { + axis: SessionAxis::Horizontal, + ratio: 0.3, + a: Box::new(leaf_of(3, "/a")), + b: Box::new(leaf_of(4, "/b")), + }; + let node = desired_node_from_session(&layout, false).expect("both leaves have ids"); + assert_eq!( + node.to_pane_node(), + PaneNode::Split { + axis: TreeAxis::Horizontal, + ratio: 0.3, + a: Box::new(PaneNode::Leaf { pane: 3 }), + b: Box::new(PaneNode::Leaf { pane: 4 }), + } + ); + let seed = node.seed_of(4).expect("pane 4 is a leaf"); + assert_eq!(seed.cwd.as_deref(), Some("/b")); + assert_eq!( + seed.agent.as_ref().and_then(|a| a.session_id.as_deref()), + Some("sess-4"), + "the session a wake resumes stays on the record" + ); + + // A leaf the machine never had is left out, as for a live tab. + let unknown = SessionPane::Split { + axis: SessionAxis::Vertical, + ratio: 0.5, + a: Box::new(leaf_of(3, "/a")), + b: Box::new(SessionPane::Leaf { + cwd: None, + pane_id: None, + shell: None, + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + }), + }; + assert_eq!( + desired_node_from_session(&unknown, false).map(|n| n.to_pane_node()), + Some(PaneNode::Leaf { pane: 3 }) + ); + } + + /// A restore reads the mark off the tree: the sleeping tab keeps its pane + /// ids — they are what the wake restores from — and comes with a name read + /// off the tree's records, since nothing in it will be running to say one. + #[test] + fn a_restore_brings_a_sleeping_tab_back_asleep_and_named() { + let (awake, sleeping) = (TabId::new(), TabId::new()); + let ws = tty7_core::core::machine::Workspace { + tabs: vec![ + TreeTab { + id: awake, + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 1 }, + hibernated: false, + }, + TreeTab { + id: sleeping, + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 2 }, + hibernated: true, + }, + ], + active_tab: Some(awake), + ..Default::default() + }; + let panes = vec![ + PaneRecord { + cwd: Some("/work".into()), + live: true, + ..PaneRecord::new(1) + }, + PaneRecord { + cwd: Some("/work/api".into()), + osc_title: Some("fixing the switcher".into()), + live: false, + ..PaneRecord::new(2) + }, + ]; + + let session = session_from_tree(&ws, &panes); + assert!(!session.tabs[0].hibernated); + assert!(session.tabs[0].asleep_view.is_none()); + let tab = &session.tabs[1]; + assert!(tab.hibernated); + match &tab.pane { + SessionPane::Leaf { pane_id, cwd, .. } => { + assert_eq!(*pane_id, Some(2)); + assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api"))); + } + _ => panic!("leaf"), + } + let view = tab.asleep_view.as_ref().expect("a sleeping tab is named"); + assert_eq!(view.osc_title.as_deref(), Some("fixing the switcher")); + assert!(!view.live); + } } From 27483e893da7b1bcc13f16a59415fb05a3587f13 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:44:32 +0800 Subject: [PATCH 10/20] feat(ui): collapse the New Tab menu's shell list to three by frecency (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + menu listed every shell the machine reports (nine on a stock macOS box) above the SSH hosts. The Local section now names the default shell, always first, then only shells that have actually been opened, by frecency, three rows at most, the same way the SSH section caps its hosts. Shell usage is recorded in a new `shell_frecency` config map, keyed by the inventory label, bumped from both the menu row and the palette. Every shell is now a palette command titled "Shell: {label}" (same word in every locale), and an "Other Shells…" row opens the palette pre-filtered to them. That row is hidden when the menu already names the whole inventory. Running a shell command from the palette respects the ⌥/Alt split modifier like the menu row. --- CHANGELOG.md | 11 ++ crates/tty7-core/src/core/config.rs | 7 + docs/reference/configuration.mdx | 7 +- docs/window/command-palette.mdx | 2 +- src/ui/app.rs | 30 +++ src/ui/i18n/en.rs | 2 + src/ui/i18n/ja.rs | 2 + src/ui/i18n/mod.rs | 3 + src/ui/i18n/zh.rs | 2 + src/ui/palette.rs | 5 + src/ui/tab_strip.rs | 289 +++++++++++++++++++++++++--- 11 files changed, 331 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea729d45..e6cab309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The New Tab menu names three shells, not every one the machine has.** A + stock macOS box reports nine, so the `+` menu opened on a column of `csh`, + `tcsh` and `ksh` that almost nobody runs, above the SSH hosts people came for. + The Local section now lists the default shell, always first, and then only + shells that have actually been opened, by frecency — three rows at most, the + way the SSH section already caps its hosts. The rest sit behind an "Other + Shells…" row that opens the command palette filtered to them: every shell is + now a palette command, "Shell: {name}", and running one does what its menu + row does, splitting instead when ⌥ (Alt) is held. The row is left out when + the menu already names the whole list. + - **Saved SSH hosts live in `servers.json`, beside `config.json`** (#911), so `config.json` can be synced between machines for its colours and keys without carrying a list of servers. `ssh_profiles` and diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 456c7700..577b39ea 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -404,6 +404,12 @@ pub struct Config { /// beside them. #[serde(default, skip_serializing)] pub ssh_profile_frecency: HashMap, + /// How often and how lately each shell was opened from the New Tab menu + /// or its palette command, keyed by the label the inventory shows it + /// under. Orders the menu's short list of shells the way + /// `ssh_profile_frecency` orders its hosts. + #[serde(default)] + pub shell_frecency: HashMap, #[serde(default)] pub command_frecency: HashMap, @@ -790,6 +796,7 @@ impl Default for Config { verify_host_keys: true, ssh_warn_on_close: false, ssh_profile_frecency: HashMap::new(), + shell_frecency: HashMap::new(), command_frecency: HashMap::new(), agent_commands: HashMap::new(), restore_agent_sessions: true, diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 33429065..7df8203e 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -113,7 +113,7 @@ their id from the file name. [More about themes →](/customization/themes) | Key | Type | Default | | |---|---|---|---| | `shell` | object | — | `{"program": "fish", "args": ["-l"]}`. Unset uses the platform default. `args` you write are launched verbatim, which also turns off [shell integration](/reference/shell-integration) for that shell — leave them out to keep it. | -| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. | +| `custom_shells` | array | `[]` | Extra entries for the new-tab menu: `[{"label": "Ubuntu", "program": "wsl.exe", "args": ["-d", "Ubuntu"]}]`. Launched exactly as written, listed after the detected shells — in the menu's **Other Shells…** list until you have opened it, and in the palette as *Shell:* followed by its label. Give an entry arguments and tty7 also skips [shell integration](/reference/shell-integration) for it, so it has no prompt marks, working-directory tracking, or command-finished notifications; an entry with no arguments, on a shell tty7 recognizes, is integrated like the detected row beside it. An entry with no `program` is skipped; with no `label` it is named after its program. | | `working_directory` | object | `{"strategy":"inherit"}` | `strategy` is `inherit`, `home`, or `custom`; `path` is used when custom. | | `env` | object | `{}` | Extra environment variables for every pane. | | `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. | @@ -228,8 +228,9 @@ Each object in `ssh_profiles` can also set: ## Keys tty7 manages itself -`command_frecency` records how often and how recently you run a command, so the -palette can rank it. It is written by the app; there is no reason to edit it. +`shell_frecency` and `command_frecency` record how often and how recently you +open a shell or run a command, so the new-tab menu and the palette can rank +them. They are written by the app; there is no reason to edit them. If the file cannot be parsed, tty7 starts on defaults, keeps your original at diff --git a/docs/window/command-palette.mdx b/docs/window/command-palette.mdx index a52aa348..ac098dca 100644 --- a/docs/window/command-palette.mdx +++ b/docs/window/command-palette.mdx @@ -20,7 +20,7 @@ Results are grouped, and the groups are the map of the app: | Group | Examples | |---|---| -| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | +| **Tabs & Panes** | New Tab · Shell: zsh · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | | **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… | | **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff | | **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch | diff --git a/src/ui/app.rs b/src/ui/app.rs index 33d8bde4..f09045f1 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5711,6 +5711,31 @@ impl Tty7App { }, ); + // Every shell the window's machine has, named alike in every language + // so the New Tab menu's "Other Shells…" row can land on exactly these + // by typing one word. Listed in the order that menu uses. + { + let default_shell = self.default_shell_label(cx); + let usage = &cx.global::().shell_frecency; + let now = crate::core::config::unix_now(); + for s in crate::ui::tab_strip::shells_by_frecency( + &self.shells.shells, + &default_shell, + usage, + now, + ) { + let mut cmd = Command::new( + t_fmt(L10nKey::AppCmdShellTitle, &[("title", &s.label)]), + CommandKind::OpenShell(s.label.clone()), + ) + .in_group(CommandGroup::TabsPanes); + if s.label == default_shell { + cmd = cmd.with_subtitle(t(L10nKey::ShellDefault)); + } + commands.push(cmd); + } + } + // Offered only where it would do something. A connection opened from a // saved host has nothing to save, and a pane that is not an SSH one has // no connection at all — either would be a row that quietly did nothing @@ -5967,6 +5992,11 @@ impl Tty7App { } OpenSshConnect(input) => self.open_typed_ssh_connect(&input, window, cx), ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx), + // ⌥ held as the row is taken splits, as it does on the menu's row. + OpenShell(label) => { + let at = SpawnWhere::from_modifiers(window.modifiers()); + self.open_listed_shell(&label, at, window, cx) + } EditSavedProfile(id) => self.open_ssh_profile_in_settings(id, window, cx), QuickConnect(target) => { if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) { diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a26c80ee..2f275720 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1690,6 +1690,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "No uncommitted changes in {cwd} (or not a git repository)." } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "Switch to Tab: {label}", L10nKey::AppPlaceholderDescription => "description", L10nKey::AppPlaceholderSshQuickConnect => "user@host or user@host:port", @@ -1906,6 +1907,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabMenuLocalShells => "Local", L10nKey::TabMenuAddHost => "Add SSH Host…", L10nKey::TabMenuAllHosts => "All SSH Hosts…", + L10nKey::TabMenuOtherShells => "Other Shells…", L10nKey::TabMenuSplitHint => "Hold {key} to split", L10nKey::TabUnnamedShell => "Shell {n}", L10nKey::ShellDefault => "default", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 7d3dc051..33304b5f 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1760,6 +1760,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "{cwd} に未コミットの変更はありません(または git リポジトリではありません)" } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "タブに切り替え: {label}", L10nKey::AppPlaceholderDescription => "説明", L10nKey::AppPlaceholderSshQuickConnect => "user@host または user@host:port", @@ -1980,6 +1981,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "ローカル", L10nKey::TabMenuAddHost => "SSH ホストを追加…", L10nKey::TabMenuAllHosts => "すべての SSH ホスト…", + L10nKey::TabMenuOtherShells => "その他のシェル…", L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割", L10nKey::TabUnnamedShell => "シェル {n}", L10nKey::ShellDefault => "デフォルト", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 9517a836..ff18fcca 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1073,6 +1073,7 @@ l10n_keys! { TabMenuLocalShells, TabMenuAddHost, TabMenuAllHosts, + TabMenuOtherShells, TabMenuSplitHint, TabUnnamedShell, ShellDefault, @@ -1362,6 +1363,7 @@ l10n_keys! { AppPaneNoKnownDirectory, AppNoUncommittedChanges, AppCmdSshProfileTitle, + AppCmdShellTitle, AppCmdSwitchToTab, AppPlaceholderDescription, AppPlaceholderSshQuickConnect, @@ -1622,6 +1624,7 @@ mod tests { L10nKey::Ok, // Pure templates: every word in them is a placeholder. L10nKey::AppCmdSshProfileTitle, + L10nKey::AppCmdShellTitle, L10nKey::HostOpsError, L10nKey::SftpTransferProgress, // Product names. diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index a501716e..d19289cb 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1589,6 +1589,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppPaneNoKnownDirectory => "此窗格没有已知的目录。", L10nKey::AppNoUncommittedChanges => "{cwd} 中没有未提交的更改(或不是 git 仓库)。", L10nKey::AppCmdSshProfileTitle => "SSH:{title}", + L10nKey::AppCmdShellTitle => "Shell:{title}", L10nKey::AppCmdSwitchToTab => "切换到标签页:{label}", L10nKey::AppPlaceholderDescription => "描述", L10nKey::AppPlaceholderSshQuickConnect => "user@host 或 user@host:port", @@ -1799,6 +1800,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "本地", L10nKey::TabMenuAddHost => "添加 SSH 主机…", L10nKey::TabMenuAllHosts => "所有 SSH 主机…", + L10nKey::TabMenuOtherShells => "其他 Shell…", L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开", L10nKey::TabUnnamedShell => "终端 {n}", L10nKey::ShellDefault => "默认", diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 334edd3e..d6865b2f 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -107,6 +107,9 @@ pub enum CommandKind { ActivateTab(usize), ConnectSavedProfile(Uuid), EditSavedProfile(Uuid), + /// Open the shell the window's inventory lists under this label, as the + /// New Tab menu's row for it would. + OpenShell(String), SaveSshSessionAsHost, QuickConnect(String), SaveQuickConnect(String), @@ -222,6 +225,7 @@ impl CommandKind { | ActivateTab(_) | ConnectSavedProfile(_) | EditSavedProfile(_) + | OpenShell(_) | QuickConnect(_) | SaveQuickConnect(_) => return None, }) @@ -337,6 +341,7 @@ impl CommandKind { | ActivateTab(_) | ConnectSavedProfile(_) | EditSavedProfile(_) + | OpenShell(_) | SaveSshSessionAsHost | QuickConnect(_) | SaveQuickConnect(_) => return None, diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 4f0acd3c..6a06992e 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -686,17 +686,27 @@ pub(crate) fn chrome_tile_sized( /// closes the section is where the rest are. const MENU_HOSTS: usize = 6; +/// How many shells the New Tab menu names, the default among them. +/// +/// The same reasoning as [`MENU_HOSTS`], at a smaller number: a stock macOS box +/// reports nine shells and almost nobody opens more than one or two of them. +/// The default always leads — it is the answer to "what do I get if I just +/// click" — and the rest of the rows go to whatever has actually been opened, +/// by frecency. Everything else is one row away, in the palette. +const MENU_SHELLS: usize = 3; + /// How wide the New Tab menu is allowed to get. const MENU_W: Pixels = px(360.); /// How tall, before it starts scrolling. /// -/// Enough for the menu's own full hand — the nine shells a stock macOS box -/// reports, both headings, [`MENU_HOSTS`] hosts and the two rows that close the -/// list, at the 26px a row occupies — so the shape everyone actually sees -/// arrives whole. Past that (a pile of custom shells) it scrolls, and it is -/// capped again against the window in [`NewTabMenu::build`], since a menu taller -/// than what it hangs off is worse than one that scrolls. +/// Both lists are capped — [`MENU_SHELLS`] shells and [`MENU_HOSTS`] hosts — +/// so the menu's full hand is a fixed number of rows: those, both headings, the +/// row closing each section and the modifier hint, at the 26px a row occupies. +/// This leaves room above that for a seam row or two more, so the full hand +/// always arrives whole and never scrolls on its own. It is capped again +/// against the window in [`NewTabMenu::build`], since a menu taller than what +/// it hangs off is worse than one that scrolls. const MENU_H: Pixels = px(560.); /// What the row closing the SSH section types into the palette for you. @@ -717,6 +727,14 @@ const MENU_H: Pixels = px(560.); /// and opens the unfiltered command list has made the reader ask twice. const PALETTE_SSH_QUERY: &str = "ssh"; +/// What the row closing the Local section types into the palette for you. +/// +/// Every shell in the window's inventory is a palette command titled +/// `Shell: {label}` ([`L10nKey::AppCmdShellTitle`], the same word in every +/// language we ship, for the reason [`PALETTE_SSH_QUERY`] gives), so this one +/// word lands on exactly the shells, default first and then by frecency. +const PALETTE_SHELL_QUERY: &str = "shell"; + /// How this platform spells the key that turns a New Tab row into a split. fn split_modifier() -> &'static str { if cfg!(target_os = "macos") { @@ -735,8 +753,12 @@ fn split_modifier() -> &'static str { /// the window sat still is in the list the next time the `+` is pressed. struct NewTabMenu { app: gpui::WeakEntity, - shells: Vec<(SharedString, ShellSpec)>, + /// The shells the menu names, by label — at most [`MENU_SHELLS`]. + shells: Vec, default_shell: SharedString, + /// The inventory holds shells the menu does not name, so the section + /// closes with a row into the palette. + more_shells: bool, /// Saved host, its display name, and the `user@host:port` beside it — /// empty when the name already says it. hosts: Vec<(uuid::Uuid, SharedString, SharedString)>, @@ -762,21 +784,17 @@ impl NewTabMenu { // host with a descriptive name and a long `user@host` drags every // other row out with it and the menu stops looking like chrome. .max_w(MENU_W) - // Shells are whatever this machine has plus whatever the user - // added by hand, so the row count has no ceiling. Past the height + // Both lists are capped, but a short window is not. Past the height // of the window an un-scrollable menu simply loses its last rows — // and the last rows here are the SSH section. .scrollable(true) - // Only the overflow case should scroll, and the default ceiling is - // too low to tell the two apart: a stock macOS box has nine shells, - // which with both headings, the hosts and the two closing rows - // already runs past `PopupMenu`'s built-in 450px. The menu would - // arrive scrolled on every machine, with `Local` cut off above. + // Our own ceiling rather than `PopupMenu`'s built-in 450px, so the + // one that applies is the one [`MENU_H`] reasons about. .max_h(ceiling) .item(PopupMenuItem::label(t(L10nKey::TabMenuLocalShells))); - for (label, spec) in &self.shells { - let spec = spec.clone(); + for label in &self.shells { let app = self.app.clone(); + let open = label.clone(); let row = if *label == self.default_shell { let label = label.clone(); PopupMenuItem::element(move |_window, cx| { @@ -788,9 +806,7 @@ impl NewTabMenu { menu = menu.item(row.on_click(move |_, window, cx| { let at = SpawnWhere::from_modifiers(window.modifiers()); if let Some(app) = app.upgrade() { - app.update(cx, |this, cx| { - this.open_shell(Some(spec.clone()), at, window, cx) - }); + app.update(cx, |this, cx| this.open_listed_shell(&open, at, window, cx)); } })); } @@ -807,6 +823,22 @@ impl NewTabMenu { }, )); } + // The rest of the inventory is in the palette, already filtered to + // it — the same seam the SSH section closes with. Absent when the + // rows above are the whole inventory: a row into a list of nothing + // new would be one more thing to read for no gain. + if self.more_shells { + let app = self.app.clone(); + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuOtherShells)).on_click( + move |_, window, cx| { + if let Some(app) = app.upgrade() { + app.update(cx, |this, cx| { + this.open_palette(PALETTE_SHELL_QUERY, window, cx) + }); + } + }, + )); + } menu = menu .item(PopupMenuItem::separator()) @@ -864,6 +896,55 @@ impl NewTabMenu { } } +/// Every shell in the inventory, most likely first: the default, then whatever +/// has been opened often and recently, then the rest in the inventory's own +/// order. Shared by the palette and the New Tab menu so the same shell leads +/// both lists. +pub(crate) fn shells_by_frecency<'a>( + shells: &'a [DetectedShell], + default: &str, + usage: &std::collections::HashMap, + now: u64, +) -> Vec<&'a DetectedShell> { + let score = |s: &DetectedShell| usage.get(&s.label).map_or(0.0, |u| u.score(now)); + let mut sorted: Vec<&DetectedShell> = shells.iter().collect(); + // Stable, so shells nobody has opened keep the order the inventory chose. + sorted.sort_by(|a, b| { + (b.label == default) + .cmp(&(a.label == default)) + .then_with(|| { + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + sorted +} + +/// The shells the menu names, off the front of [`shells_by_frecency`]'s list: +/// its head — the default, whenever the inventory has it — and then only +/// shells that have actually been opened, [`MENU_SHELLS`] rows in all. +/// +/// A shell nobody has opened is not named just because there is room: a menu +/// that filled its spare rows with `csh` and `tcsh` would be the nine-row list +/// again, only shorter. +fn menu_shells<'a>( + sorted: &[&'a DetectedShell], + default: &str, + usage: &std::collections::HashMap, + now: u64, +) -> Vec<&'a DetectedShell> { + sorted + .iter() + .take(MENU_SHELLS) + .enumerate() + .filter(|(i, s)| { + *i == 0 || s.label == default || usage.get(&s.label).is_some_and(|u| u.score(now) > 0.0) + }) + .map(|(_, s)| *s) + .collect() +} + /// The hosts the menu names, in the order they were handed over — frecency, /// so the ones that fit are the ones actually used. /// @@ -1602,19 +1683,50 @@ impl Tty7App { /// What the menu offers, read off the app as the menu opens — the builder /// runs on the popup's own entity, so the rows carry a weak handle back. fn new_tab_menu_rows(&self, app: gpui::WeakEntity, cx: &App) -> NewTabMenu { + let default_shell = self.default_shell_label(cx); + let usage = &cx.global::().shell_frecency; + let now = crate::core::config::unix_now(); + let sorted = shells_by_frecency(&self.shells.shells, &default_shell, usage, now); + let shells = menu_shells(&sorted, &default_shell, usage, now); NewTabMenu { app, - shells: self - .shells - .shells - .iter() - .map(|s| (SharedString::from(s.label.clone()), shell_spec(s))) + more_shells: shells.len() < sorted.len(), + shells: shells + .into_iter() + .map(|s| SharedString::from(s.label.clone())) .collect(), - default_shell: SharedString::from(self.default_shell_label(cx)), + default_shell: SharedString::from(default_shell), hosts: menu_hosts(crate::ui::ssh_connect::ssh_profiles_by_frecency(cx)), } } + /// Open the inventory's shell by the label it is listed under, wherever + /// `at` says — the New Tab menu's rows and the palette's `Shell:` commands + /// both land here, so both count toward the frecency that orders them. + pub(crate) fn open_listed_shell( + &mut self, + label: &str, + at: SpawnWhere, + window: &mut Window, + cx: &mut Context, + ) { + let Some(spec) = self + .shells + .shells + .iter() + .find(|s| s.label == label) + .map(shell_spec) + else { + return; + }; + self.update_config(cx, |cfg| { + let entry = cfg.shell_frecency.entry(label.to_string()).or_default(); + entry.count = entry.count.saturating_add(1); + entry.last_used = crate::core::config::unix_now(); + }); + self.open_shell(Some(spec), at, window, cx); + } + pub(crate) fn tab_context_menu( menu: PopupMenu, index: usize, @@ -3421,4 +3533,131 @@ mod tests { assert_eq!(label_of(&root, 0, Some(home())), "/"); assert_eq!(tooltip_of(&root, 0, Some(home())), None); } + + fn inventory(labels: &[&str]) -> Vec { + labels + .iter() + .map(|l| DetectedShell { + label: l.to_string(), + program: format!("/bin/{l}"), + args: Vec::new(), + args_are_tty7_defaults: true, + user_authored: false, + }) + .collect() + } + + fn used( + entries: &[(&str, u32, u64)], + ) -> std::collections::HashMap { + entries + .iter() + .map(|(l, count, last_used)| { + ( + l.to_string(), + crate::core::config::ProfileUsage { + count: *count, + last_used: *last_used, + }, + ) + }) + .collect() + } + + fn labels(shells: &[&DetectedShell]) -> Vec { + shells.iter().map(|s| s.label.clone()).collect() + } + + const STOCK_MAC: &[&str] = &["zsh", "bash", "sh", "csh", "tcsh", "ksh", "dash"]; + + #[test] + fn the_new_tab_menu_names_the_default_and_what_has_been_opened_by_frecency() { + let now = 100_000_000u64; + let day = 86_400u64; + let shells = inventory(STOCK_MAC); + // `bash` used often and lately; `dash` used more but a year ago; + // `ksh` once, two months back; and the default opened too. + let usage = used(&[ + ("zsh", 50, now), + ("ksh", 1, now - 60 * day), + ("bash", 9, now - day), + ("dash", 20, now - 365 * day), + ]); + let sorted = shells_by_frecency(&shells, "zsh", &usage, now); + assert_eq!( + labels(&sorted), + ["zsh", "bash", "dash", "ksh", "sh", "csh", "tcsh"], + "default first, then by frecency, then the inventory's own order" + ); + let menu = menu_shells(&sorted, "zsh", &usage, now); + assert_eq!(menu.len(), MENU_SHELLS); + assert_eq!(labels(&menu), ["zsh", "bash", "dash"]); + } + + #[test] + fn the_default_shell_leads_the_menu_even_when_it_has_never_been_opened() { + let now = 100_000_000u64; + let shells = inventory(STOCK_MAC); + let usage = used(&[("tcsh", 3, now), ("sh", 7, now), ("ksh", 5, now)]); + let sorted = shells_by_frecency(&shells, "bash", &usage, now); + let menu = menu_shells(&sorted, "bash", &usage, now); + assert_eq!(labels(&menu), ["bash", "sh", "ksh"]); + } + + #[test] + fn a_shell_nobody_has_opened_is_not_named_just_because_there_is_room() { + // A fresh install: nothing has been opened, so the menu names the + // default alone and leaves the rest to the palette. + let now = 100_000_000u64; + let shells = inventory(STOCK_MAC); + let none = used(&[]); + let sorted = shells_by_frecency(&shells, "zsh", &none, now); + assert_eq!(labels(&menu_shells(&sorted, "zsh", &none, now)), ["zsh"]); + + // A usage record at zero counts as never opened. + let zeroed = used(&[("bash", 0, now)]); + let sorted = shells_by_frecency(&shells, "zsh", &zeroed, now); + assert_eq!(labels(&menu_shells(&sorted, "zsh", &zeroed, now)), ["zsh"]); + + // An inventory that does not list the default still offers its head, + // which is the shell a new tab opens with. + let sorted = shells_by_frecency(&shells, "fish", &none, now); + assert_eq!(labels(&menu_shells(&sorted, "fish", &none, now)), ["zsh"]); + } + + #[test] + fn other_shells_is_offered_only_when_the_inventory_holds_more_than_the_menu_names() { + let now = 100_000_000u64; + let more = |inv: &[&str], usage: &[(&str, u32, u64)]| { + let shells = inventory(inv); + let usage = used(usage); + let default = inv.first().copied().unwrap_or(""); + let sorted = shells_by_frecency(&shells, default, &usage, now); + menu_shells(&sorted, default, &usage, now).len() < sorted.len() + }; + assert!(more(STOCK_MAC, &[]), "a stock box names one of seven"); + assert!( + !more(&["pwsh"], &[]), + "the default alone is the whole inventory" + ); + assert!( + !more(&["zsh", "bash"], &[("bash", 1, now)]), + "every shell is already a row" + ); + assert!( + !more( + &["zsh", "bash", "fish"], + &[("bash", 1, now), ("fish", 2, now)] + ), + "three shells, all named" + ); + assert!( + more(&["zsh", "bash", "fish"], &[("bash", 1, now)]), + "`fish` has never been opened, so it is left to the palette" + ); + assert!( + !more(&[], &[]), + "no inventory: the fallback row, nothing more" + ); + } } From afcb8aa2dd3f376665a25f6ae5ce58d6cf670d9d Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:47:59 +0800 Subject: [PATCH 11/20] feat(agents): quick launch for detected CLI agents (#961) * feat(agents): quick launch for detected CLI agents (#955) Every agent whose launch program is on PATH becomes a palette command, "Agent: ", ordered by frecency and bindable as LaunchAgent:. "New Agent Tab" (Cmd+Shift+A on macOS; unbound elsewhere, where Ctrl+Shift+A is select-all) launches the most recently used one, and the New Tab menu gains a single "Launch Agent..." row that opens the palette pre-filtered to them. A launch always opens a new pane (a tab in the active tab's cwd, or a split when picked from the palette with Alt held) and types the command into that pane's shell once it exists - immediately for a local pane, on landing for a remote one via PendingSpawn::run_on_land - never into a pane that was already there. Detection, status and resume then work as for a hand-typed agent. The command is the agent's bare binary unless the new `agent_launch` config map overrides it. A wrapper named there is detected as its agent without an `agent_commands` entry: the daemon folds the programs `agent_launch` runs into its alias map (interpreters, shells and real agent names excepted), reloaded when config.json changes instead of once per process, and a mapped script run under its interpreter (`bash ~/bin/cc`, `node cc.js`) is now recognised too. A running agent's pane menu gets "Set Current Launch Args as Default", which writes its launch argv - minus session flags and positional prompts, joined with the settings' quoting - into `agent_launch`. Remote workspaces cannot be asked for their PATH through the Host trait, so they offer the agents previously seen running in that workspace (WindowView::seen_agents). * fix(agents): count only agents that start under a view, not ones reattached to A view rebuilt over a running pane - every agent tab after an app restart, or a workspace switched back to - saw its agent appear from nothing and reported it as detected, bumping that agent's frecency once per launch of the app. The terminal now remembers whether its link was an attach, and such a view only reports agents once its shell has been seen back at the prompt with no agent in front. Also pins down that the agents seen in a remote workspace, its quick launch list, persist in views.json with the rest of the workspace. * fix(daemon): probe the foreground as soon as a new program takes it The foreground probes ran on output only, at most once per 500ms interval. A quick launch types the agent's command the moment the shell is up, so the agent drew its whole first screen inside the interval of the prompt it was typed at and then waited for a key: the pane never learned it was running an agent until something else printed. Seen in a dev instance, where a launched Claude Code stayed undetected at its trust prompt. The reader now asks the pty for its foreground process group on every read (one ioctl) and probes immediately when it changes. --- CHANGELOG.md | 20 ++ README.md | 2 + README.zh-CN.md | 1 + crates/tty7-core/src/core/cli_agent.rs | 360 +++++++++++++++++++- crates/tty7-core/src/core/config.rs | 87 ++++- crates/tty7-core/src/core/session.rs | 42 +++ crates/tty7-core/src/daemon/pane.rs | 110 +++++- docs/agents/overview.mdx | 35 ++ docs/reference/configuration.mdx | 2 + docs/reference/keyboard-shortcuts.mdx | 3 +- src/core/actions.rs | 10 + src/core/session.rs | 18 + src/terminal/remote.rs | 10 + src/terminal/view.rs | 153 ++++++++- src/ui/agent_launch.rs | 449 +++++++++++++++++++++++++ src/ui/app.rs | 108 ++++-- src/ui/diff_overlay.rs | 1 + src/ui/document_column.rs | 1 + src/ui/i18n/en.rs | 13 + src/ui/i18n/ja.rs | 11 + src/ui/i18n/mod.rs | 12 + src/ui/i18n/zh.rs | 11 + src/ui/keymap.rs | 81 +++++ src/ui/mod.rs | 1 + src/ui/palette.rs | 18 +- src/ui/pending_pane.rs | 4 + src/ui/tab_strip.rs | 13 + 27 files changed, 1519 insertions(+), 57 deletions(-) create mode 100644 src/ui/agent_launch.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cab309..b5328e8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Quick launch for the coding agents on your PATH** (#955). Every agent tty7 + recognises whose binary is on `PATH` is a palette command — "Agent: Claude + Code", "Agent: Codex", … — ordered by how often and how recently it was + launched or seen running, and each is bindable as `LaunchAgent:`. "New + Agent Tab" (⌘⇧A on macOS, unbound elsewhere, where Ctrl+Shift+A is + select-all) starts the one used last, and the New Tab menu's "Launch Agent…" + row opens the palette on the list. A launch always opens a new tab in the + current tab's directory and types the agent's command into its shell once the + pane exists, never into a pane that was already there, so detection, status + and resume work as for a hand-typed agent and quitting it returns to the + shell. The command is the agent's bare binary unless `agent_launch` in + `config.json` gives it one (`"claude": "claude --dangerously-skip-permissions"`); + a wrapper named there is detected as that agent without an `agent_commands` + entry, including a script run under its interpreter, and the daemon picks up + an edit without a restart. A running agent's pane menu has "Set Current Launch + Args as Default", which writes the flags it was started with — minus the + session it resumed and any prompt — into `agent_launch`. A remote workspace + cannot be asked for its `PATH`, so there the list is the agents already seen + running in that workspace. + - **Tabs can be put to sleep, and woken where they were** (#762). Right-click a tab → Hibernate, or "Hibernate Tab" in the command palette, stops every process in the tab to give its memory back while the tab keeps its place in diff --git a/README.md b/README.md index a29edb91..9754c716 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ that tells tty7 which session to fork. None of them are wrapped or proxied — the agent you start is the agent you get, in a normal PTY, with its own interface. An agent launched through a wrapper script can be mapped to one by name with `agent_commands` in `config.json`. +Every agent on your `PATH` is also a palette command (**Agent: Claude Code**, …) +that opens it in a new tab; `agent_launch` sets the command line it starts with. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index 67a78a13..571937ec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,6 +96,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。 如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。 +PATH 上的每个 agent 也都是一条命令面板命令(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。 ## 文档 diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index eb7d5865..42b44526 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -304,6 +304,32 @@ impl CLIAgent { } fn replay_flags(self, argv: &[String]) -> Option> { + let tail = self.session_free_tail(argv)?; + let safe = |t: &str| { + !t.is_empty() + && t.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b)) + }; + if !tail.iter().all(|t| safe(t)) { + return None; + } + let mut prev_was_flag = false; + for t in &tail { + let is_flag = t.starts_with('-'); + if !is_flag && !prev_was_flag { + return None; + } + prev_was_flag = is_flag; + } + Some(tail.into_iter().map(String::from).collect()) + } + + /// What follows the agent's own name in `argv`, with everything that names + /// a session taken out: the resume/fork subcommands and the flags listed as + /// stale for this agent, each with its value. `None` when no token in + /// `argv` names this agent — a wrapper, where there is no telling which + /// arguments are the agent's. + fn session_free_tail(self, argv: &[String]) -> Option> { let names_self = |token: &str| { token.split(['/', '\\']).any(|seg| { CLIAgent::match_token(&base_stem(seg).to_ascii_lowercase()) == Some(self) @@ -490,24 +516,7 @@ impl CLIAgent { i += 1; } } - - let safe = |t: &str| { - !t.is_empty() - && t.bytes() - .all(|b| b.is_ascii_alphanumeric() || b"-_=./,:@+~".contains(&b)) - }; - if !tail.iter().all(|t| safe(t)) { - return None; - } - let mut prev_was_flag = false; - for t in &tail { - let is_flag = t.starts_with('-'); - if !is_flag && !prev_was_flag { - return None; - } - prev_was_flag = is_flag; - } - Some(tail.into_iter().map(String::from).collect()) + Some(tail) } pub fn accent_rgb(self) -> u32 { @@ -598,6 +607,58 @@ impl CLIAgent { .find(|a| a.aliases().contains(&token)) } + /// The program a launch with no override runs: the first name the agent + /// is detected by, which is the one its installer puts on `PATH`. + pub fn binary(self) -> &'static str { + self.aliases()[0] + } + + /// The command line a quick launch types for this agent: the user's + /// `agent_launch` entry when there is one, the bare binary otherwise. + /// + /// Keys are matched the way [`CLIAgent::from_slug`] reads them, so a + /// hand-written `"Claude"` still counts. A blank entry is no override. + pub fn launch_command(self, overrides: &HashMap) -> String { + overrides + .iter() + .find(|(slug, _)| CLIAgent::from_slug(slug) == Some(self)) + .map(|(_, line)| line.trim()) + .filter(|line| !line.is_empty()) + .map_or_else(|| self.binary().to_string(), str::to_string) + } + + /// `argv` as a command line worth launching this agent with again: its + /// binary followed by the flags it ran with, minus everything that names + /// one particular session (a resume, a fork, a session id) and minus + /// positional arguments such as a prompt, which belong to that one run. + /// + /// A run that went through something that does not name the agent — a + /// wrapper script mapped by `agent_commands` — cannot be taken apart, so + /// it comes back whole: it is what was typed, and it launched the agent. + pub fn launch_argv_for_default(self, argv: &[String]) -> Option> { + let env = argv.iter().take_while(|t| is_env_assignment(t)).count(); + if argv.len() == env { + return None; + } + let Some(tail) = self.session_free_tail(argv) else { + return Some(argv.to_vec()); + }; + let mut out: Vec = argv[..env].to_vec(); + out.push(self.binary().to_string()); + let mut takes_value = false; + for t in tail { + let is_flag = t.starts_with('-') && t.len() > 1; + if is_flag { + out.push(t.to_string()); + takes_value = !t.contains('='); + } else if takes_value { + out.push(t.to_string()); + takes_value = false; + } + } + Some(out) + } + #[cfg_attr(not(test), allow(dead_code))] pub fn detect_from_argv(argv: &[String]) -> Option { Self::detect_from_argv_with(argv, &HashMap::new()) @@ -625,6 +686,20 @@ impl CLIAgent { return Some(agent); } + // A wrapper that is a script runs as its interpreter, so the name the + // user mapped is the script's, one token in: `bash ~/bin/cc`, + // `node ~/bin/cc.js`. Only the mapped names are looked for there — the + // built-in ones already have the scan below. + if (is_interpreter(launcher_stem) || is_shell(launcher_stem)) + && let Some(agent) = rest + .clone() + .find(|a| !a.starts_with('-') && !is_env_assignment(a)) + .and_then(|script| custom.get(&base_stem(script).to_ascii_lowercase())) + .and_then(|slug| CLIAgent::from_slug(slug)) + { + return Some(agent); + } + if is_interpreter(launcher_stem) { for arg in rest { if arg.starts_with('-') { @@ -719,6 +794,99 @@ fn is_interpreter(stem: &str) -> bool { ) } +fn is_shell(stem: &str) -> bool { + matches!( + stem.to_ascii_lowercase().as_str(), + "sh" | "bash" | "zsh" | "dash" | "ksh" | "fish" + ) +} + +/// The program a launch command line runs: its first word that is not an +/// environment assignment. +pub fn launch_program(command: &str) -> Option { + command_argv(command) + .into_iter() + .find(|t| !is_env_assignment(t)) +} + +/// The wrapper names `agent_launch` implies, in the shape `agent_commands` +/// takes: `{"claude": "cc --fast"}` means a `cc` in a pane is Claude Code. +/// +/// This is how a pane opened by a quick launch through a wrapper is still +/// recognised without the user also writing an `agent_commands` entry: the +/// launch typed exactly this command, so its program is known to be that +/// agent. A program that is already an agent's own name needs no entry, and +/// an interpreter (`npx`, `node`, `env`, …) or a shell is never mapped +/// wholesale — `npx` is not Claude Code just because Claude Code is one of the +/// things it can run. +pub fn launch_aliases(overrides: &HashMap) -> HashMap { + let mut out = HashMap::new(); + for (slug, line) in overrides { + let Some(agent) = CLIAgent::from_slug(slug) else { + continue; + }; + let Some(program) = launch_program(line) else { + continue; + }; + let stem = base_stem(&program).to_ascii_lowercase(); + if stem.is_empty() + || CLIAgent::match_token(&stem).is_some() + || is_interpreter(&stem) + || is_shell(&stem) + { + continue; + } + out.insert(stem, agent.slug().to_string()); + } + out +} + +/// Whether `program` names an executable file, either directly (it has a +/// directory in it) or through one of the directories in `path`. +/// +/// On Windows a bare name is also tried with the extensions an npm or winget +/// install leaves behind, since `claude` there is really `claude.cmd`. +pub fn program_on_path(program: &str, path: &std::ffi::OsStr) -> bool { + let program = program.trim(); + if program.is_empty() { + return false; + } + if program.contains(['/', '\\']) { + // The launch is typed into a shell, which expands a leading `~/`. + let home = program + .strip_prefix("~/") + .and_then(|rest| Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join(rest))); + return is_executable(home.as_deref().unwrap_or(std::path::Path::new(program))); + } + let exts: &[&str] = if cfg!(windows) { + &["", ".exe", ".cmd", ".bat", ".ps1"] + } else { + &[""] + }; + std::env::split_paths(path).any(|dir| { + exts.iter() + .any(|ext| is_executable(&dir.join(format!("{program}{ext}")))) + }) +} + +fn is_executable(path: &std::path::Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent"; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -2253,4 +2421,160 @@ mod tests { "\"waiting\"" ); } + + fn overrides(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn a_launch_is_the_bare_binary_unless_overridden() { + let none = HashMap::new(); + assert_eq!(CLIAgent::Claude.launch_command(&none), "claude"); + assert_eq!(CLIAgent::Codex.launch_command(&none), "codex"); + assert_eq!(CLIAgent::Cursor.launch_command(&none), "cursor-agent"); + assert_eq!(CLIAgent::Antigravity.launch_command(&none), "agy"); + assert_eq!(CLIAgent::QoderCLI.launch_command(&none), "qoder"); + + let set = overrides(&[ + ("claude", " claude --dangerously-skip-permissions "), + // Keys are read the way `from_slug` reads them. + ("Codex", "codex --model o3"), + // A blank entry is no override at all. + ("gemini", " "), + ]); + assert_eq!( + CLIAgent::Claude.launch_command(&set), + "claude --dangerously-skip-permissions" + ); + assert_eq!(CLIAgent::Codex.launch_command(&set), "codex --model o3"); + assert_eq!(CLIAgent::Gemini.launch_command(&set), "gemini"); + assert_eq!(CLIAgent::Aider.launch_command(&set), "aider"); + } + + #[test] + fn every_agent_launches_as_a_name_it_is_detected_by() { + let none = HashMap::new(); + for agent in CLIAgent::ALL { + let argv = command_argv(&agent.launch_command(&none)); + assert_eq!( + CLIAgent::detect_from_argv(&argv), + Some(agent), + "{agent:?} would launch as something detection does not know" + ); + } + } + + #[test] + fn a_wrapper_named_by_a_launch_override_is_detected_as_its_agent() { + let aliases = launch_aliases(&overrides(&[("claude", "FOO=1 cc --fast")])); + // Run as a binary, and run as a script under its interpreter. + for run in [ + argv(&["cc", "--fast"]), + argv(&["/Users/me/bin/cc", "--fast"]), + argv(&["/bin/bash", "/Users/me/bin/cc", "--fast"]), + argv(&["node", "/Users/me/bin/cc.js", "--fast"]), + ] { + assert_eq!( + CLIAgent::detect_from_argv_with(&run, &aliases), + Some(CLIAgent::Claude), + "{run:?}" + ); + } + // Without the override the same wrapper is nobody. + assert_eq!( + CLIAgent::detect_from_argv_with(&argv(&["cc", "--fast"]), &HashMap::new()), + None + ); + } + + #[test] + fn a_launch_override_never_maps_an_interpreter_or_a_real_agent_name() { + let aliases = launch_aliases(&overrides(&[ + ("claude", "npx @anthropic-ai/claude-code"), + ("codex", "bash -lc codex"), + // Already detected as itself; mapping it would add nothing. + ("gemini", "gemini --yolo"), + ("not-an-agent", "cc"), + ])); + assert!(aliases.is_empty(), "{aliases:?}"); + // A shell running something else is still nobody. + assert_eq!( + CLIAgent::detect_from_argv_with( + &argv(&["bash", "./build.sh"]), + &launch_aliases(&overrides(&[("claude", "cc")])) + ), + None + ); + } + + #[test] + fn launch_args_drop_the_session_and_the_prompt_but_keep_the_flags() { + assert_eq!( + CLIAgent::Claude.launch_argv_for_default(&argv(&[ + "node", + "/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js", + "--model", + "opus", + "--resume", + "abc-123", + "--append-system-prompt", + "be terse", + "fix the bug", + ])), + Some(argv(&[ + "claude", + "--model", + "opus", + "--append-system-prompt", + "be terse", + ])) + ); + assert_eq!( + CLIAgent::Codex.launch_argv_for_default(&argv(&[ + "codex", + "resume", + "019a", + "--model=o3", + "prompt", + ])), + Some(argv(&["codex", "--model=o3"])) + ); + // A wrapper cannot be taken apart; what was typed is what launched it. + assert_eq!( + CLIAgent::Claude.launch_argv_for_default(&argv(&["cc", "--fast", "x"])), + Some(argv(&["cc", "--fast", "x"])) + ); + assert_eq!(CLIAgent::Claude.launch_argv_for_default(&[]), None); + } + + #[test] + fn program_on_path_finds_only_executables() { + let dir = tempfile::TempDir::new().unwrap(); + let bin = dir.path().join("claude"); + std::fs::write(&bin, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::write(dir.path().join("codex"), "not executable").unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!program_on_path("codex", dir.path().as_os_str())); + } + std::fs::create_dir(dir.path().join("gemini")).unwrap(); + let path = + std::env::join_paths([std::path::Path::new("/nonexistent"), dir.path()]).unwrap(); + assert!(program_on_path("claude", &path)); + assert!( + !program_on_path("gemini", &path), + "a directory is not a program" + ); + assert!(!program_on_path("aider", &path)); + assert!(program_on_path( + bin.to_str().unwrap(), + std::ffi::OsStr::new("") + )); + assert!(!program_on_path("", &path)); + } } diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 577b39ea..b277f52f 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -416,6 +416,17 @@ pub struct Config { #[serde(default)] pub agent_commands: HashMap, + /// The command line a quick launch types for an agent, keyed by its slug: + /// `{"claude": "claude --dangerously-skip-permissions"}`. An agent with no + /// entry is launched as its bare binary. A wrapper named here is detected + /// as that agent without an `agent_commands` entry of its own. + #[serde(default)] + pub agent_launch: HashMap, + /// How often and how recently each agent (by slug) was launched or seen + /// running — what orders the quick-launch commands and picks the agent + /// "New Agent Tab" opens. + #[serde(default)] + pub agent_frecency: HashMap, #[serde(default = "default_true")] pub restore_agent_sessions: bool, /// Give each pane its own shell history instead of one file every pane @@ -799,6 +810,8 @@ impl Default for Config { shell_frecency: HashMap::new(), command_frecency: HashMap::new(), agent_commands: HashMap::new(), + agent_launch: HashMap::new(), + agent_frecency: HashMap::new(), restore_agent_sessions: true, per_pane_history: false, quarantined: false, @@ -1419,15 +1432,44 @@ pub fn extra_env() -> HashMap { Config::load().env } -pub fn agent_commands_cached() -> &'static HashMap { - static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - CACHE.get_or_init(|| { - Config::load() - .agent_commands - .into_iter() - .map(|(k, v)| (k.to_ascii_lowercase(), v)) - .collect() - }) +/// Every wrapper name the daemon should read as an agent: `agent_commands`, +/// plus the programs `agent_launch` entries run (see +/// [`crate::core::cli_agent::launch_aliases`]). An explicit `agent_commands` +/// entry wins over an implied one. +/// +/// Read again whenever `config.json` changes rather than once per process: the +/// daemon outlives any number of edits, and a launch override added while it +/// runs has to be recognised by the very next launch that uses it. +pub fn agent_detection_aliases() -> std::sync::Arc> { + type Stamp = Option<(std::time::SystemTime, u64)>; + static CACHE: std::sync::Mutex>)>> = + std::sync::Mutex::new(None); + let stamp: Stamp = Config::path() + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|m| Some((m.modified().ok()?, m.len()))); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((seen, aliases)) = cache.as_ref() + && *seen == stamp + { + return aliases.clone(); + } + let cfg = Config::load(); + let aliases = std::sync::Arc::new(detection_aliases(&cfg.agent_commands, &cfg.agent_launch)); + *cache = Some((stamp, aliases.clone())); + aliases +} + +/// [`agent_detection_aliases`] over the two maps it reads, for a caller that +/// already holds them. +pub fn detection_aliases( + commands: &HashMap, + launch: &HashMap, +) -> HashMap { + let mut aliases = crate::core::cli_agent::launch_aliases(launch); + for (name, slug) in commands { + aliases.insert(name.to_ascii_lowercase(), slug.clone()); + } + aliases } fn default_preset() -> String { @@ -2782,6 +2824,33 @@ mod tests { assert_eq!(portable_data_dir(exe_dir.path()), None); } + #[test] + fn launch_overrides_teach_the_daemon_their_wrappers_as_the_file_changes() { + let _guard = lock_config_file(); + pin_config_dir(); + let mut cfg = Config::default(); + cfg.agent_launch + .insert("claude".to_string(), "cc --fast".to_string()); + cfg.save(); + let aliases = agent_detection_aliases(); + assert_eq!(aliases.get("cc").map(String::as_str), Some("claude")); + + // An edit made while the daemon runs is picked up on the next read, + // and an explicit `agent_commands` entry outranks an implied one. + cfg.agent_launch + .insert("codex".to_string(), "/opt/bin/cx".to_string()); + cfg.agent_commands + .insert("CC".to_string(), "gemini".to_string()); + cfg.save(); + let aliases = agent_detection_aliases(); + assert_eq!(aliases.get("cc").map(String::as_str), Some("gemini")); + assert_eq!(aliases.get("cx").map(String::as_str), Some("codex")); + + let back: Config = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap(); + assert_eq!(back.agent_launch, cfg.agent_launch); + Config::default().save(); + } + fn host(name: &str, address: &str) -> crate::core::ssh_profile::SshProfile { let mut profile = crate::core::ssh_profile::SshProfile::new(name); profile.host = address.to_string(); diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 1deeeca4..db36df48 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -384,6 +384,11 @@ pub struct WindowView { /// is another client's activity, not ours); opening one clears the mark. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub synced: bool, + /// Slugs of the coding agents seen running in this workspace, most recent + /// last. A remote workspace cannot be asked what is on its `PATH`, so + /// this is what its quick-launch list is made of. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub seen_agents: Vec, } impl Default for WindowView { @@ -397,6 +402,7 @@ impl Default for WindowView { label: None, subject: None, synced: false, + seen_agents: Vec::new(), } } } @@ -417,6 +423,17 @@ impl WindowView { self.host.is_some() } + /// Remember `slug` as running here, moving it to the end if it was + /// already known. Answers whether anything changed. + pub fn saw_agent(&mut self, slug: &str) -> bool { + if self.seen_agents.last().is_some_and(|s| s == slug) { + return false; + } + self.seen_agents.retain(|s| s != slug); + self.seen_agents.push(slug.to_string()); + true + } + pub fn host_id(&self) -> crate::host::HostId { match &self.host { Some(r) => r.host_id(), @@ -1172,4 +1189,29 @@ mod tests { views.views.retain(|w| w.id != b_id); assert_eq!(numbers(&views), vec![a_id, c_id]); } + + /// A remote workspace's quick-launch list is the agents seen running in + /// it, so the list has to be in `views.json` with the rest of the + /// workspace — an in-memory one would be empty after every restart. + #[test] + fn agents_seen_in_a_workspace_survive_a_restart() { + let _file = lock_session_file(); + pin_config_dir(); + let mut entry = remote_view("build-box"); + assert!(entry.saw_agent("claude")); + assert!(entry.saw_agent("codex")); + assert!(!entry.saw_agent("codex"), "already the most recent"); + assert!(entry.saw_agent("claude"), "moves to the end"); + let id = entry.id; + WindowViews { + active: Some(id), + views: vec![entry, view()], + } + .save(); + let loaded = WindowViews::load().expect("a saved views file should load back"); + assert_eq!(loaded.get(id).unwrap().seen_agents, vec!["codex", "claude"]); + // A workspace that has seen nothing writes nothing for it. + let text = std::fs::read_to_string(WindowViews::path().unwrap()).unwrap(); + assert_eq!(text.matches("seen_agents").count(), 1); + } } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 5aad6979..6492ca1f 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -839,6 +839,10 @@ enum PaneBackend { } struct ForegroundProbes { + /// The pty's foreground process group: one ioctl, cheap enough to ask on + /// every read, and a change in it is what says the probes below would now + /// answer differently. + group: Box Option + Send>, remote: Box Option + Send>, agent: Box Option)>> + Send>, cwd: Box Option + Send>, @@ -1680,6 +1684,7 @@ impl DaemonPane { ); let fg_master = master.clone(); + let group_master = master.clone(); let remote_master = master.clone(); let agent_master = master.clone(); let cwd_master = master.clone(); @@ -1691,6 +1696,7 @@ impl DaemonPane { writer.clone(), move || foreground_command_running(&fg_master, shell_pid), ForegroundProbes { + group: Box::new(move || pty_foreground_pgid(&group_master)), remote: Box::new(move || foreground_remote_context(&remote_master)), agent: Box::new(move || foreground_agent(&agent_master)), cwd: Box::new(move || foreground_cwd(&cwd_master, shell_pid)), @@ -1914,6 +1920,7 @@ impl DaemonPane { writer.clone(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -1963,6 +1970,7 @@ impl DaemonPane { death: Arc, ) -> JoinHandle<()> { let ForegroundProbes { + group: foreground_group, remote: foreground_remote, agent: foreground_agent_fn, cwd: foreground_cwd_fn, @@ -1996,6 +2004,7 @@ impl DaemonPane { let mut tr_read_t = std::time::Duration::ZERO; let mut tr_disp_t = std::time::Duration::ZERO; let mut next_remote_check = std::time::Instant::now(); + let mut last_group = None; loop { if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) { @@ -2073,8 +2082,21 @@ impl DaemonPane { // reads, most visibly) stayed on the far end until // some unrelated output arrived (#817). let back_at_prompt = signals.shell.iter().any(|s| s.at_prompt); - let poll_now = - back_at_prompt || std::time::Instant::now() >= next_remote_check; + // A new foreground group is a new program in front + // — probe for it on its first output. The interval + // alone missed an agent that drew its whole first + // screen within it of the prompt it was typed at + // and then sat waiting for a key: a launch that + // types the command the moment the shell is up + // did that every time, and the pane never learned + // it was running an agent until something else + // printed. + let group = foreground_group(); + let group_changed = group != last_group; + last_group = group; + let poll_now = back_at_prompt + || group_changed + || std::time::Instant::now() >= next_remote_check; if poll_now { next_remote_check = std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL; @@ -3014,7 +3036,7 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { if shell_mark_capture_changed(&st.shell, &shell) { apply_agent( st, - agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()), + agent_from_shell_mark(&shell, &crate::core::config::agent_detection_aliases()), ); } st.shell = shell.clone(); @@ -3346,7 +3368,7 @@ fn foreground_agent( let argv = crate::daemon::remote::foreground_argv(pid)?; let agent = crate::core::cli_agent::CLIAgent::detect_from_argv_with( &argv, - crate::core::config::agent_commands_cached(), + &crate::core::config::agent_detection_aliases(), )?; Some((agent, argv)) }; @@ -5249,6 +5271,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| Some(None)), cwd: Box::new(|| None), @@ -5310,6 +5333,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| Some(None)), cwd: Box::new(|| None), @@ -6104,6 +6128,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote, agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6151,6 +6176,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6255,6 +6281,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6352,6 +6379,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6389,6 +6417,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6459,6 +6488,7 @@ mod tests { writer, || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6552,6 +6582,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6633,6 +6664,7 @@ mod tests { writer, || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6670,6 +6702,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6721,6 +6754,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| Some(PathBuf::from("/Users/alice/dev/tty7"))), @@ -6752,6 +6786,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -6778,6 +6813,7 @@ mod tests { null_writer(), || false, ForegroundProbes { + group: Box::new(|| None), remote: Box::new(|| None), agent: Box::new(|| None), cwd: Box::new(|| None), @@ -7341,4 +7377,70 @@ mod tests { apply_signals(&mut st, SniffSignals::default()); assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x"))); } + + /// A quick launch types the agent's command the moment the shell is up, so + /// the agent's whole first screen can land inside the poll interval of the + /// prompt it was typed at — and then it waits for a key, printing nothing + /// more. Probing only on the interval, the pane never found out an agent + /// was running. A change of foreground process group now probes at once. + #[test] + fn a_new_foreground_group_is_probed_inside_the_poll_interval() { + struct Chunks(std::collections::VecDeque>); + + impl Read for Chunks { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.pop_front() { + Some(chunk) => { + buf[..chunk.len()].copy_from_slice(&chunk); + Ok(chunk.len()) + } + None => Ok(0), + } + } + } + + let probes_for = |groups: Vec| { + let state = Arc::new(Mutex::new(test_state(true))); + let probes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = probes.clone(); + let groups = Mutex::new(groups.into_iter()); + let handle = DaemonPane::spawn_reader( + state, + Arc::new(AtomicBool::new(false)), + Arc::new(OutputGate::new()), + // Plain output, no prompt marks: only the interval or the + // group can trigger a probe. + Box::new(Chunks( + [b"$ ".to_vec(), b"agent screen".to_vec()] + .into_iter() + .collect(), + )), + null_writer(), + || false, + ForegroundProbes { + group: Box::new(move || groups.lock().unwrap().next()), + remote: Box::new(|| None), + agent: Box::new(move || { + counted.fetch_add(1, Ordering::SeqCst); + None + }), + cwd: Box::new(|| None), + }, + Arc::new(DeathReporter::new(|| {})), + ); + handle.join().unwrap(); + probes.load(Ordering::SeqCst) + }; + + assert_eq!( + probes_for(vec![10, 10]), + 1, + "same program: the interval rules" + ); + assert_eq!( + probes_for(vec![10, 11]), + 2, + "a new program is probed at once" + ); + } } diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index a3b446f7..649e7648 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -66,6 +66,41 @@ The key is your command's name; the value is one of the slugs above (`claude`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, `omp`, `kimi`, `qodercli`, `crush`, `codebuddy`). +## Quick launch + +Every agent tty7 finds on your `PATH` is one palette command away: **Agent: +Claude Code**, **Agent: Codex**, and so on, most-used first. The New Tab menu's +**Launch Agent…** row opens the palette on that list, and **New Agent Tab** +(⌘ ⇧ A on macOS, unbound elsewhere) starts whichever agent you used +last. + +A launch always opens a new tab in the current tab's directory and types the +agent's command into its shell, so the agent is detected, tracked and resumed +exactly as if you had typed it — and quitting it leaves you at the prompt. Each +`Agent: …` command is bindable too, as `LaunchAgent:`. + +By default an agent launches as its bare binary (`claude`, `codex`, +`cursor-agent`, …). Give it a command line of its own with `agent_launch`: + +```json +{ + "agent_launch": { + "claude": "claude --dangerously-skip-permissions", + "codex": "cx --profile work" + } +} +``` + +A wrapper named there (`cx` above) is recognised as that agent without an +`agent_commands` entry. To keep the flags a running agent was started with, +right-click its pane and choose **Set Current Launch Args as Default**; tty7 +writes them into `agent_launch`, minus the session they resumed and any prompt. + +In a remote workspace tty7 cannot read the far machine's `PATH`, so it offers +the agents it has already seen running there: start one by hand once and it +joins the list. A wrapper named in your local `agent_launch` is only recognised +over there if the remote `config.json` names it too. + ## What you get for free Just by running an agent in a pane: diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 7df8203e..12e49c9b 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -178,6 +178,8 @@ angle brackets, and a tab: | Key | Type | Default | | |---|---|---|---| | `agent_commands` | object | `{}` | Map a wrapper command to an agent slug: `{"cc": "claude"}`. | +| `agent_launch` | object | `{}` | The command line quick launch types for an agent, by slug: `{"claude": "claude --dangerously-skip-permissions"}`. Unset agents launch as their bare binary. [More →](/agents/overview#quick-launch) | +| `agent_frecency` | object | `{}` | Written by tty7: how often and how recently each agent ran, to order quick launch. | | `restore_agent_sessions` | bool | `true` | Relaunch an agent conversation when a lost pane is restored. | | `install_cli_on_path` | bool | `true` | Put the bundled `tty7` command on PATH at launch. | diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index c9ea38c2..c0509050 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -19,6 +19,7 @@ it shows what *your* copy is bound to. This is the shipped default. | New Workspace | ⌘ ⇧ N | Ctrl ⇧ N | | Switch Workspace | ⌘ ⇧ O | Ctrl ⇧ O | | New Window | ⌘ N | — | +| New Agent Tab | ⌘ ⇧ A | — | ## Panes @@ -110,7 +111,7 @@ Keybindings**: | Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | | Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | | Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | -| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` | +| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:` (one per agent, e.g. `LaunchAgent:claude`) | | Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` | | Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` | | Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` | diff --git a/src/core/actions.rs b/src/core/actions.rs index c8e81210..bf9fff64 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -37,6 +37,8 @@ actions!( ForkAgentSessionDown, ForkAgentSessionUp, CopyAgentSessionId, + NewAgentTab, + SaveAgentLaunchArgs, SplitRight, SplitDown, FocusNextPane, @@ -126,3 +128,11 @@ actions!( Quit ] ); + +/// Open a new tab running one particular coding agent. The keymap names one of +/// these per agent (`LaunchAgent:claude`, …); see `ui::agent_launch`. +#[derive(Clone, Debug, PartialEq, gpui::Action)] +#[action(namespace = tty7, no_json)] +pub struct LaunchAgent { + pub agent: tty7_core::core::cli_agent::CLIAgent, +} diff --git a/src/core/session.rs b/src/core/session.rs index 4cf3af92..ac897bbe 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -161,6 +161,24 @@ impl WorkspaceStore { store.views.save(); } + /// Note that `agent` ran in workspace `id` (see `WindowView::seen_agents`). + pub fn record_agent_seen( + cx: &mut gpui::App, + id: WorkspaceId, + agent: tty7_core::core::cli_agent::CLIAgent, + ) { + let Some(store) = Self::try_store(cx) else { + return; + }; + if store + .views + .get_mut(id) + .is_some_and(|view| view.saw_agent(agent.slug())) + { + store.views.save(); + } + } + pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId { host_for(Self::all(cx), id) } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 340596d7..9673e5d2 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -571,6 +571,10 @@ pub struct RemoteTerminal { pub exited: bool, size: TermSize, synced_size: bool, + /// This link reattached to a pane that was already running rather than + /// spawning a fresh one, so whatever it reports first — an agent in the + /// foreground included — predates this client. + reattached: bool, /// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked /// alongside `size` so a display-scale change still reaches the child even /// when the grid dimensions are unchanged. @@ -1087,6 +1091,7 @@ impl RemoteTerminal { exited: false, size, synced_size: false, + reattached: awaiting_replay, synced_cell: (0, 0), resize_sent_at: None, link, @@ -1878,6 +1883,11 @@ impl RemoteTerminal { self.remote_context.lock().ok().and_then(|g| g.clone()) } + /// See the field: an attach to a running pane, not a fresh spawn. + pub fn reattached(&self) -> bool { + self.reattached + } + pub fn at_prompt(&self) -> bool { self.shell_state .lock() diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5c810f08..fa3e03c7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -27,8 +27,8 @@ use super::typeahead::{RawInput, Typeahead}; use crate::core::actions::{ CloseActiveTab, CopyLinkPathUnderPointer, DecreaseFontSize, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab, - OpenLinkUnderPointer, RevealLinkUnderPointer, SendBackTab, SendTab, SplitDown, SplitRight, - ToggleMaximizePane, + OpenLinkUnderPointer, RevealLinkUnderPointer, SaveAgentLaunchArgs, SendBackTab, SendTab, + SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode}; use crate::core::shell_quote::quote_for_shell; @@ -140,6 +140,12 @@ pub struct AgentSessionChanged; impl gpui::EventEmitter for TerminalView {} +/// A coding agent started running in this pane: its foreground went from no +/// agent to this one. What quick launch counts as the agent being used. +pub struct AgentDetected(pub crate::core::cli_agent::CLIAgent); + +impl gpui::EventEmitter for TerminalView {} + /// A file link the user clicked, on its way to whoever can show it. The /// terminal resolves the path — it is the only thing that knows the pane's /// directory and host — and the app opens it, because the editor and the file @@ -436,6 +442,15 @@ pub struct TerminalView { running_since: Option, running_title: String, running_agent: Option, + /// The foreground agent as of the last poll, so a new one is reported + /// once ([`AgentDetected`]) rather than on every frame it keeps running. + seen_agent: Option, + /// Whether an agent appearing here is news. A pane this view spawned is + /// armed from the start; one it reattached to — an app restart, a + /// workspace switched back to — may already be running an agent that was + /// counted when it started, so it arms only once its shell is seen back + /// at the prompt with no agent in front. + agent_detection_armed: bool, last_agent_status: Option, last_agent_session: (Option, Option>), agent_turn_started: Option, @@ -1444,6 +1459,7 @@ impl TerminalView { .map(crate::core::config::gpui_font_features); let report_mouse = config.mouse_reporting; let prompt_editor = config.prompt_editor; + let agent_detection_armed = !terminal.reattached(); let mut font = gpui::font(font_family); font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone())); if let Some(features) = &font_features { @@ -1658,6 +1674,8 @@ impl TerminalView { running_since: None, running_title: String::new(), running_agent: None, + seen_agent: None, + agent_detection_armed, last_agent_status: None, last_agent_session: (None, None), agent_turn_started: None, @@ -3785,6 +3803,8 @@ impl TerminalView { _ => {} } + self.poll_agent_detection(at_prompt, cx); + let turn_finished = self.poll_agent_status(notify_allowed, window, cx); let session = self.terminal.agent_session(); @@ -4045,6 +4065,24 @@ impl TerminalView { ); } + /// Report an agent that has just started in this pane ([`AgentDetected`]), + /// once per start, and never one that was already running when this view + /// reattached to the pane. + fn poll_agent_detection(&mut self, at_prompt: bool, cx: &mut Context) { + let agent = self.terminal.foreground_agent(); + if agent != self.seen_agent { + self.seen_agent = agent; + if let Some(agent) = agent + && self.agent_detection_armed + { + cx.emit(AgentDetected(agent)); + } + } + if agent.is_none() && at_prompt { + self.agent_detection_armed = true; + } + } + fn poll_agent_status( &mut self, notify_allowed: bool, @@ -7339,6 +7377,12 @@ impl Render for TerminalView { let fork_ready = can_fork && view.remote_context().is_none() && view.agent_session().is_some_and(|s| s.session_id.is_some()); + // `None` over a pane with no agent; otherwise whether it knows + // the command line it was started with. + let launch_argv_known = view.agent().map(|_| { + view.agent_session() + .is_some_and(|s| s.launch_argv.is_some()) + }); let menu = match (can_fork, fork_ready) { (true, true) => { @@ -7372,6 +7416,21 @@ impl Render for TerminalView { (false, _) => menu, }; + // Only over a running agent, and only live when the pane knows + // the command line it was started with — there is nothing to + // save otherwise. Beside Fork when both are there. + let menu = match launch_argv_known { + Some(known) => { + let menu = if can_fork { menu } else { menu.separator() }; + menu.menu_with_disabled( + t(L10nKey::AppMenuSaveAgentLaunchArgs), + Box::new(SaveAgentLaunchArgs), + !known, + ) + } + None => menu, + }; + menu.separator() .menu(t(L10nKey::AppMenuSplitRight), Box::new(SplitRight)) .menu(t(L10nKey::AppMenuSplitDown), Box::new(SplitDown)) @@ -16273,6 +16332,96 @@ mod gpui_tests { }) .unwrap(); } + + /// Quick launch counts an agent each time one starts. Reattaching to a pane + /// whose agent was already running — every agent tab after an app restart + /// — is not a start, and counting it bumped every agent once per launch of + /// the app. Once that pane's agent exits, the next one is a start again. + #[gpui::test] + fn only_an_agent_that_starts_under_this_view_counts_as_detected(cx: &mut TestAppContext) { + use crate::core::cli_agent::CLIAgent; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let detected = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let pane = |reattached: bool, cx: &mut TestAppContext| { + let (pane, daemon) = window + .update(cx, |_, window, cx| { + if reattached { + super::quiet_reattached_test_pane(2, window, cx) + } else { + super::quiet_test_pane(3, window, cx) + } + }) + .unwrap(); + let seen = detected.clone(); + cx.update(|cx| { + cx.subscribe(&pane, move |_, ev: &AgentDetected, _| { + seen.borrow_mut().push(ev.0) + }) + .detach() + }); + (pane, daemon) + }; + let report = |agent: Option, + at_prompt: bool, + pane: &Entity, + daemon: &mut Stream, + cx: &mut TestAppContext| { + DaemonMsg::Agent(agent).encode(daemon).unwrap(); + for _ in 0..200 { + if pane.read_with(cx, |p, _| p.terminal.foreground_agent()) == agent { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + pane.update(cx, |p, cx| p.poll_agent_detection(at_prompt, cx)); + cx.run_until_parked(); + }; + + let (restored, mut restored_daemon) = pane(true, cx); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![], + "already running when reattached" + ); + report(None, true, &restored, &mut restored_daemon, cx); + report( + Some(CLIAgent::Codex), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex], + "started after the reattach" + ); + + let (fresh, mut fresh_daemon) = pane(false, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex, CLIAgent::Claude], + "a spawned pane counts its first agent, once" + ); + } } /// The window between the shell reporting a prompt and its line editor diff --git a/src/ui/agent_launch.rs b/src/ui/agent_launch.rs new file mode 100644 index 00000000..c7fd6210 --- /dev/null +++ b/src/ui/agent_launch.rs @@ -0,0 +1,449 @@ +//! Quick launch for the coding agents this machine can run (#955). +//! +//! Launching an agent never types into a pane that already exists. It opens a +//! new shell — a tab in the active tab's directory, or a split when the entry +//! point was asked for one — and types the agent's command line into *that* +//! shell once it is there. The agent then runs the way a hand-typed one does: +//! the daemon detects it from the pane's foreground, status and resume work as +//! usual, and quitting it leaves the shell behind. +//! +//! Which agents are offered: on this computer, those whose launch program is +//! on `PATH`. A remote workspace's `PATH` is not something the `Host` trait can +//! answer — it has no way to run a command or read the far shell's +//! environment — so there the list is the agents seen running in that +//! workspace before (`WindowView::seen_agents`). Either way the order is +//! frecency, recorded the way saved SSH hosts' is. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use gpui::{App, Axis, Context, Window}; +use gpui_component::WindowExt as _; + +use crate::core::cli_agent::{CLIAgent, launch_program, program_on_path}; +use crate::core::config::{Config, ProfileUsage, unix_now}; +use crate::core::session::WorkspaceStore; +use crate::ui::app::{SpawnAs, SpawnWhere, Tty7App, join_shell_args}; +use crate::ui::i18n::{L10nKey, t_fmt}; +use crate::ui::pane::PaneSlot; + +/// What the New Tab menu's "Launch Agent…" row types into the palette: every +/// quick-launch command is titled `Agent: {name}` (the same word in every +/// language we ship), so this lands on the list with the cursor ready to +/// narrow it — the same seam `PALETTE_SSH_QUERY` is for hosts. +pub(crate) const PALETTE_AGENT_QUERY: &str = "agent"; + +/// How the keymap spells "launch this agent": `LaunchAgent:claude`. +const LAUNCH_ACTION_PREFIX: &str = "LaunchAgent:"; + +/// One keymap action name per agent, in [`CLIAgent::ALL`] order. +pub(crate) fn launch_action_names() -> &'static [(CLIAgent, String)] { + static NAMES: LazyLock> = LazyLock::new(|| { + CLIAgent::ALL + .into_iter() + .map(|agent| (agent, format!("{LAUNCH_ACTION_PREFIX}{}", agent.slug()))) + .collect() + }); + &NAMES +} + +pub(crate) fn launch_action_name(agent: CLIAgent) -> &'static str { + launch_action_names() + .iter() + .find(|(a, _)| *a == agent) + .map(|(_, name)| name.as_str()) + .expect("every agent has a launch action") +} + +pub(crate) fn agent_for_launch_action(action: &str) -> Option { + action + .strip_prefix(LAUNCH_ACTION_PREFIX) + .and_then(CLIAgent::from_slug) +} + +/// The agents whose launch program is on `path`, in [`CLIAgent::ALL`] order. +/// An agent with an `agent_launch` override is looked up by the override's +/// program, since that is what the launch will run. +pub(crate) fn installed_on( + path: &std::ffi::OsStr, + overrides: &HashMap, +) -> Vec { + CLIAgent::ALL + .into_iter() + .filter(|agent| { + launch_program(&agent.launch_command(overrides)) + .is_some_and(|program| program_on_path(&program, path)) + }) + .collect() +} + +/// `agents` most-used-first by frecency score; agents with the same score +/// keep the order they came in. +pub(crate) fn by_frecency( + agents: impl IntoIterator, + usage: &HashMap, + now: u64, +) -> Vec { + let score = |agent: &CLIAgent| usage.get(agent.slug()).map(|u| u.score(now)).unwrap_or(0.0); + let mut agents: Vec = agents.into_iter().collect(); + agents.sort_by(|a, b| { + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + agents +} + +/// The agent "New Agent Tab" opens: of `offered`, the one launched or seen +/// most recently; the first offered when none has been used yet. +pub(crate) fn most_recent( + offered: &[CLIAgent], + usage: &HashMap, +) -> Option { + let last_used = |agent: &CLIAgent| usage.get(agent.slug()).map_or(0, |u| u.last_used); + offered + .iter() + .copied() + .filter(|agent| last_used(agent) > 0) + // `max_by_key` keeps the last of equals; reversed, that is the first. + .rev() + .max_by_key(last_used) + .or_else(|| offered.first().copied()) +} + +/// The `agent_launch` line "Set Current Launch Args as Default" writes for a +/// pane that `agent` is running in with `argv`. +pub(crate) fn launch_line_to_save(agent: CLIAgent, argv: &[String]) -> Option { + agent + .launch_argv_for_default(argv) + .map(|argv| join_shell_args(&argv)) +} + +/// Make `line` the command `agent` is launched with from now on. +pub(crate) fn remember_launch_line(cfg: &mut Config, agent: CLIAgent, line: String) { + // One entry per agent, whatever case an existing key was written in. + cfg.agent_launch + .retain(|slug, _| CLIAgent::from_slug(slug) != Some(agent)); + cfg.agent_launch.insert(agent.slug().to_string(), line); +} + +/// Type `command` into the shell `slot` holds — now if it is up, or the +/// moment it lands if it is still connecting. +fn run_when_ready(slot: &PaneSlot, command: String, cx: &mut App) { + match slot { + PaneSlot::Ready(view) => view.read(cx).run_command_line(&command), + PaneSlot::Connecting(pending) => { + pending.update(cx, |pending, _| pending.spawn.run_on_land = Some(command)); + } + } +} + +impl Tty7App { + /// The agents this window's quick launch offers, most likely first. + pub(crate) fn offered_agents(&self, cx: &App) -> Vec { + let cfg = cx.global::(); + let candidates = match WorkspaceStore::all(cx) + .get(self.workspace) + .filter(|view| view.is_remote()) + { + Some(view) => view + .seen_agents + .iter() + .filter_map(|slug| CLIAgent::from_slug(slug)) + .collect(), + None => installed_on( + &std::env::var_os("PATH").unwrap_or_default(), + &cfg.agent_launch, + ), + }; + by_frecency(candidates, &cfg.agent_frecency, unix_now()) + } + + /// Open a new shell for `agent` and start it there. + pub(crate) fn launch_agent( + &mut self, + agent: CLIAgent, + at: SpawnWhere, + window: &mut Window, + cx: &mut Context, + ) { + let command = agent.launch_command(&cx.global::().agent_launch); + let slot = match at { + SpawnWhere::NewTab => { + let cwd = self.tabs.get(self.active).and_then(|t| { + t.pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).spawnable_cwd()) + }); + self.new_tab_slot(cwd, None, window, cx) + } + SpawnWhere::Split => { + self.split_slot(Axis::Horizontal, Some(SpawnAs::Shell(None)), window, cx) + } + }; + // Nothing opened (the spawn failed, or this workspace cannot host a + // shell right now), and the reason is already on screen. The command + // goes nowhere rather than into whatever pane was focused before. + let Some(slot) = slot else { + log::warn!("no pane opened for {}; not launching it", agent.slug()); + return; + }; + run_when_ready(&slot, command, cx); + // Recency only: the run is counted when the pane reports the agent + // actually running, so a launch and its detection are one use, not two. + self.update_config(cx, |cfg| { + cfg.agent_frecency + .entry(agent.slug().to_string()) + .or_default() + .last_used = unix_now(); + }); + } + + /// "New Agent Tab": launch the agent used most recently. + pub(crate) fn new_agent_tab(&mut self, window: &mut Window, cx: &mut Context) { + let offered = self.offered_agents(cx); + match most_recent(&offered, &cx.global::().agent_frecency) { + Some(agent) => self.launch_agent(agent, SpawnWhere::NewTab, window, cx), + None => { + let remote = WorkspaceStore::remote_ref(cx, self.workspace).is_some(); + window.push_notification( + crate::ui::i18n::t(if remote { + L10nKey::AppNoAgentSeenHere + } else { + L10nKey::AppNoAgentOnPath + }), + cx, + ); + } + } + } + + /// A pane in this window started running `agent`. + pub(crate) fn note_agent_detected(&mut self, agent: CLIAgent, cx: &mut Context) { + self.update_config(cx, |cfg| { + let entry = cfg + .agent_frecency + .entry(agent.slug().to_string()) + .or_default(); + entry.count = entry.count.saturating_add(1); + entry.last_used = unix_now(); + }); + if WorkspaceStore::remote_ref(cx, self.workspace).is_some() { + WorkspaceStore::record_agent_seen(cx, self.workspace, agent); + } + } + + /// "Set Current Launch Args as Default": make the focused pane's agent + /// launch the way this one was started. + pub(crate) fn save_agent_launch_args(&mut self, window: &mut Window, cx: &mut Context) { + let Some(view) = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + else { + return; + }; + let view = view.read(cx); + let Some(agent) = view.agent() else { + window.push_notification(crate::ui::i18n::t(L10nKey::AppPaneNoCodingAgent), cx); + return; + }; + let name = agent.display_name(); + let Some(line) = view + .agent_session() + .and_then(|s| s.launch_argv) + .and_then(|argv| launch_line_to_save(agent, &argv)) + else { + window.push_notification( + t_fmt(L10nKey::AppAgentLaunchArgsUnknown, &[("name", name)]), + cx, + ); + return; + }; + let saved = line.clone(); + self.update_config(cx, |cfg| remember_launch_line(cfg, agent, saved)); + window.push_notification( + t_fmt( + L10nKey::AppAgentLaunchSaved, + &[("name", name), ("command", &line)], + ), + cx, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn usage(pairs: &[(&str, u32, u64)]) -> HashMap { + pairs + .iter() + .map(|(slug, count, last_used)| { + ( + slug.to_string(), + ProfileUsage { + count: *count, + last_used: *last_used, + }, + ) + }) + .collect() + } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() + } + + const NOW: u64 = 1_800_000_000; + const DAY: u64 = 86_400; + + #[test] + fn agents_are_offered_most_used_first() { + let used = usage(&[ + ("codex", 3, NOW - DAY), + ("claude", 12, NOW - 2 * DAY), + // Used a lot, but so long ago that it has decayed below Codex. + ("gemini", 20, NOW - 200 * DAY), + ]); + let offered = by_frecency( + [ + CLIAgent::Aider, + CLIAgent::Gemini, + CLIAgent::Codex, + CLIAgent::Claude, + CLIAgent::Amp, + ], + &used, + NOW, + ); + assert_eq!( + offered, + vec![ + CLIAgent::Claude, + CLIAgent::Codex, + CLIAgent::Gemini, + // Never used: they keep the order they were found in. + CLIAgent::Aider, + CLIAgent::Amp, + ] + ); + } + + #[test] + fn new_agent_tab_opens_the_most_recently_used_agent() { + let offered = [CLIAgent::Claude, CLIAgent::Codex, CLIAgent::Gemini]; + // Claude is used more, but Codex was the last one run or launched. + let used = usage(&[ + ("claude", 40, NOW - DAY), + ("codex", 1, NOW - 60), + ("aider", 5, NOW), + ]); + assert_eq!(most_recent(&offered, &used), Some(CLIAgent::Codex)); + // Aider was more recent still, but it is not offered here. + // Nothing used yet: the first one offered. + assert_eq!( + most_recent(&offered, &HashMap::new()), + Some(CLIAgent::Claude) + ); + assert_eq!(most_recent(&[], &used), None); + // A launch that only stamped recency counts as the most recent. + let launched = usage(&[("claude", 40, NOW - DAY), ("gemini", 0, NOW)]); + assert_eq!(most_recent(&offered, &launched), Some(CLIAgent::Gemini)); + } + + #[test] + fn saved_launch_args_survive_a_round_trip_through_the_command_line() { + let run = argv(&[ + "/opt/homebrew/bin/claude", + "--append-system-prompt", + "be terse, don't guess", + "--add-dir", + "/Users/me/My Projects", + "--model", + "opus", + "--resume", + "abc-123", + ]); + let line = launch_line_to_save(CLIAgent::Claude, &run).unwrap(); + assert_eq!( + crate::ui::app::split_shell_args(&line).unwrap(), + argv(&[ + "claude", + "--append-system-prompt", + "be terse, don't guess", + "--add-dir", + "/Users/me/My Projects", + "--model", + "opus", + ]) + ); + // And the saved line is recognised as the agent it launches. + assert_eq!( + CLIAgent::detect_from_command_with(&line, &HashMap::new()), + Some(CLIAgent::Claude) + ); + } + + #[test] + fn set_current_launch_args_as_default_writes_the_agent_launch_entry() { + let mut cfg = Config::default(); + cfg.agent_launch + .insert("Claude".to_string(), "claude --old".to_string()); + cfg.agent_launch + .insert("codex".to_string(), "codex --model o3".to_string()); + let line = launch_line_to_save( + CLIAgent::Claude, + &argv(&["claude", "--dangerously-skip-permissions", "--continue"]), + ) + .unwrap(); + remember_launch_line(&mut cfg, CLIAgent::Claude, line); + + let json: serde_json::Value = serde_json::to_value(&cfg.0).unwrap(); + assert_eq!( + json["agent_launch"], + serde_json::json!({ + "claude": "claude --dangerously-skip-permissions", + "codex": "codex --model o3", + }) + ); + assert_eq!( + CLIAgent::Claude.launch_command(&cfg.agent_launch), + "claude --dangerously-skip-permissions" + ); + } + + #[test] + fn every_agent_has_a_bindable_launch_action() { + for agent in CLIAgent::ALL { + let name = launch_action_name(agent); + assert_eq!(agent_for_launch_action(name), Some(agent), "{name}"); + } + assert_eq!(agent_for_launch_action("LaunchAgent:nobody"), None); + assert_eq!(agent_for_launch_action("NewAgentTab"), None); + } + + #[test] + fn only_agents_whose_launch_program_is_on_path_are_offered() { + let dir = tempfile::TempDir::new().unwrap(); + for name in ["codex", "cc"] { + let path = dir.path().join(name); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + let path = dir.path().as_os_str(); + assert_eq!(installed_on(path, &HashMap::new()), vec![CLIAgent::Codex]); + // With an override, the override's program is what has to be there. + let overrides: HashMap = [ + ("claude".to_string(), "cc --fast".to_string()), + ("codex".to_string(), "codex-nightly".to_string()), + ] + .into_iter() + .collect(); + assert_eq!(installed_on(path, &overrides), vec![CLIAgent::Claude]); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index f09045f1..b09a0f9a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3917,6 +3917,7 @@ impl Tty7App { spawn.agent_launch_argv.as_deref(), cx, ) + .or_else(|| spawn.run_on_land.clone()) }) .flatten(); let view = build_terminal_view(parts, font_size, window, cx); @@ -4031,8 +4032,21 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { + self.new_tab_slot(cwd, shell, window, cx); + } + + /// [`Self::new_tab_with_cwd`], answering with the pane it opened — `None` + /// when nothing opened, so a caller about to type into the new pane has + /// nothing to type into rather than whatever was focused before. + pub(crate) fn new_tab_slot( + &mut self, + cwd: Option, + shell: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option { if !self.guard_local_spawn(window, cx) { - return; + return None; } let group = self.spawn_group(cwd.as_deref(), cx); let tab = match new_terminal( @@ -4054,7 +4068,7 @@ impl Tty7App { self.startup_error = Some(gpui::SharedString::from(text.clone())); window.push_notification(text, cx); cx.notify(); - return; + return None; } }; // Something opened, so whatever the last failure was is stale. @@ -4062,7 +4076,7 @@ impl Tty7App { self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); - let new_tab = Tab::new(Pane::leaf(tab)); + let new_tab = Tab::new(Pane::leaf(tab.clone())); if let Some(group) = group { *new_tab.sidebar_group.borrow_mut() = group; } @@ -4071,6 +4085,7 @@ impl Tty7App { self.focus_active(window, cx); self.save_session(cx); cx.notify(); + Some(tab) } pub(crate) fn open_native_ssh_tab( @@ -4154,15 +4169,24 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) { - let Some(target) = self + self.split_slot(axis, spawn, window, cx); + } + + /// [`Self::split_into`], answering with the pane it placed, or `None` + /// when nothing was placed. + pub(crate) fn split_slot( + &mut self, + axis: Axis, + spawn: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let target = self .tabs .get(self.active) - .and_then(|t| t.pane.focused_or_first(window, cx)) - else { - return; - }; + .and_then(|t| t.pane.focused_or_first(window, cx))?; if !self.guard_local_spawn(window, cx) { - return; + return None; } let cwd = target.read(cx).spawnable_cwd(); let spawn = match spawn { @@ -4190,7 +4214,7 @@ impl Tty7App { ), cx, ); - return; + return None; } } } @@ -4212,22 +4236,23 @@ impl Tty7App { t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]), cx, ); - return; + return None; } } } }; - if let Some(tab) = self.tabs.get_mut(self.active) { - if tab - .pane - .split_leaf(target.entity_id(), axis, false, new.clone()) - { - self.maximized = None; - self.focus_leaf(&new, window, cx); - self.save_session(cx); - cx.notify(); - } + let tab = self.tabs.get_mut(self.active)?; + if !tab + .pane + .split_leaf(target.entity_id(), axis, false, new.clone()) + { + return None; } + self.maximized = None; + self.focus_leaf(&new, window, cx); + self.save_session(cx); + cx.notify(); + Some(new) } fn close_pane(&mut self, window: &mut Window, cx: &mut Context) { @@ -5768,6 +5793,20 @@ impl Tty7App { ); } + for agent in self.offered_agents(cx) { + commands.push( + Command::new( + t_fmt( + L10nKey::AppCmdAgentLaunchTitle, + &[("name", agent.display_name())], + ), + CommandKind::LaunchAgent(agent), + ) + .with_subtitle(agent.launch_command(&cx.global::().agent_launch)) + .in_group(CommandGroup::Agents), + ); + } + for (i, tab) in self.tabs.iter().enumerate() { if i == self.active { continue; @@ -5951,6 +5990,13 @@ impl Tty7App { MarkTabUnread => self.mark_tab_unread(self.active, cx), HibernateTab => self.hibernate_tab(self.active, window, cx), ForkAgentSession => self.fork_active_pane_session(ForkPlacement::NewTab, window, cx), + NewAgentTab => self.new_agent_tab(window, cx), + // Picked from the palette with ⌥ held, the way a New Tab menu row + // is: a split beside the focused pane instead of a tab. + LaunchAgent(agent) => { + let at = SpawnWhere::from_modifiers(window.modifiers()); + self.launch_agent(agent, at, window, cx) + } CopyAgentSessionId => self.copy_agent_session_id(self.active, window, cx), RenameWorkspace => self.start_workspace_rename(window, cx), OpenSettings => self.toggle_settings(window, cx), @@ -9288,6 +9334,15 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| { this.copy_agent_session_id(this.active, window, cx) })) + .on_action( + cx.listener(|this, _: &NewAgentTab, window, cx| this.new_agent_tab(window, cx)), + ) + .on_action(cx.listener(|this, action: &LaunchAgent, window, cx| { + this.launch_agent(action.agent, SpawnWhere::NewTab, window, cx) + })) + .on_action(cx.listener(|this, _: &SaveAgentLaunchArgs, window, cx| { + this.save_agent_launch_args(window, cx) + })) .on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| { this.open_settings_section(SettingsSection::Keybindings, window, cx) })) @@ -9824,6 +9879,7 @@ pub(crate) fn new_terminal( agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner, font_size, }; @@ -9903,6 +9959,14 @@ fn build_terminal_view( }, ) .detach(); + cx.subscribe_in( + &view, + window, + |app, _view, ev: &crate::terminal::view::AgentDetected, _window, cx| { + app.note_agent_detected(ev.0, cx); + }, + ) + .detach(); cx.subscribe_in( &view, window, @@ -11033,6 +11097,7 @@ mod tests { agent: Some(CLIAgent::Claude), agent_session_id: Some("sid-abc".to_string()), agent_launch_argv: Some(vec!["claude".to_string()]), + run_on_land: None, owner: None, font_size: 14.0, }, @@ -12650,6 +12715,7 @@ mod tab_focus_memory_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14., }, diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 61e746e7..8f394962 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -3363,6 +3363,7 @@ mod selection_gpui_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14.0, }, diff --git a/src/ui/document_column.rs b/src/ui/document_column.rs index 447f7389..23738f23 100644 --- a/src/ui/document_column.rs +++ b/src/ui/document_column.rs @@ -569,6 +569,7 @@ mod gpui_tests { agent: None, agent_session_id: None, agent_launch_argv: None, + run_on_land: None, owner: None, font_size: 14.0, }, diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 2f275720..e5efd553 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1525,6 +1525,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory", L10nKey::CmdCopySessionId => "Copy Session ID", L10nKey::CmdCopySessionIdSubtitle => "the coding agent's own session id", + L10nKey::CmdNewAgentTab => "New Agent Tab", + L10nKey::CmdNewAgentTabSubtitle => "open the coding agent you used last in a new tab", L10nKey::CmdForkSession => "Fork Session", L10nKey::CmdForkSessionSubtitle => "branch this agent session into a new tab", L10nKey::CmdMarkTabAsUnread => "Mark Tab as Unread", @@ -1690,6 +1692,15 @@ pub fn translate_en(key: L10nKey) -> &'static str { "No uncommitted changes in {cwd} (or not a git repository)." } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}", + L10nKey::AppNoAgentOnPath => "No coding agent was found on this machine's PATH", + L10nKey::AppNoAgentSeenHere => { + "No coding agent has run in this workspace yet — start one by hand once and it will be offered here" + } + L10nKey::AppAgentLaunchSaved => "{name} will now launch as: {command}", + L10nKey::AppAgentLaunchArgsUnknown => { + "{name} did not report the arguments it was started with" + } L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "Switch to Tab: {label}", L10nKey::AppPlaceholderDescription => "description", @@ -1849,6 +1860,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory", L10nKey::AppMenuCopySessionId => "Copy Session ID", L10nKey::AppMenuForkSession => "Fork Session", + L10nKey::AppMenuSaveAgentLaunchArgs => "Set Current Launch Args as Default", L10nKey::AppMenuClosePaneTab => "Close", L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs", L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right", @@ -1907,6 +1919,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabMenuLocalShells => "Local", L10nKey::TabMenuAddHost => "Add SSH Host…", L10nKey::TabMenuAllHosts => "All SSH Hosts…", + L10nKey::TabMenuLaunchAgent => "Launch Agent…", L10nKey::TabMenuOtherShells => "Other Shells…", L10nKey::TabMenuSplitHint => "Hold {key} to split", L10nKey::TabUnnamedShell => "Shell {n}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 33304b5f..74e7a74a 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1589,6 +1589,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::CmdCopySessionId => "セッション ID をコピー", L10nKey::CmdCopySessionIdSubtitle => "コーディングエージェント自身のセッション ID", + L10nKey::CmdNewAgentTab => "新しいエージェントタブ", + L10nKey::CmdNewAgentTabSubtitle => "最後に使ったコーディングエージェントを新しいタブで開く", L10nKey::CmdForkSession => "セッションをフォーク", L10nKey::CmdForkSessionSubtitle => "このエージェントのセッションを新しいタブにフォーク", L10nKey::CmdMarkTabAsUnread => "タブを未読としてマーク", @@ -1760,6 +1762,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "{cwd} に未コミットの変更はありません(または git リポジトリではありません)" } L10nKey::AppCmdSshProfileTitle => "SSH: {title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent: {name}", + L10nKey::AppNoAgentOnPath => "このマシンの PATH にコーディングエージェントが見つかりません", + L10nKey::AppNoAgentSeenHere => { + "このワークスペースではまだコーディングエージェントが実行されていません — 一度手動で起動すると、ここに表示されます" + } + L10nKey::AppAgentLaunchSaved => "{name} は今後このコマンドで起動します: {command}", + L10nKey::AppAgentLaunchArgsUnknown => "{name} は起動時の引数を報告していません", L10nKey::AppCmdShellTitle => "Shell: {title}", L10nKey::AppCmdSwitchToTab => "タブに切り替え: {label}", L10nKey::AppPlaceholderDescription => "説明", @@ -1923,6 +1932,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::AppMenuCopySessionId => "セッション ID をコピー", L10nKey::AppMenuForkSession => "セッションをフォーク", + L10nKey::AppMenuSaveAgentLaunchArgs => "現在の起動引数をデフォルトに設定", L10nKey::AppMenuClosePaneTab => "閉じる", L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる", L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる", @@ -1981,6 +1991,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "ローカル", L10nKey::TabMenuAddHost => "SSH ホストを追加…", L10nKey::TabMenuAllHosts => "すべての SSH ホスト…", + L10nKey::TabMenuLaunchAgent => "エージェントを起動…", L10nKey::TabMenuOtherShells => "その他のシェル…", L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割", L10nKey::TabUnnamedShell => "シェル {n}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ff18fcca..43f94f69 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1014,6 +1014,7 @@ l10n_keys! { AppMenuCopyWorkingDirectory, AppMenuCopySessionId, AppMenuForkSession, + AppMenuSaveAgentLaunchArgs, AppMenuClosePaneTab, AppMenuCloseOtherTabs, AppMenuCloseTabsRight, @@ -1073,6 +1074,7 @@ l10n_keys! { TabMenuLocalShells, TabMenuAddHost, TabMenuAllHosts, + TabMenuLaunchAgent, TabMenuOtherShells, TabMenuSplitHint, TabUnnamedShell, @@ -1232,6 +1234,8 @@ l10n_keys! { CmdCopyWorkingDirectory, CmdCopySessionId, CmdCopySessionIdSubtitle, + CmdNewAgentTab, + CmdNewAgentTabSubtitle, CmdForkSession, CmdForkSessionSubtitle, CmdMarkTabAsUnread, @@ -1363,6 +1367,11 @@ l10n_keys! { AppPaneNoKnownDirectory, AppNoUncommittedChanges, AppCmdSshProfileTitle, + AppCmdAgentLaunchTitle, + AppNoAgentOnPath, + AppNoAgentSeenHere, + AppAgentLaunchSaved, + AppAgentLaunchArgsUnknown, AppCmdShellTitle, AppCmdSwitchToTab, AppPlaceholderDescription, @@ -1666,6 +1675,9 @@ mod tests { // translation here would be less clear, not more. L10nKey::SettingsShell, L10nKey::CmdGroupAgents, + // The quick-launch rows, which the New Tab menu finds by typing + // this one word into the palette in every locale. + L10nKey::AppCmdAgentLaunchTitle, ]; for &key in KEPT_IN_ENGLISH { diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index d19289cb..7e5dc6c3 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1432,6 +1432,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdCopyWorkingDirectory => "复制工作目录", L10nKey::CmdCopySessionId => "复制会话 ID", L10nKey::CmdCopySessionIdSubtitle => "编码 agent 自身的会话 ID", + L10nKey::CmdNewAgentTab => "新建 Agent 标签页", + L10nKey::CmdNewAgentTabSubtitle => "在新标签页中打开上次使用的编码 agent", L10nKey::CmdForkSession => "Fork 会话", L10nKey::CmdForkSessionSubtitle => "将此 agent 会话 fork 到新标签页", L10nKey::CmdMarkTabAsUnread => "将标签页标记为未读", @@ -1589,6 +1591,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppPaneNoKnownDirectory => "此窗格没有已知的目录。", L10nKey::AppNoUncommittedChanges => "{cwd} 中没有未提交的更改(或不是 git 仓库)。", L10nKey::AppCmdSshProfileTitle => "SSH:{title}", + L10nKey::AppCmdAgentLaunchTitle => "Agent:{name}", + L10nKey::AppNoAgentOnPath => "在本机 PATH 中未找到编码 agent", + L10nKey::AppNoAgentSeenHere => { + "此工作区还没有运行过编码 agent——手动启动一次后,这里就会列出它" + } + L10nKey::AppAgentLaunchSaved => "{name} 以后将以此命令启动:{command}", + L10nKey::AppAgentLaunchArgsUnknown => "{name} 未报告其启动参数", L10nKey::AppCmdShellTitle => "Shell:{title}", L10nKey::AppCmdSwitchToTab => "切换到标签页:{label}", L10nKey::AppPlaceholderDescription => "描述", @@ -1742,6 +1751,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录", L10nKey::AppMenuCopySessionId => "复制会话 ID", L10nKey::AppMenuForkSession => "Fork 会话", + L10nKey::AppMenuSaveAgentLaunchArgs => "将当前启动参数设为默认", L10nKey::AppMenuClosePaneTab => "关闭", L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页", L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页", @@ -1800,6 +1810,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuLocalShells => "本地", L10nKey::TabMenuAddHost => "添加 SSH 主机…", L10nKey::TabMenuAllHosts => "所有 SSH 主机…", + L10nKey::TabMenuLaunchAgent => "启动 Agent…", L10nKey::TabMenuOtherShells => "其他 Shell…", L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开", L10nKey::TabUnnamedShell => "终端 {n}", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index e9c93fe8..08296cd1 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -301,6 +301,24 @@ fn per_platform(mac: &'static str, other: &'static str) -> &'static str { } pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { + let mut bindings = shipped_bindings(); + // One slot per agent, unbound: the palette's `Agent: …` rows are what these + // run, and each takes a key the same way any other action does. Right + // after the other agent actions, so the Keybindings page keeps them together. + let at = bindings + .iter() + .position(|(action, _)| *action == "NewAgentTab") + .map_or(bindings.len(), |i| i + 1); + bindings.splice( + at..at, + crate::ui::agent_launch::launch_action_names() + .iter() + .map(|(_, name)| (name.as_str(), "")), + ); + bindings +} + +fn shipped_bindings() -> Vec<(&'static str, &'static str)> { vec![ ("NewTab", per_platform("secondary-t", "secondary-shift-t")), ("NewWorkspace", "secondary-shift-n"), @@ -332,6 +350,11 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("ForkAgentSessionDown", ""), ("ForkAgentSessionUp", ""), ("CopyAgentSessionId", ""), + // ⌘⇧A for "agent" on macOS, where nothing else in the table or the + // terminal holds it. Off macOS Ctrl+Shift+A is select-all in every + // Linux terminal and in this prompt editor's Ctrl+A family, so it ships + // unbound there rather than on a chord nobody would guess. + ("NewAgentTab", per_platform("secondary-shift-a", "")), ("StopWorkspace", ""), ("DeleteWorkspace", ""), ("RenameWorkspace", ""), @@ -579,6 +602,15 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { t_fmt(L10nKey::KeybindGoToTab, &[("n", n)]), )); } + if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) { + return Some(( + CommandGroup::Agents, + t_fmt( + L10nKey::AppCmdAgentLaunchTitle, + &[("name", agent.display_name())], + ), + )); + } if let Some(n) = action.strip_prefix("SelectWorkspace") { return Some(( CommandGroup::Workspaces, @@ -831,6 +863,7 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::Agents, t(L10nKey::CmdCopySessionId).to_string(), ), + "NewAgentTab" => (CommandGroup::Agents, t(L10nKey::CmdNewAgentTab).to_string()), "TogglePalette" => ( CommandGroup::Application, t(L10nKey::AppMenuCommandPalette).to_string(), @@ -1348,6 +1381,9 @@ fn action_context(action: &str) -> Option<&'static str> { } fn make_binding(action: &str, keystroke: &str) -> Option { + if let Some(agent) = crate::ui::agent_launch::agent_for_launch_action(action) { + return Some(KeyBinding::new(keystroke, LaunchAgent { agent }, None)); + } Some(match action { "NewTab" => KeyBinding::new(keystroke, NewTab, None), "NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None), @@ -1371,6 +1407,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ForkAgentSessionDown" => KeyBinding::new(keystroke, ForkAgentSessionDown, None), "ForkAgentSessionUp" => KeyBinding::new(keystroke, ForkAgentSessionUp, None), "CopyAgentSessionId" => KeyBinding::new(keystroke, CopyAgentSessionId, None), + "NewAgentTab" => KeyBinding::new(keystroke, NewAgentTab, None), "SplitRight" => KeyBinding::new(keystroke, SplitRight, None), "SplitDown" => KeyBinding::new(keystroke, SplitDown, None), "FocusNextPane" => KeyBinding::new(keystroke, FocusNextPane, None), @@ -2321,6 +2358,50 @@ mod tests { let ks = Keystroke::parse("secondary").unwrap(); assert_eq!(spec_from_keystroke(&ks), None); } + + #[test] + fn new_agent_tab_ships_a_chord_only_where_one_is_free() { + let mut effective: Vec<(String, String)> = default_bindings() + .into_iter() + .map(|(a, k)| (a.to_string(), k.to_string())) + .collect(); + let default = effective + .iter() + .find(|(action, _)| action == "NewAgentTab") + .map(|(_, key)| key.clone()) + .expect("NewAgentTab has to be listed or it cannot be bound"); + if cfg!(target_os = "macos") { + assert_eq!(default, "secondary-shift-a"); + assert_eq!( + dispatched(&effective, &default, "Terminal"), + vec![NewAgentTab::name_for_type()], + "{default} must reach NewAgentTab, and nothing else may answer it" + ); + } else { + assert_eq!(default, "", "Ctrl+Shift+A is select-all off macOS"); + } + + // Each agent's own launch is a slot of its own, and a key bound to it + // dispatches that agent. + let name = crate::ui::agent_launch::launch_action_name( + tty7_core::core::cli_agent::CLIAgent::Codex, + ); + effective + .iter_mut() + .find(|(action, _)| action == name) + .expect("every agent has a keymap slot") + .1 = "ctrl-alt-shift-c".to_string(); + let mut keymap = gpui::Keymap::default(); + keymap.add_bindings(action_bindings(&effective)); + let typed = [Keystroke::parse("ctrl-alt-shift-c").unwrap()]; + let context = [gpui::KeyContext::parse("Terminal").unwrap()]; + let hits = keymap.bindings_for_input(&typed, &context).0; + assert_eq!(hits.len(), 1); + assert!(hits[0].action().partial_eq(&LaunchAgent { + agent: tty7_core::core::cli_agent::CLIAgent::Codex, + })); + assert_eq!(action_entry(name).0, CommandGroup::Agents); + } } #[cfg(test)] diff --git a/src/ui/mod.rs b/src/ui/mod.rs index df167e08..a286cb19 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +pub mod agent_launch; pub mod app; pub mod assets; pub mod code_editor; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index d6865b2f..50bde72e 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -36,6 +36,8 @@ pub enum CommandKind { HibernateTab, ForkAgentSession, CopyAgentSessionId, + NewAgentTab, + LaunchAgent(crate::core::cli_agent::CLIAgent), ResetFontSize, NextPane, PrevPane, @@ -148,6 +150,7 @@ impl CommandKind { HibernateTab => "hibernate-tab", ForkAgentSession => "fork-agent-session", CopyAgentSessionId => "copy-agent-session-id", + NewAgentTab => "new-agent-tab", ResetFontSize => "reset-font-size", NextPane => "next-pane", PrevPane => "prev-pane", @@ -227,7 +230,8 @@ impl CommandKind { | EditSavedProfile(_) | OpenShell(_) | QuickConnect(_) - | SaveQuickConnect(_) => return None, + | SaveQuickConnect(_) + | LaunchAgent(_) => return None, }) } @@ -240,6 +244,12 @@ impl CommandKind { CutText => return inline("secondary-x"), PasteText => return inline("secondary-v"), SelectAllText => return inline("secondary-a"), + LaunchAgent(agent) => { + return crate::ui::keymap::effective_key( + crate::ui::agent_launch::launch_action_name(*agent), + cx, + ); + } _ => {} } let action = match self { @@ -262,6 +272,7 @@ impl CommandKind { HibernateTab => "HibernateTab", ForkAgentSession => "ForkAgentSession", CopyAgentSessionId => "CopyAgentSessionId", + NewAgentTab => "NewAgentTab", ResetFontSize => "ResetFontSize", NextPane => "FocusNextPane", PrevPane => "FocusPrevPane", @@ -344,7 +355,8 @@ impl CommandKind { | OpenShell(_) | SaveSshSessionAsHost | QuickConnect(_) - | SaveQuickConnect(_) => return None, + | SaveQuickConnect(_) + | LaunchAgent(_) => return None, }; crate::ui::keymap::effective_key(action, cx) } @@ -594,6 +606,8 @@ impl Command { ]; let agents = [ + Command::localized(L10nKey::CmdNewAgentTab, NewAgentTab) + .with_subtitle(t(L10nKey::CmdNewAgentTabSubtitle)), Command::localized(L10nKey::CmdAgentSendSelection, SendSelectionToAgent) .with_subtitle(t(L10nKey::CmdAgentSendSelectionSubtitle)), Command::localized(L10nKey::CmdAgentSendGitDiffForReview, SendGitDiffToAgent) diff --git a/src/ui/pending_pane.rs b/src/ui/pending_pane.rs index 93ed11c2..0428c1d5 100644 --- a/src/ui/pending_pane.rs +++ b/src/ui/pending_pane.rs @@ -20,6 +20,10 @@ pub struct PendingSpawn { pub agent: Option, pub agent_session_id: Option, pub agent_launch_argv: Option>, + /// A command line to type into the shell once the pane exists, for a pane + /// opened to run something (a quick-launched agent). Typed only into this + /// pane, and only when it is a fresh shell rather than a restored one. + pub run_on_land: Option, pub owner: Option, pub font_size: f32, } diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 6a06992e..ddb712f7 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -884,6 +884,19 @@ impl NewTabMenu { }); } })); + // The agents' seam into the palette, the same way the row above is the + // hosts': the menu names none of them, the palette holds them all. + let app = self.app.clone(); + menu = menu.item(PopupMenuItem::new(t(L10nKey::TabMenuLaunchAgent)).on_click( + move |_, window, cx| { + if let Some(app) = app.upgrade() { + app.update(cx, |this, cx| { + let query = crate::ui::agent_launch::PALETTE_AGENT_QUERY; + this.open_palette(query, window, cx); + }); + } + }, + )); // The one place ⌥ is spelled out. Nothing else in the app teaches it, // and a modifier nobody is told about is a feature nobody has. No rule From cf0e8f02e6943b3a606f26efa3d587da409625c7 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:53:26 +0800 Subject: [PATCH 12/20] feat(sidebar): pinned groups above auto groups (#962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): keep pinned groups on the workspace and derive the rest Replace the sidebar's hand-made groups with the model from #955: the sidebar groups tabs by repo automatically, and you pin what you want to keep. - Pinned groups (`PinnedGroup`: id, optional name, optional folder, fold) are stored on the workspace in the machine tree, in display order, and a tab points at one by `GroupId`. Everything else is an auto group worked out every frame and never stored: by repo home, or by `user@host` for an SSH pane — native or a shell that ssh'd onward — so `/home/ubuntu` on two machines no longer lands under one header. - A tab whose cwd *enters* a pinned folder joins it (deepest folder wins; a repo home equal to the folder counts, which keeps worktrees with their repo). It is edge-triggered through `EntryWatch`, so a tab dragged out while still inside the folder stays out until it leaves and comes back, and a tab restored at launch is not pulled in by where it already sits. - Groups sync as one `WorkspaceSetGroups` / `GroupsChanged`, pushed only from an edit and adopted from every pull, so a fresh window can never push an empty set over the workspace's. The machine hands tabs naming a dropped group back to auto grouping in the same mutation. Control dialect → v11. - Config: `sidebar_grouping` (three modes) and `sidebar_collapsed_groups` give way to one `sidebar_auto_grouping` toggle; folds live with the workspace. - `tty7 tab ls` reports the pinned group a tab is in (name or folder leaf; JSON carries id, name and folder). * feat(sidebar): draw pinned groups above a divider, with their own gestures The sidebar now reads as two halves: the groups you keep, in the order you put them, then a divider, then the groups it works out (Arc-style). - Pinned headers drag-reorder among themselves (their own reorder surface, so a pinned header cannot be dropped among the derived ones); the order lands on the workspace's group list, not on the tabs. - An auto header carried above the divider is pinned when let go. With nothing pinned yet the divider appears during that drag as a "Drop here to pin" zone, since a hairline at the top of the list is nothing to aim at. - A tab kept in a pinned group and dropped anywhere below the divider goes back to auto grouping; the divider lights to say so. - An empty pinned group stays, with a "+ New Tab" row that opens a tab in its folder (or where ⌘T would, for a label group) and files it there. - Folder groups carry a pin mark that unpins on click and a tooltip with the folder; auto headers show pin and "+" on hover. - Header menus: pinned — Rename, Set Folder… (local workspaces), Use Current Tab's Folder, Clear Folder, New Tab, Unpin (folder groups), Delete. Auto — Pin Group, New Tab. Nothing renames an auto group; nothing pins implicitly. * feat(sidebar): open folders as pinned groups from Finder, the file tree and the palette Every way into a pinned group the design calls for: - Drop a folder from Finder or Explorer onto the sidebar to pin it (a local workspace only — a dropped path is this machine's, and a folder group keeps a directory on the workspace's host). Files are let fall. - "Pin as Group" on a folder in the file tree, on local and remote workspaces alike, since the tree and the group are both on the workspace's host. - Palette "New Group" makes an empty label group and opens its name for typing; "Open Folder as Group…" picks a folder with the system picker, pins it and opens a tab in it. The picker browses this computer, so that one is not offered on a remote workspace. - Tab right-click "Move to Group" lists the pinned groups plus "New Group…", which files the tab in a fresh label group with its name open for typing. Pinning a folder already pinned hands back the group that keeps it rather than making a second one to split its tabs with. * fix(sidebar): let groups that arrive from elsewhere pull no tab into a folder A window draws its first frames before its copy of the workspace's groups lands, so every tab's entry watch recorded "in no folder" — and the groups landing then read as each tab walking into its folder. A restored tab, or one dragged out of its folder group, was pulled back in on every launch. Groups adopted from a pull or from another window's `GroupsChanged` now start every tab's watch over from where it is; only this window's own pin gathers the tabs inside the folder, and says so tab by tab. A tab also goes up with the group it names even when the window does not know that group yet, so a sync in that same gap cannot send every kept tab back to auto grouping. * docs(sidebar): describe pinned and auto groups, and log the change Rewrite the sidebar page's grouping section around "grouped by repo automatically; pin what you want to keep": the divider, folder and label groups, the edge-triggered join, every way to pin, and the header menus. The configuration reference swaps `sidebar_grouping` for `sidebar_auto_grouping`, the CLI reference describes the GROUP column as the pinned group, and the changelog gains an Unreleased entry (#955). * fix(sidebar): file a tab opened by the CLI in a pinned folder into it A tab that reaches a window as TabCreated — from `tty7 tab new` or another window — started its entry watch as a restored tab, so opening one inside a pinned folder left it in the auto group below. It is as new as a tab opened here, and now joins the folder like one; every window that hears of it reaches the same answer. * test(machine): build the group sets in their initializers Clippy's field_reassign_with_default on the two WorkspaceGroups the set-groups test assembles. * fix(sidebar): draw restored tabs in their auto group, and title by repo again Auto groups are not stored, so after a restart every tab sat in Ungrouped until its own repo probe came back, then jumped; before pinned groups the stored repo key put it in place on the first frame. Each tab now carries `last_auto`, the auto group it last resolved to, as a hint: stored with the tab, sent up alongside its group in `TabSetGroup` whenever the live answer moves, and used to draw the tab until the probe answers. The probe always wins and rewrites the hint, and the hint never outranks a pinned group or the folder-entry rule. Another window's hint only fills a gap, so two windows can never bounce a disagreement between them. The workspace's fallback title regained the repo majority it lost: the most common pinned folder first, then the repo most unpinned tabs were last filed under (a worktree counting toward its repo home), then a pane's cwd. * refactor: drop what the new sidebar left unused, and two clippy findings - `TerminalView::native_ssh_cwd` and its helper existed for the sidebar's old folder grouping of native SSH panes; an SSH tab now groups by host, and nothing else read it. - The file tree's context menu takes `cx` instead of `danger` and the new groups flag, back to the argument count it had on main. - A title test builds its workspace in the initializer. --- CHANGELOG.md | 29 + README.md | 2 +- assets/icons/pin.svg | 1 + crates/tty7-cli/src/commands.rs | 89 +- crates/tty7-cli/src/output.rs | 11 + crates/tty7-cli/src/testbed.rs | 11 +- crates/tty7-core/src/core/config.rs | 63 +- crates/tty7-core/src/core/group_key.rs | 664 +++- crates/tty7-core/src/core/machine.rs | 140 +- crates/tty7-core/src/core/session.rs | 7 +- crates/tty7-core/src/daemon/control.rs | 18 +- crates/tty7-core/src/host/server.rs | 8 +- .../tty7-server/tests/scrollback_restore.rs | 2 +- docs/cli/reference.mdx | 10 +- docs/customization/settings.mdx | 4 +- docs/reference/configuration.mdx | 2 +- docs/window/sidebar.mdx | 66 +- src/terminal/view.rs | 54 - src/ui/app.rs | 148 +- src/ui/assets.rs | 1 + src/ui/file_tree.rs | 25 +- src/ui/home.rs | 21 +- src/ui/i18n/en.rs | 25 +- src/ui/i18n/ja.rs | 25 +- src/ui/i18n/mod.rs | 19 +- src/ui/i18n/zh.rs | 25 +- src/ui/machine_mirror.rs | 146 +- src/ui/palette.rs | 6 + src/ui/reorder.rs | 130 +- src/ui/settings.rs | 40 +- src/ui/tab_sidebar.rs | 2658 +++++++++++++---- src/ui/tab_strip.rs | 47 +- src/ui/tree_sync.rs | 233 +- 33 files changed, 3615 insertions(+), 1115 deletions(-) create mode 100644 assets/icons/pin.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index b5328e8f..426b3eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The sidebar groups tabs by repo automatically; pin what you want to keep** + (#955). Groups now come in two halves, split by a divider. Below it, every + tab you have not pinned is filed under its git repository, and an SSH tab + under the host it is on rather than its remote path — `/home/ubuntu` on two + machines used to share a header. Above it sit the groups you keep, in the + order you drag them into, until you delete them. A pinned group can keep a + folder: a tab whose working directory enters it joins it (the deepest folder + wins when they nest, and a worktree of a pinned repo counts), while a tab you + drag out stays out until it leaves the folder and comes back. Pin an auto + group with the pin on its header or by dragging the header above the + divider; pin a folder by dropping it from Finder, with **Pin as Group** in the + Files panel, or with **Open Folder as Group…** in the palette. **New Group** + in the palette or on a tab's right-click makes a label group. Deleting a + group closes nothing — its tabs go back to auto grouping — and dragging a tab + below the divider does the same for one tab. Groups, their order and which + are folded are stored with the workspace, so every window onto it agrees. + **Settings → Window & Tabs → Auto grouping** replaces the three-way *Sidebar + grouping* choice; off, unpinned tabs sit in one flat list under the pinned + groups. Scratch is now **Ungrouped**. + - **The New Tab menu names three shells, not every one the machine has.** A stock macOS box reports nine, so the `+` menu opened on a column of `csh`, `tcsh` and `ksh` that almost nobody runs, above the SSH hosts people came for. @@ -125,6 +145,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 already sends, so a remote repository gets them unchanged. Searching the diff text itself is not part of this. +### Removed + +- **The *By repo or folder* grouping mode, "Group Automatically", and groups + stored by name.** A folder you want grouped is pinned instead, and dragging a + tab below the divider is the way back to automatic grouping. Hand-made groups + and folds from earlier versions are not carried over. The control dialect + moves to v11, so each remote host needs one Update Server, which ends the + sessions on it. + ### Fixed - **Nerd Font icons from a fallback font come out at the text's size** (#866). diff --git a/README.md b/README.md index 9754c716..74d6f4ac 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (23 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | +| **Agent-aware** | per-pane detection (23 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 00000000..05568e0d --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1 @@ + diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index d7bea737..45198d3f 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -918,12 +918,11 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R vec![ format!("@{}", resolve::ordinal_of(&machine, tab.id).unwrap_or(0)), output::tab_label(view), - // The GUI files tabs under a directory and shows its last - // segment as the heading; the full path would be the widest - // column in the table for no gain. - tab.sidebar_group - .as_deref() - .map(|g| output::path_leaf(g).to_string()) + // Only a pinned group is the workspace's to report: an auto + // group is worked out by the GUI from a probe of the tab's + // cwd, which the tree does not hold. + pinned_group_of(ws, tab) + .map(output::pinned_group_label) .unwrap_or_else(|| "-".to_string()), tab.root.pane_ids().len().to_string(), ] @@ -942,7 +941,11 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R "name": tab.name, "label": output::tab_label(view), "agent": view.agent.map(|a| a.display_name()), - "group": tab.sidebar_group, + "group": pinned_group_of(ws, tab).map(|g| json!({ + "id": g.id.to_string(), + "name": g.given_name(), + "folder": g.folder, + })), "panes": tab.root.pane_ids(), "hibernated": tab.hibernated, }) @@ -954,6 +957,16 @@ fn tab_ls(explicit: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> R ) } +/// The pinned group `tab` is filed under, when it names one the workspace +/// still has. A tab naming a deleted group is an auto tab, the same way the +/// sidebar draws it. +fn pinned_group_of<'a>( + ws: &'a tty7_core::core::machine::Workspace, + tab: &tty7_core::core::machine::Tab, +) -> Option<&'a tty7_core::core::group_key::PinnedGroup> { + ws.groups.get(tab.group?) +} + fn tab_new( explicit: Option<&str>, cwd: Option, @@ -2471,10 +2484,22 @@ mod tests { ); } + /// File tab @2 under a pinned folder group, the way the GUI would. + fn pin_second_tab(backend: &mut MockBackend, name: Option<&str>) -> String { + use tty7_core::core::group_key::PinnedGroup; + let mut group = PinnedGroup::folder(std::path::Path::new("C:\\proj\\sub")); + group.name = name.map(str::to_string); + let id = group.id; + let ws = &mut backend.machine.workspaces[0]; + ws.groups.pinned.push(group); + ws.tabs[1].group = Some(id); + id.to_string() + } + #[test] fn tab_ls_names_an_unnamed_tab_and_shows_the_leaf_of_its_group() { let mut backend = mock(); - backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into()); + pin_second_tab(&mut backend, None); let out = run_cli( &["tty7", "tab", "ls", "api"], @@ -2483,17 +2508,55 @@ mod tests { ); // @1 was named; @2 was not, so it borrows the leaf of its cwd. The - // GROUP column is the heading's last segment, not the whole path. + // GROUP column is what the header reads — the folder's last segment + // for a group nobody renamed, not the whole path. assert_eq!( human(out), "TAB NAME GROUP PANES\n@1 build - 1\n@2 proj sub 2\n" ); } + #[test] + fn tab_ls_shows_a_renamed_group_by_its_name() { + let mut backend = mock(); + pin_second_tab(&mut backend, Some("work")); + + let out = run_cli( + &["tty7", "tab", "ls", "api"], + &Context::default(), + &mut backend, + ); + + assert_eq!( + human(out), + "TAB NAME GROUP PANES\n@1 build - 1\n@2 proj work 2\n" + ); + } + + /// A tab naming a group the workspace no longer has is an auto tab, as + /// the sidebar draws it. + #[test] + fn tab_ls_shows_no_group_for_a_tab_naming_a_deleted_one() { + let mut backend = mock(); + pin_second_tab(&mut backend, None); + backend.machine.workspaces[0].groups.pinned.clear(); + + let out = run_cli( + &["tty7", "tab", "ls", "api"], + &Context::default(), + &mut backend, + ); + + assert_eq!( + human(out), + "TAB NAME GROUP PANES\n@1 build - 1\n@2 proj - 2\n" + ); + } + #[test] fn tab_ls_json_keeps_the_literal_name_beside_the_label() { let mut backend = mock(); - backend.machine.workspaces[0].tabs[1].sidebar_group = Some("C:\\proj\\sub".into()); + let id = pin_second_tab(&mut backend, None); let out = run_cli( &["tty7", "tab", "ls", "api"], @@ -2508,9 +2571,11 @@ mod tests { assert_eq!(tabs[1]["name"], Value::Null, "nobody named this tab"); assert_eq!(tabs[1]["label"], "proj", "the table's stand-in travels too"); assert_eq!( - tabs[1]["group"], "C:\\proj\\sub", - "the JSON keeps the whole heading the table abbreviates" + tabs[1]["group"], + json!({ "id": id, "name": null, "folder": "C:\\proj\\sub" }), + "the JSON keeps the whole folder the table abbreviates" ); + assert_eq!(tabs[0]["group"], Value::Null, "an auto tab names no group"); } #[test] diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index c165a3f7..85639207 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -47,6 +47,17 @@ fn clamp(s: &str, max: usize) -> String { /// The last segment of a path, for columns that have room for a word and not /// for a path. Both separators: the same server answers a Windows client, and /// `C:\proj` has to lose its head too. +/// What the sidebar's header for a pinned group reads: the name the user gave +/// it, or the last segment of its folder — the full path would be the widest +/// column in the table for no gain. +pub fn pinned_group_label(group: &tty7_core::core::group_key::PinnedGroup) -> String { + group + .given_name() + .map(str::to_string) + .or_else(|| group.folder.as_deref().map(|f| path_leaf(f).to_string())) + .unwrap_or_else(|| "-".to_string()) +} + pub fn path_leaf(path: &str) -> &str { let trimmed = path.trim_end_matches(['/', '\\']); match trimmed.rsplit(['/', '\\']).next() { diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index a24aaf75..84482064 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -10,14 +10,16 @@ pub fn two_workspace_machine() -> Machine { Tab { id: TabId::new(), name: Some("build".into()), - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }, Tab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Split { axis: Axis::Horizontal, ratio: 0.5, @@ -29,6 +31,7 @@ pub fn two_workspace_machine() -> Machine { ], active_tab: None, attachment: None, + groups: Default::default(), }; let web = Workspace { id: WorkspaceId::new(), @@ -37,12 +40,14 @@ pub fn two_workspace_machine() -> Machine { tabs: vec![Tab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 5 }, hibernated: false, }], active_tab: None, attachment: None, + groups: Default::default(), }; let record = |id: u64, cwd: &str| PaneRecord { id, diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index b277f52f..5c6b9a18 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -268,24 +268,12 @@ pub struct Config { /// [`Self::editor_soft_wrap`]. Files that are not Markdown ignore it. #[serde(default)] pub editor_markdown_preview: bool, - #[serde(default, deserialize_with = "de_lenient")] - pub sidebar_grouping: SidebarGrouping, - /// Which sidebar groups are folded shut, by group key: the repo root the - /// group is named after, or the empty string for the scratch group, which - /// has no root of its own and no real key can ever collide with. - /// - /// Kept as a list of the folded ones rather than a flag per group because - /// groups come and go with the tabs — a group nobody has opened yet has to - /// start expanded, and an entry for a repo that is no longer around costs - /// one dead path in the file. - /// - /// `String`, not `PathBuf`: serde refuses to serialize a non-UTF-8 - /// `PathBuf`, and `Config::save` turns that refusal into one `warn!` and - /// a return — so a single repo root with odd bytes in it would silently - /// stop the *whole* config being written from then on. A lossy spelling - /// of such a root at worst folds two of them together. - #[serde(default, deserialize_with = "de_lenient")] - pub sidebar_collapsed_groups: Vec, + /// Whether the sidebar files tabs nobody pinned into groups of its own — + /// by repository, and by host for an SSH pane. Off, those tabs sit in one + /// flat list below the pinned groups, which are the user's and show + /// either way. + #[serde(default = "default_true")] + pub sidebar_auto_grouping: bool, #[serde(default = "default_true")] pub sidebar_diff_preview: bool, #[serde(default, deserialize_with = "de_lenient")] @@ -597,17 +585,6 @@ pub enum WindowBackdrop { Off, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum SidebarGrouping { - #[default] - Repo, - /// By repository where there is one; a tab whose cwd is known not to be - /// in a repo groups under that cwd instead of falling to Scratch. - RepoOrDirectory, - None, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NotifyMode { @@ -770,8 +747,7 @@ impl Default for Config { scm_changes_tree: false, editor_soft_wrap: false, editor_markdown_preview: false, - sidebar_grouping: SidebarGrouping::Repo, - sidebar_collapsed_groups: Vec::new(), + sidebar_auto_grouping: true, sidebar_diff_preview: true, notify_on_command_finish: NotifyMode::Unfocused, check_for_updates: true, @@ -1778,24 +1754,13 @@ mod tests { } #[test] - fn sidebar_grouping_defaults_and_round_trips_leniently() { - assert_eq!(Config::default().sidebar_grouping, SidebarGrouping::Repo); - - let text = serde_json::to_string(&Config { - sidebar_grouping: SidebarGrouping::RepoOrDirectory, - ..Config::default() - }) - .unwrap(); - assert!(text.contains("\"sidebar_grouping\":\"repo-or-directory\"")); - let back: Config = serde_json::from_str(&text).unwrap(); - assert_eq!(back.sidebar_grouping, SidebarGrouping::RepoOrDirectory); - - let flat: Config = serde_json::from_str(r#"{"sidebar_grouping":"none"}"#).unwrap(); - assert_eq!(flat.sidebar_grouping, SidebarGrouping::None); - - // Unknown values fall back to Repo instead of rejecting the whole config. - let lenient: Config = serde_json::from_str(r#"{"sidebar_grouping":"folders"}"#).unwrap(); - assert_eq!(lenient.sidebar_grouping, SidebarGrouping::Repo); + fn sidebar_auto_grouping_defaults_on_and_round_trips() { + assert!(Config::default().sidebar_auto_grouping); + let off: Config = serde_json::from_str(r#"{"sidebar_auto_grouping":false}"#).unwrap(); + assert!(!off.sidebar_auto_grouping); + let json = serde_json::to_string(&off).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(!back.sidebar_auto_grouping, "persisted"); } #[test] diff --git a/crates/tty7-core/src/core/group_key.rs b/crates/tty7-core/src/core/group_key.rs index 5992fbc3..99a8f0b4 100644 --- a/crates/tty7-core/src/core/group_key.rs +++ b/crates/tty7-core/src/core/group_key.rs @@ -1,156 +1,610 @@ -//! Which sidebar group a tab belongs to, and where that answer came from. +//! Which sidebar group a tab belongs to, and who decided. //! -//! Two kinds of group live in the same sidebar and look alike, but they are -//! owned by different things. A [`GroupKey::Repo`] is *derived*: the sidebar -//! recomputes it every frame from the tab's cwd, so a tab that `cd`s into -//! another repository walks into another group on its own. A -//! [`GroupKey::Custom`] is *stated*: the user put the tab there by hand, and -//! nothing about the cwd may move it out again. +//! "The sidebar groups tabs by repo automatically. Pin what you want to keep." +//! Everything here follows from that sentence. Groups above the divider are +//! *kept*: a [`PinnedGroup`] is stored with the workspace, has an id, an order +//! the user chose and a name they may change, and a tab in one only ever +//! leaves by hand. Everything below the divider is *derived*: an [`AutoKey`] +//! is worked out from the tab's cwd (or, for an SSH pane, from the host it is +//! on) every time the sidebar is drawn, so a tab that `cd`s into another +//! repository walks into another group on its own, and a group whose last tab +//! leaves simply stops existing. Nothing about an auto group is stored except +//! whether it is folded. //! -//! Keeping the two in one enum is what makes the "never overwrite a stated -//! group" rule enforceable — the alternative, a path plus a `pinned` flag, -//! leaves the flag and the path free to disagree, and a custom group's name -//! is not a path in the first place. +//! A pinned group may name a folder. That is what lets a group be kept for a +//! project while its tabs come and go: a tab whose cwd *enters* the folder +//! joins it (see [`EntryWatch`]), and an empty folder group stays on screen +//! with a row to open a tab in it. A pinned group without a folder is a plain +//! label the user files tabs under by hand. use std::path::{Path, PathBuf}; -/// The prefix that marks a custom group in the flat string spelling. No -/// absolute path can collide with it: a POSIX root starts with `/` and a -/// Windows one with a single-letter drive, so neither can begin `custom:`. -const CUSTOM: &str = "custom:"; +use serde::{Deserialize, Serialize}; +/// The identity of a pinned group. A name cannot be one — two groups may share +/// a name, and a rename must not orphan every tab filed under the old one — +/// so tabs point at this instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GroupId(uuid::Uuid); + +impl GroupId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Default for GroupId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for GroupId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A group the user chose to keep. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PinnedGroup { + pub id: GroupId, + /// What the header reads. `None` falls back to the folder's last + /// component, so pinning `~/src/tty7` reads `tty7` until someone renames + /// it — and renaming it back to nothing brings that back. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// A directory on the workspace's own host, or `None` for a label group. + /// + /// A `String`, not a `PathBuf`, for the same reason `PaneRecord::cwd` is + /// one: serde refuses to write a non-UTF-8 `PathBuf`, and the machine tree + /// is written whole — one odd byte in one folder would stop every layout + /// on the machine being saved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub collapsed: bool, +} + +impl PinnedGroup { + /// A group for tabs the user files by hand, called `name`. + pub fn label(name: impl Into) -> Self { + Self { + id: GroupId::new(), + name: Some(name.into()), + folder: None, + collapsed: false, + } + } + + /// A group that keeps `folder`, named after it until renamed. + pub fn folder(folder: &Path) -> Self { + Self { + id: GroupId::new(), + name: None, + folder: Some(folder.to_string_lossy().into_owned()), + collapsed: false, + } + } + + pub fn folder_path(&self) -> Option<&Path> { + self.folder.as_deref().map(Path::new) + } + + /// The name the user typed, when there is one worth printing. A blank + /// name is no name: it would draw an unlabelled header. + pub fn given_name(&self) -> Option<&str> { + self.name + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + } +} + +/// A group the sidebar works out for itself. Never stored as a membership — +/// only as the key a fold is remembered under. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(from = "AutoKeyWire", into = "AutoKeyWire")] +pub enum AutoKey { + /// The repository home a tab's cwd resolved to. A linked worktree resolves + /// to the repo it belongs to, and a submodule to itself — so a submodule + /// gets its own group, and a worktree sits with its main checkout. + Repo(PathBuf), + /// The host an SSH pane is on, as its `user@host` target. + /// + /// By host and not by remote path: two machines' `/home/ubuntu` are not + /// the same directory, and grouping by the path alone filed tabs on two + /// different boxes under one header. + SshHost(String), +} + +/// The spelling [`AutoKey`] is stored in. A repo root goes down as a lossy +/// string for the reason [`PinnedGroup::folder`] is one. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum AutoKeyWire { + Repo(String), + SshHost(String), +} + +impl From for AutoKey { + fn from(w: AutoKeyWire) -> Self { + match w { + AutoKeyWire::Repo(p) => AutoKey::Repo(PathBuf::from(p)), + AutoKeyWire::SshHost(h) => AutoKey::SshHost(h), + } + } +} + +impl From for AutoKeyWire { + fn from(k: AutoKey) -> Self { + match k { + AutoKey::Repo(p) => AutoKeyWire::Repo(p.to_string_lossy().into_owned()), + AutoKey::SshHost(h) => AutoKeyWire::SshHost(h), + } + } +} + +/// Where a tab is drawn: a pinned group, an auto group, or — as `None` beside +/// it — Ungrouped. #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum GroupKey { - /// The repository (or, under `RepoOrDirectory`, the plain directory) the - /// sidebar worked out for itself. Recomputed from the cwd every frame. - Repo(PathBuf), - /// A group the user named and put this tab in. Never recomputed. - Custom(String), + Pinned(GroupId), + Auto(AutoKey), } impl GroupKey { - /// The custom group called `name`, or `None` when the name is blank — - /// an empty name would print as an unlabelled header and collide with - /// Scratch, which already owns "no name at all". - pub fn custom(name: &str) -> Option { - let name = name.trim(); - (!name.is_empty()).then(|| Self::Custom(name.to_string())) + pub fn is_pinned(&self) -> bool { + matches!(self, Self::Pinned(_)) } - pub fn is_custom(&self) -> bool { - matches!(self, Self::Custom(_)) - } - - pub fn repo_root(&self) -> Option<&Path> { + pub fn pinned(&self) -> Option { match self { - Self::Repo(p) => Some(p), - Self::Custom(_) => None, + Self::Pinned(id) => Some(*id), + Self::Auto(_) => None, } } - /// The flat spelling used everywhere a group has to survive as one - /// string: the control protocol, the session file, and the list of - /// folded groups in the config. - pub fn encode(&self) -> String { + pub fn auto(&self) -> Option<&AutoKey> { match self { - // Lossy on purpose. `Config::save` turns a serde failure on a - // non-UTF-8 `PathBuf` into one `warn!` and a return, so a single - // repo root with odd bytes in it would silently stop the whole - // config being written from then on. Two such roots folding - // together is the cheaper failure by a wide margin. - Self::Repo(p) => p.to_string_lossy().into_owned(), - Self::Custom(name) => format!("{CUSTOM}{name}"), - } - } - - /// Reads back [`GroupKey::encode`]. Anything without the marker is a - /// repo root, which is also what every group written before custom - /// groups existed decodes to. - pub fn decode(s: &str) -> Option { - match s.strip_prefix(CUSTOM) { - Some(name) => Self::custom(name), - None => (!s.is_empty()).then(|| Self::Repo(PathBuf::from(s))), + Self::Auto(key) => Some(key), + Self::Pinned(_) => None, } } } -impl serde::Serialize for GroupKey { - fn serialize(&self, ser: S) -> Result { - ser.serialize_str(&self.encode()) +/// Everything about a workspace's sidebar groups that outlives a frame, stored +/// on the workspace in the machine tree so every window onto it agrees. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceGroups { + /// In display order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pinned: Vec, + /// The auto groups folded shut. A list of the folded ones rather than a + /// flag per group because auto groups come and go with the tabs — one + /// nobody has seen yet has to start open. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub auto_collapsed: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ungrouped_collapsed: bool, +} + +impl WorkspaceGroups { + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + pub fn get(&self, id: GroupId) -> Option<&PinnedGroup> { + self.pinned.iter().find(|g| g.id == id) + } + + pub fn get_mut(&mut self, id: GroupId) -> Option<&mut PinnedGroup> { + self.pinned.iter_mut().find(|g| g.id == id) + } + + pub fn contains(&self, id: GroupId) -> bool { + self.get(id).is_some() + } + + /// Whether the group drawn under `key` is folded; `None` is Ungrouped. + pub fn is_folded(&self, key: Option<&GroupKey>) -> bool { + match key { + Some(GroupKey::Pinned(id)) => self.get(*id).is_some_and(|g| g.collapsed), + Some(GroupKey::Auto(auto)) => self.auto_collapsed.contains(auto), + None => self.ungrouped_collapsed, + } + } + + pub fn toggle_folded(&mut self, key: Option<&GroupKey>) { + match key { + Some(GroupKey::Pinned(id)) => { + if let Some(g) = self.get_mut(*id) { + g.collapsed = !g.collapsed; + } + } + Some(GroupKey::Auto(auto)) => { + match self.auto_collapsed.iter().position(|k| k == auto) { + Some(at) => { + self.auto_collapsed.remove(at); + } + None => self.auto_collapsed.push(auto.clone()), + } + } + None => self.ungrouped_collapsed = !self.ungrouped_collapsed, + } + } + + /// The pinned folder group a cwd is inside, if any — see + /// [`pinned_folder_for`]. + pub fn folder_for(&self, cwd: Option<&Path>, repo_home: Option<&Path>) -> Option { + pinned_folder_for(&self.pinned, cwd, repo_home) } } -impl<'de> serde::Deserialize<'de> for GroupKey { - fn deserialize>(de: D) -> Result { - let s = String::deserialize(de)?; - Self::decode(&s).ok_or_else(|| serde::de::Error::custom("empty sidebar group key")) +/// The pinned folder group a tab sitting in `cwd` (whose repo home, when it is +/// in one, is `repo_home`) belongs in. +/// +/// A folder counts when the cwd is inside it, or when the cwd's repo home *is* +/// it: a linked worktree usually lives outside the checkout it was made from +/// (`~/wt/feature` for `~/src/tty7`), and pinning the repo is a statement about +/// all of its checkouts. When folders nest the deepest one wins, so pinning a +/// monorepo root and one package inside it files the package's tabs under the +/// package. Depth is counted in path components; two folders at the same depth +/// cannot both contain one cwd unless they are the same folder, and then the +/// first in the list wins. +pub fn pinned_folder_for( + pinned: &[PinnedGroup], + cwd: Option<&Path>, + repo_home: Option<&Path>, +) -> Option { + let mut best: Option<(usize, GroupId)> = None; + for group in pinned { + let Some(folder) = group.folder_path() else { + continue; + }; + let inside = cwd.is_some_and(|c| c.starts_with(folder)) || repo_home == Some(folder); + if !inside { + continue; + } + let depth = folder.components().count(); + if best.is_none_or(|(d, _)| depth > d) { + best = Some((depth, group.id)); + } } + best.map(|(_, id)| id) } -/// How a group is named in `Config::sidebar_collapsed_groups`. The scratch -/// group has no key of its own, so it is written as the empty string — which -/// neither a repo root nor a custom group can ever be. -pub fn collapse_key(key: Option<&GroupKey>) -> String { - key.map(GroupKey::encode).unwrap_or_default() +/// The auto group a tab resolves to, from what is known about it right now. +/// +/// `ssh_host` is the target of an SSH pane — one whose shell runs on a machine +/// no `Host` of ours reaches, so there is no repo to probe and its cwd names a +/// directory on a box the other tabs are not on. It outranks everything: the +/// host is the only thing about such a tab that is certain. +/// +/// Otherwise `known` is the repo cache's three-valued answer for the tab's cwd: +/// `Some(Some(home))` a repo, `Some(None)` a directory that is known not to be +/// in one (Ungrouped), and `None` a probe that has not landed. That last one is +/// no decision at all, answered as `None`, so the caller keeps whatever it had +/// rather than bouncing the tab through Ungrouped mid-probe. +pub fn auto_key(ssh_host: Option<&str>, known: Option>) -> Option> { + if let Some(host) = ssh_host.map(str::trim).filter(|h| !h.is_empty()) { + return Some(Some(AutoKey::SshHost(host.to_string()))); + } + Some(known?.map(AutoKey::Repo)) +} + +/// Where a tab is drawn, given the pinned group it names (if any), the auto +/// group it resolved to, and whether auto grouping is on. +/// +/// A tab naming a group that no longer exists is an auto tab: deleting a +/// group returns its tabs to auto grouping, and a window that hears of the +/// deletion before it hears of the tabs being cleared must already draw them +/// that way. +pub fn place( + stated: Option, + groups: &WorkspaceGroups, + auto_grouping: bool, + auto: Option, +) -> Option { + if let Some(id) = stated.filter(|id| groups.contains(*id)) { + return Some(GroupKey::Pinned(id)); + } + if !auto_grouping { + return None; + } + auto.map(GroupKey::Auto) +} + +/// Edge detection for rule 2: a tab joins a pinned folder when its cwd +/// *enters* the folder, not whenever it happens to be inside. +/// +/// The difference is the user's say. A tab dragged out of a folder group while +/// it is still sitting in the folder has been told where to go; re-applying +/// "inside means member" on the next frame would put it straight back. So the +/// watch remembers which pinned folder the tab was last seen in, and only a +/// change of answer — outside to inside, or one folder to another — counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct EntryWatch { + /// `None` until the tab is first looked at; then the folder it was in. + last: Option>, +} + +impl EntryWatch { + /// For a tab that already existed when this window met it — restored from + /// the machine tree at launch, or on a pull. The first look only + /// records where it is: whatever it is sitting in, it was already there, + /// and whoever last had it decided its group. + pub fn baseline() -> Self { + Self { last: None } + } + + /// For a tab that was just opened, here or by another client. It starts + /// outside every folder, so the first cwd it reports inside one is an + /// entry — which is what makes a tab opened in a pinned folder land in + /// that group. + pub fn fresh() -> Self { + Self { last: Some(None) } + } + + /// Records that the tab is now in `now` (the deepest pinned folder + /// containing it, if any), and answers the folder it just entered. + pub fn observe(&mut self, now: Option) -> Option { + let entered = match self.last { + Some(before) => now.filter(|g| before != Some(*g)), + None => None, + }; + self.last = Some(now); + entered + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn a_repo_root_round_trips_as_a_bare_path() { - let k = GroupKey::Repo(PathBuf::from("/home/u/tty7")); - assert_eq!(k.encode(), "/home/u/tty7"); - assert_eq!(GroupKey::decode("/home/u/tty7"), Some(k)); + fn p(s: &str) -> PathBuf { + PathBuf::from(s) } - #[test] - fn a_custom_group_round_trips_under_its_marker() { - let k = GroupKey::custom("工作").expect("non-blank"); - assert_eq!(k.encode(), "custom:工作"); - assert_eq!(GroupKey::decode("custom:工作"), Some(k)); + fn folders(paths: &[&str]) -> Vec { + paths + .iter() + .map(|f| PinnedGroup::folder(Path::new(f))) + .collect() } - /// The whole point of the marker. A custom group named after something - /// that looks like a path must not come back as a repo root, or folding - /// one would fold the other. + /// Pinning a monorepo root and a package in it: the package's tabs go to + /// the package, the rest of the repo's to the root. #[test] - fn a_custom_group_named_like_a_path_stays_custom() { - let k = GroupKey::custom("/home/u/tty7").expect("non-blank"); - assert_eq!(k.encode(), "custom:/home/u/tty7"); - assert_eq!(GroupKey::decode(&k.encode()), Some(k.clone())); - assert!(k.is_custom()); - assert_ne!(k, GroupKey::Repo(PathBuf::from("/home/u/tty7"))); + fn nested_pinned_folders_file_a_tab_under_the_deepest() { + let pinned = folders(&["/w/mono", "/w/mono/pkg/app", "/w/mono/pkg"]); + let at = |cwd: &str| pinned_folder_for(&pinned, Some(Path::new(cwd)), None); + assert_eq!(at("/w/mono/pkg/app/src"), Some(pinned[1].id)); + assert_eq!(at("/w/mono/pkg/lib"), Some(pinned[2].id)); + assert_eq!(at("/w/mono/docs"), Some(pinned[0].id)); + assert_eq!(at("/w/other"), None); } - /// Every group written before custom groups existed is a bare path. + /// Order in the list says nothing about depth — the deepest wins even + /// when it was pinned first. #[test] - fn an_old_session_key_decodes_as_a_repo() { + fn the_deepest_folder_wins_whichever_was_pinned_first() { + let pinned = folders(&["/w/mono/pkg", "/w/mono"]); assert_eq!( - GroupKey::decode("/w/alpha"), - Some(GroupKey::Repo(PathBuf::from("/w/alpha"))) + pinned_folder_for(&pinned, Some(Path::new("/w/mono/pkg/x")), None), + Some(pinned[0].id) + ); + } + + /// Component-wise, not by string prefix: `/w/tty7-old` is not inside + /// `/w/tty7`. + #[test] + fn a_sibling_sharing_a_prefix_is_not_inside() { + let pinned = folders(&["/w/tty7"]); + assert_eq!( + pinned_folder_for(&pinned, Some(Path::new("/w/tty7-old")), None), + None + ); + } + + /// A worktree lives outside the checkout it came from, but its repo home + /// is that checkout — pinning the repo keeps its worktrees too. + #[test] + fn a_worktree_joins_the_folder_its_repo_home_is_pinned_as() { + let pinned = folders(&["/w/tty7"]); + assert_eq!( + pinned_folder_for( + &pinned, + Some(Path::new("/tmp/wt/feature/src")), + Some(Path::new("/w/tty7")) + ), + Some(pinned[0].id) + ); + } + + /// The repo home counts as the folder itself, so a deeper folder the cwd + /// is inside still wins over it. + #[test] + fn a_deeper_folder_beats_a_repo_home_match() { + let pinned = folders(&["/w/tty7", "/tmp/wt/feature"]); + assert_eq!( + pinned_folder_for( + &pinned, + Some(Path::new("/tmp/wt/feature/src")), + Some(Path::new("/w/tty7")) + ), + Some(pinned[1].id) + ); + } + + /// A label group has no folder, so no cwd is ever inside it. + #[test] + fn a_label_group_never_pulls_a_tab_in() { + let pinned = vec![PinnedGroup::label("work")]; + assert_eq!(pinned_folder_for(&pinned, Some(Path::new("/")), None), None); + } + + #[test] + fn a_fresh_tab_entering_a_folder_joins_it() { + let g = GroupId::new(); + let mut watch = EntryWatch::fresh(); + assert_eq!(watch.observe(None), None); + assert_eq!(watch.observe(Some(g)), Some(g), "outside to inside"); + assert_eq!(watch.observe(Some(g)), None, "staying inside is no entry"); + } + + #[test] + fn a_fresh_tab_opened_inside_a_folder_joins_on_its_first_look() { + let g = GroupId::new(); + let mut watch = EntryWatch::fresh(); + assert_eq!(watch.observe(Some(g)), Some(g)); + } + + /// A tab this window met already in place keeps the group whoever had it + /// gave it — sitting in a folder is not the same as walking into it. + #[test] + fn a_restored_tab_is_not_pulled_in_by_where_it_already_is() { + let g = GroupId::new(); + let mut watch = EntryWatch::baseline(); + assert_eq!(watch.observe(Some(g)), None); + assert_eq!(watch.observe(None), None); + assert_eq!(watch.observe(Some(g)), Some(g), "but walking back in is"); + } + + /// Dragged out while inside: not pulled back until it leaves and comes + /// back. The watch is fed whether or not the tab is in a group, so it + /// already knows the tab is inside when the drag lands. + #[test] + fn a_tab_dragged_out_is_not_pulled_back_until_it_re_enters() { + let g = GroupId::new(); + let mut watch = EntryWatch::fresh(); + assert_eq!(watch.observe(Some(g)), Some(g), "joined on the way in"); + // … dragged out here; the cwd has not moved … + assert_eq!(watch.observe(Some(g)), None, "still inside: stays out"); + assert_eq!(watch.observe(Some(g)), None); + assert_eq!(watch.observe(None), None, "left"); + assert_eq!(watch.observe(Some(g)), Some(g), "re-entered: joins again"); + } + + #[test] + fn moving_from_one_folder_into_another_is_an_entry() { + let (a, b) = (GroupId::new(), GroupId::new()); + let mut watch = EntryWatch::fresh(); + watch.observe(Some(a)); + assert_eq!(watch.observe(Some(b)), Some(b)); + } + + /// The same remote path on two machines is two directories. + #[test] + fn ssh_tabs_on_two_hosts_with_one_path_land_apart() { + let one = auto_key(Some("ubuntu@alpha"), Some(Some(p("/home/ubuntu")))); + let two = auto_key(Some("ubuntu@beta"), Some(Some(p("/home/ubuntu")))); + assert_eq!(one, Some(Some(AutoKey::SshHost("ubuntu@alpha".into())))); + assert_eq!(two, Some(Some(AutoKey::SshHost("ubuntu@beta".into())))); + assert_ne!(one, two); + } + + #[test] + fn a_probe_that_has_not_landed_is_no_decision() { + assert_eq!(auto_key(None, None), None); + assert_eq!(auto_key(None, Some(None)), Some(None), "known: no repo"); + assert_eq!( + auto_key(None, Some(Some(p("/w/r")))), + Some(Some(AutoKey::Repo(p("/w/r")))) ); } #[test] - fn a_blank_name_is_no_group_at_all() { - assert_eq!(GroupKey::custom(" "), None); - assert_eq!(GroupKey::decode("custom:"), None); - assert_eq!(GroupKey::decode(""), None); - } - - #[test] - fn a_name_is_trimmed_before_it_becomes_a_key() { - assert_eq!(GroupKey::custom(" work "), GroupKey::custom("work")); - } - - #[test] - fn scratch_collapses_under_the_empty_string() { - assert_eq!(collapse_key(None), ""); + fn a_tab_in_a_pinned_group_is_drawn_there_whatever_its_cwd_says() { + let mut groups = WorkspaceGroups::default(); + let work = PinnedGroup::label("work"); + let id = work.id; + groups.pinned.push(work); + let repo = Some(AutoKey::Repo(p("/w/r"))); assert_eq!( - collapse_key(Some(&GroupKey::Repo(PathBuf::from("/w/a")))), - "/w/a" + place(Some(id), &groups, true, repo.clone()), + Some(GroupKey::Pinned(id)) ); + assert_eq!( + place(None, &groups, true, repo), + Some(GroupKey::Auto(AutoKey::Repo(p("/w/r")))) + ); + } + + /// Deleting a group returns its tabs to auto grouping — even in a window + /// that has heard of the deletion and not yet of the tabs being cleared. + #[test] + fn a_tab_naming_a_deleted_group_is_filed_automatically() { + let groups = WorkspaceGroups::default(); + let repo = Some(AutoKey::Repo(p("/w/r"))); + assert_eq!( + place(Some(GroupId::new()), &groups, true, repo), + Some(GroupKey::Auto(AutoKey::Repo(p("/w/r")))) + ); + } + + /// Auto grouping off: no auto group, but a pinned one still stands. + #[test] + fn auto_grouping_off_keeps_pinned_groups_and_drops_the_rest() { + let mut groups = WorkspaceGroups::default(); + let work = PinnedGroup::label("work"); + let id = work.id; + groups.pinned.push(work); + let repo = Some(AutoKey::Repo(p("/w/r"))); + assert_eq!(place(None, &groups, false, repo.clone()), None); + assert_eq!( + place(Some(id), &groups, false, repo), + Some(GroupKey::Pinned(id)) + ); + } + + #[test] + fn folds_are_kept_per_kind_of_group() { + let mut groups = WorkspaceGroups::default(); + let work = PinnedGroup::label("work"); + let id = work.id; + groups.pinned.push(work); + let repo = GroupKey::Auto(AutoKey::Repo(p("/w/r"))); + let pinned = GroupKey::Pinned(id); + for key in [Some(&repo), Some(&pinned), None] { + assert!(!groups.is_folded(key)); + groups.toggle_folded(key); + assert!(groups.is_folded(key)); + } + assert_eq!(groups.auto_collapsed, vec![AutoKey::Repo(p("/w/r"))]); + assert!(groups.pinned[0].collapsed); + assert!(groups.ungrouped_collapsed); + groups.toggle_folded(Some(&repo)); + assert!(groups.auto_collapsed.is_empty(), "unfolding takes it out"); + } + + #[test] + fn groups_round_trip_and_an_empty_set_writes_nothing() { + assert_eq!( + serde_json::to_string(&WorkspaceGroups::default()).unwrap(), + "{}" + ); + let mut groups = WorkspaceGroups::default(); + groups + .pinned + .push(PinnedGroup::folder(Path::new("/w/tty7"))); + groups.pinned.push(PinnedGroup::label("工作")); + groups.auto_collapsed.push(AutoKey::SshHost("u@h".into())); + groups.auto_collapsed.push(AutoKey::Repo(p("/w/r"))); + let text = serde_json::to_string(&groups).unwrap(); + assert!(text.contains(r#"{"repo":"/w/r"}"#), "{text}"); + let back: WorkspaceGroups = serde_json::from_str(&text).unwrap(); + assert_eq!(back, groups); + } + + #[test] + fn a_blank_name_falls_back_to_the_folder() { + let mut g = PinnedGroup::folder(Path::new("/w/tty7")); + assert_eq!(g.given_name(), None); + g.name = Some(" ".into()); + assert_eq!(g.given_name(), None); + g.name = Some(" tty ".into()); + assert_eq!(g.given_name(), Some("tty")); } } diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 201c0141..7040d3ab 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -7,6 +7,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::core::cli_agent::CLIAgent; +use crate::core::group_key::{AutoKey, GroupId, WorkspaceGroups}; use crate::core::session::WorkspaceId; use crate::daemon::protocol::{NativeSshSpec, ShellSpec}; @@ -134,6 +135,11 @@ pub struct Workspace { /// one read back at boot would name a holder that no longer exists. #[serde(default, skip_serializing_if = "Option::is_none")] pub attachment: Option, + /// The workspace's pinned sidebar groups and which groups are folded. + /// Kept here, beside the tabs that point into it, so every window onto + /// the workspace draws the same groups in the same order. + #[serde(default, skip_serializing_if = "WorkspaceGroups::is_empty")] + pub groups: WorkspaceGroups, } impl Default for Workspace { @@ -145,6 +151,7 @@ impl Default for Workspace { tabs: Vec::new(), active_tab: None, attachment: None, + groups: WorkspaceGroups::default(), } } } @@ -155,8 +162,20 @@ pub struct Tab { pub id: TabId, #[serde(default)] pub name: Option, - #[serde(default)] - pub sidebar_group: Option, + /// The pinned group this tab was put in, or `None` for one the sidebar + /// files by itself. Only pinned groups are stored: an auto group is + /// worked out from the tab's cwd every time it is drawn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, + /// The auto group this tab last resolved to — a hint, not a membership. + /// + /// Auto groups are worked out from a repo probe, and at launch no probe + /// has answered yet: without this every restored tab sat in Ungrouped + /// until its own came back, then jumped. The GUI draws the tab here until + /// the live answer lands, and the live answer always wins and rewrites + /// it. It never outranks `group`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_auto: Option, pub root: PaneNode, /// The tab was put to sleep: its panes were stopped to give their memory /// back, and it stays in the workspace to be woken later (#762). @@ -178,7 +197,8 @@ impl Tab { Tab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane }, hibernated: false, } @@ -442,7 +462,12 @@ pub enum LayoutDelta { }, TabRegrouped { tab: TabId, - group: Option, + group: Option, + #[serde(default)] + last_auto: Option, + }, + GroupsChanged { + groups: WorkspaceGroups, }, TabRestructured { tab: Tab, @@ -802,19 +827,68 @@ impl MachineStore { &self, workspace: WorkspaceId, tab: TabId, - group: Option, + group: Option, + last_auto: Option, origin: Option, ) -> io::Result<()> { self.mutate(origin, |m| { let t = find_tab(m, workspace, tab)?; - t.sidebar_group = group.clone(); + t.group = group; + t.last_auto = last_auto.clone(); Ok(( (), - vec![(workspace, LayoutDelta::TabRegrouped { tab, group })], + vec![( + workspace, + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + }, + )], )) }) } + /// Replaces the workspace's sidebar groups whole. + /// + /// Whole rather than one verb per edit: the set is small, edited by hand, + /// and every edit to it — a pin, a rename, a drag that reorders — is + /// "this is the list now". Last writer wins, which for two windows racing + /// to fold the same header is the answer either of them would have given. + /// + /// A tab still pointing at a group this drops is handed back to auto + /// grouping here, in the same mutation, so the tree never holds a tab + /// filed under a group nobody can see. Each such tab is announced like any + /// other regroup; the client that sent the new set is left out of those + /// deltas as usual and has already cleared the tabs itself. + pub fn workspace_set_groups( + &self, + workspace: WorkspaceId, + groups: WorkspaceGroups, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let mut deltas = Vec::new(); + for t in &mut ws.tabs { + if t.group.is_some_and(|g| !groups.contains(g)) { + t.group = None; + deltas.push(( + workspace, + LayoutDelta::TabRegrouped { + tab: t.id, + group: None, + last_auto: t.last_auto.clone(), + }, + )); + } + } + ws.groups = groups.clone(); + deltas.push((workspace, LayoutDelta::GroupsChanged { groups })); + Ok(((), deltas)) + }) + } + pub fn pane_split( &self, workspace: WorkspaceId, @@ -2107,17 +2181,63 @@ mod tests { store .tab_rename(ws, first.id, Some("build".into()), None) .unwrap(); + let group = GroupId::new(); store - .tab_set_group(ws, first.id, Some("/repo/tty7".into()), None) + .tab_set_group(ws, first.id, Some(group), None, None) .unwrap(); store.tab_move(ws, first.id, 1, None).unwrap(); let workspace = store.workspace(ws).unwrap(); assert_eq!(workspace.tabs[0].id, second.id); assert_eq!(workspace.tabs[1].name.as_deref(), Some("build")); + assert_eq!(workspace.tabs[1].group, Some(group)); + } + + /// Dropping a group hands its tabs back to auto grouping in the same + /// mutation, and says so — a window that only heard `GroupsChanged` would + /// otherwise keep a tab filed under a group it can no longer draw. + #[test] + fn dropping_a_group_returns_its_tabs_to_auto_grouping() { + use crate::core::group_key::PinnedGroup; + let (store, _dir, ws, first) = store_with_tab(); + let second = store + .tab_create(ws, None, seed(2, "/b"), None, None) + .unwrap(); + let (keep, drop) = (PinnedGroup::label("keep"), PinnedGroup::label("drop")); + let groups = WorkspaceGroups { + pinned: vec![keep.clone(), drop.clone()], + ..Default::default() + }; + store.workspace_set_groups(ws, groups, None).unwrap(); + store + .tab_set_group(ws, first.id, Some(drop.id), None, None) + .unwrap(); + store + .tab_set_group(ws, second.id, Some(keep.id), None, None) + .unwrap(); + + let (_sub, heard) = recorded(&store); + let kept = WorkspaceGroups { + pinned: vec![keep.clone()], + ..Default::default() + }; + store.workspace_set_groups(ws, kept.clone(), None).unwrap(); + + let workspace = store.workspace(ws).unwrap(); + assert_eq!(workspace.groups, kept); + assert_eq!(workspace.tabs[0].group, None, "back to auto grouping"); + assert_eq!(workspace.tabs[1].group, Some(keep.id), "untouched"); + let heard = heard.lock().unwrap(); assert_eq!( - workspace.tabs[1].sidebar_group.as_deref(), - Some("/repo/tty7") + heard.iter().map(|(_, d)| d.clone()).collect::>(), + vec![ + LayoutDelta::TabRegrouped { + tab: first.id, + group: None, + last_auto: None, + }, + LayoutDelta::GroupsChanged { groups: kept }, + ] ); } diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index db36df48..4eed1d90 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -50,8 +50,13 @@ pub struct SessionTab { #[serde(default)] pub name: Option, pub pane: SessionPane, + /// The pinned sidebar group the tab was in. Carried so a tab reopened + /// with ⌘⇧T goes back where it was, when that group is still there. #[serde(default, skip_serializing_if = "Option::is_none")] - pub sidebar_group: Option, + pub group: Option, + /// The auto group the tab last resolved to, drawn until a probe answers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_auto: Option, #[serde(skip)] pub tree_id: Option, /// Asleep in the tree: comes back as a placeholder holding `pane`, and diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 1543d8ca..3587f55c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -53,7 +53,12 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame}; /// half (#857) — would have stayed on this machine and never reached a remote /// one. A new filename is what gets them uploaded, at the price of one Update /// Server per host, which ends the sessions on it. -pub const CONTROL_VERSION: u32 = 10; +/// +/// v11 replaces the sidebar's hand-made groups. A tab now names a pinned group +/// by id (`TabSetGroup` carries a `GroupId`, not a string), the groups +/// themselves live on the workspace and move as `WorkspaceSetGroups` and +/// `LayoutDelta::GroupsChanged`, and a v10 peer can decode none of that. +pub const CONTROL_VERSION: u32 = 11; const DIALECT_MARKER: &str = "speaks control v"; @@ -294,7 +299,15 @@ pub enum ControlRequest { TabSetGroup { workspace: WorkspaceId, tab: TabId, - group: Option, + group: Option, + /// The auto group the tab last resolved to — see `Tab::last_auto`. + #[serde(default)] + last_auto: Option, + }, + /// The workspace's pinned sidebar groups and folds, replaced whole. + WorkspaceSetGroups { + workspace: WorkspaceId, + groups: crate::core::group_key::WorkspaceGroups, }, /// Put a tab to sleep or mark it awake. Going to sleep, the peer also /// stops every pane the tab holds, keeping each one's screen on disk for @@ -413,6 +426,7 @@ impl ControlRequest { | TabRename { .. } | TabMove { .. } | TabSetGroup { .. } + | WorkspaceSetGroups { .. } | TabSetHibernated { .. } | PaneSplit { .. } | PaneClose { .. } diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index ebcc73ab..6eb9bb9d 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -782,9 +782,15 @@ fn run_request( workspace, tab, group, + last_auto, } => { conn.machine()? - .tab_set_group(workspace, tab, group, conn.machine_origin)?; + .tab_set_group(workspace, tab, group, last_auto, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::WorkspaceSetGroups { workspace, groups } => { + conn.machine()? + .workspace_set_groups(workspace, groups, conn.machine_origin)?; (ReplyOk::Unit, Vec::new()) } ControlRequest::TabSetHibernated { diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs index 22ae262a..a6d90cfa 100644 --- a/crates/tty7-server/tests/scrollback_restore.rs +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -95,7 +95,7 @@ impl Instance { {{ "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "name": null, - "sidebar_group": null, + "group": null, "root": {{ "Leaf": {{ "pane": {pane_id} }} }} }} ], diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index ea365f54..9846cbb2 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -295,7 +295,7 @@ candidates. | Command | Effect | JSON | |---|---|---| | `ws ls` | Every workspace | `{"workspaces":[...]}` | -| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` | +| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","groups":{"pinned":[…]},"tabs":[{"id","name","group","last_auto","root",…}]}` | | `ws new [NAME]` | An empty workspace (no tab, no pane) | `{"id","name"}` | | `ws rename WORKSPACE NAME` | Name or rename | `{"id","name"}` | | `ws rm WORKSPACE` | Delete the workspace and hang up its panes | `{"removed"}` | @@ -342,9 +342,11 @@ tree drops a pane's record when the tab holding it closes, and the registry is what is left. That only matters if the shell later dies, since a restore would then have nothing to restore from. -`GROUP` is the heading the GUI's sidebar files the tab under, shown by its last -segment. Read-only from here: with the default repo grouping the GUI recomputes -it from the tab's working directory. +`GROUP` is the pinned sidebar group the tab is kept in — its name, or the last +segment of its folder — and `-` for a tab the GUI groups automatically. The JSON +carries the group's `id`, `name` and `folder` (or `null`). Auto groups are not +listed: the GUI works them out from each tab's working directory and stores +nothing about them. `label` falls back through the best evidence available — the name if someone set one, else the agent running there, else the last segment of the cwd, else the diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index eb06a642..72e196d6 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -31,8 +31,8 @@ section something is in. Keyboard shortcuts, Option as Meta, mouse behavior, selection, and clipboard. - Tab position and placement, sidebar grouping, diff previews, and what SSH - tabs are called. + Tab position and placement, sidebar auto grouping, diff previews, and what + SSH tabs are called. Hosts, connection defaults, security, authentication, proxies, and forwarding. diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index 12e49c9b..f7af4865 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -93,7 +93,7 @@ their id from the file name. [More about themes →](/customization/themes) | `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). | | `new_tab_position` | enum | `"after-current"` | Or `end`. | | `ssh_tab_title` | enum | `"dynamic"` | What an SSH tab is called: `dynamic` (the title the remote side sets), `profile-name` (the saved host's name, a `~/.ssh/config` alias, or the address typed for a quick connect), or `hostname` (the address dialled). A tab you renamed keeps its name. | -| `sidebar_grouping` | enum | `"repo"` | Or `repo-or-directory` to group non-repo tabs by their folder, or `none` for a flat list. | +| `sidebar_auto_grouping` | bool | `true` | Groups unpinned tabs by repository (SSH tabs by host). Off: a flat list below the pinned groups, which show either way. Pinned groups and folds are stored with the workspace, not here. | | `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. | | `sidebar_width` | number | `220` | Pixels (100–2000). | | `sidebar_collapsed` | bool | `false` | | diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index 1eacf956..a5c32cd5 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -1,6 +1,6 @@ --- title: "The sidebar" -description: "Tabs grouped by repository, with branch, diff counts, and agent status on every row." +description: "Tabs grouped by repository automatically, groups you pin kept above them, and branch, diff counts, and agent status on every row." --- The left sidebar is tty7's tab bar, and it is the default because a vertical row @@ -14,18 +14,54 @@ is doing. The tty7 tab sidebar -## Grouped by repository +## Groups -Rows sit under a header per git repository, with everything else collected in a -trailing **Scratch** section. The grouping follows the tab's working directory, -not its history — switching branches or `cd`-ing around inside a repository -never moves a row out from under its header. +The sidebar groups tabs by repository automatically. Pin what you want to keep. -**Settings → Window & Tabs → Sidebar grouping** switches between *By repo* (the -default), *By repo or folder*, and *Flat*. The default collects every non-repo -tab in Scratch; *By repo or folder* groups those tabs under their working -directory instead — for agents running in plain folders — leaving Scratch to -tabs whose shell has not reported a directory yet. +**Auto groups** sit below a divider. Every tab you have not pinned is filed +under the git repository its working directory is in — a linked worktree under +the repository it belongs to, a submodule under itself — and an SSH tab under +the host it is connected to, so `/home/ubuntu` on two machines is two groups. +Everything else collects in a trailing **Ungrouped** section. An auto group +follows the tab's working directory, not its history: `cd` into another +repository and the row moves; switching branches never does. When its last tab +leaves, the group is gone. + +**Pinned groups** sit above the divider, in the order you put them, and stay +until you delete them — an empty one keeps its place with a **+ New Tab** row. +A tab in a pinned group never leaves it on its own. There are two kinds: + +- A **folder group** keeps a directory. A tab whose working directory *enters* + the folder joins it — a tab opened there, a `cd` into it, or a worktree of the + repository the folder is. When folders nest, the deepest one wins, so pinning + a monorepo and one package in it files the package's tabs under the package. + A tab you drag out while it is still inside stays out until it leaves the + folder and comes back. Hover the header for the folder's path; click its pin + to unpin it, and its tabs go back to auto groups. +- A **label group** is just a name, for tabs that belong together for a reason + no directory shows. Tabs go in and out of it by hand. + +Ways to pin: + +| To get | Do this | +|---|---| +| A folder group from an auto group | Click the pin on its header, or drag the header above the divider | +| A folder group from a folder | Drop it from Finder onto the sidebar, choose **Pin as Group** on it in the Files panel, or run **Open Folder as Group…** from the command palette | +| A label group | Right-click a tab → **Move to Group → New Group…**, or run **New Group** from the command palette | + +Right-click a pinned group's header to rename it, point it at a folder (**Set +Folder…**, or **Use Current Tab's Folder**), clear its folder, open a tab in it, +or delete it. Deleting a group closes nothing: its tabs go back to auto +grouping. An auto group has no rename — pin it first. The **+** on a header +opens a tab in that group's folder (a label group's opens where ⌘ T +would), and ⌘ T itself joins the group of the tab you are in. + +Groups, their order, and which ones are folded are kept with the workspace, so +every window onto it — and the next launch — shows the same thing. + +**Settings → Window & Tabs → Auto grouping** turns auto groups off. The tabs you +have not pinned then sit in one flat list below your pinned groups, which show +either way. ## What a row tells you @@ -54,9 +90,11 @@ they simply stop opening the overlay. ## Rearranging -Drag a row to reorder it within its group, or drag a whole group header to move -the group. A row cannot be dragged into a different group: a tab's group comes -from its working directory, so `cd` is what moves it. +Drag a row to reorder it within its group, or drag a group header to move the +group — pinned groups among pinned groups, auto groups among auto groups. Drag +a row onto a pinned group to put it there, or anywhere below the divider to +hand it back to auto grouping. A row cannot be dragged into an auto group: its +membership comes from the working directory, so `cd` is what moves it. Drag a row out over the panes instead and it stops being a session of its own: it lands as a pane of the tab on screen, wherever the highlight says. Dragging a diff --git a/src/terminal/view.rs b/src/terminal/view.rs index fa3e03c7..6ccac9f9 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -269,23 +269,6 @@ fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool { } } -/// The cwd a native SSH pane's remote shell reported, for the few readers that -/// only need a name for it and not a host to act on it. -/// -/// Such a pane belongs to this machine's daemon, so [`cwd_is_on_host`] rightly -/// turns its paths away from every `Host` call — there is no host to hand them -/// to. But the shell on the far end states them itself (OSC 7), unlike a shell -/// that ssh'd onward from a local prompt, whose directory is only ever a guess. -/// Only an absolute POSIX path counts: that is what a remote sshd's shell -/// reports, and anything else is not a directory worth naming. -fn native_ssh_cwd( - remote: Option<&RemoteContext>, - cwd: Option, -) -> Option { - remote.filter(|r| r.kind == crate::daemon::protocol::RemoteKind::NativeSsh)?; - cwd.filter(|c| c.to_string_lossy().starts_with('/')) -} - /// Which path dialect a pane's output is written in. /// /// A pane running on this machine spells paths the way this OS does, and that @@ -2035,12 +2018,6 @@ impl TerminalView { self.git_status_cwd.as_deref() } - /// See [`native_ssh_cwd`]. `None` for every pane that is not a native SSH - /// one — those either have a `git_status_cwd` or have no cwd to name. - pub fn native_ssh_cwd(&self) -> Option { - native_ssh_cwd(self.remote_context().as_ref(), self.cwd()) - } - /// Plant the cwd the git-status poll would have found. For tests that /// need a pane to look like it is sitting somewhere known — a real poll /// needs a live shell reporting a directory, which a quiet test pane has @@ -10089,37 +10066,6 @@ mod tests { assert!(!cwd_is_on_host(false, false)); } - #[test] - fn only_a_native_ssh_pane_names_its_remote_cwd() { - use super::{RemoteContext, native_ssh_cwd}; - use std::path::PathBuf; - let native = RemoteContext { - kind: RemoteKind::NativeSsh, - argv: Vec::new(), - target: "ubuntu@box".into(), - }; - let home = || Some(PathBuf::from("/home/ubuntu")); - assert_eq!(native_ssh_cwd(Some(&native), home()), home()); - - // `ssh` typed at a local prompt: the directory is a guess, not a - // report from the far end. - let typed = RemoteContext { - kind: RemoteKind::Ssh, - ..native.clone() - }; - assert_eq!(native_ssh_cwd(Some(&typed), home()), None); - assert_eq!(native_ssh_cwd(Some(&wsl_context("Ubuntu")), home()), None); - // A local pane has its own path through `git_status_cwd`. - assert_eq!(native_ssh_cwd(None, home()), None); - - assert_eq!(native_ssh_cwd(Some(&native), None), None); - assert_eq!( - native_ssh_cwd(Some(&native), Some(PathBuf::from("~"))), - None, - "only an absolute path names a directory" - ); - } - /// Which machine's spelling a pane's paths are read in. Ungated on /// purpose: the bug this settles was a Windows-only one that hid behind a /// `#[cfg(unix)]` on the test that covered it. diff --git a/src/ui/app.rs b/src/ui/app.rs index b09a0f9a..5d6907a1 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -405,7 +405,19 @@ pub struct Tab { pub(crate) zoomed: Option>, pub(crate) diff_overlay: Option, pub(crate) code: Option>, - pub(crate) sidebar_group: std::cell::RefCell>, + /// The pinned sidebar group this tab was put in, by id, or `None` to + /// leave it to auto grouping. A `Cell` because the sidebar files a tab + /// that walks into a pinned folder while it is working out where to draw + /// it, which it does from `&self`. + pub(crate) group: std::cell::Cell>, + /// The auto group this tab last resolved to. Stored nowhere — an auto + /// group is derived — but remembered here so a tab whose repo probe is + /// still in flight stays where it was instead of bouncing through + /// Ungrouped for the frames the probe takes. + pub(crate) auto_group: std::cell::RefCell>, + /// Which pinned folder this tab was last seen inside, so it joins one only + /// on the way *in* — see [`crate::core::group_key::EntryWatch`]. + pub(crate) folder_watch: std::cell::Cell, pub(crate) overlay_top: OverlayTop, /// Whether this tab's document fills the workspace or docks beside the /// terminal, once the tab has been told. `None` follows `document_layout` @@ -478,7 +490,9 @@ impl Tab { code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new(None), + group: std::cell::Cell::new(None), + auto_group: std::cell::RefCell::new(None), + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::fresh()), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), @@ -496,11 +510,13 @@ impl Tab { code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new( - tree.sidebar_group - .as_deref() - .and_then(crate::core::group_key::GroupKey::decode), - ), + group: std::cell::Cell::new(tree.group), + auto_group: std::cell::RefCell::new(tree.last_auto.clone()), + // Only ever built for a tab that was just created elsewhere — by + // `tty7 tab new`, or another window — so it is as new as one + // opened here: opened inside a pinned folder, it joins it. Every + // window that hears of it reaches the same answer. + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::fresh()), tree_id: std::cell::Cell::new(tree.id), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), @@ -798,13 +814,9 @@ pub(crate) struct WorkspaceRename { } pub(crate) struct GroupRename { - /// The group being renamed, by the key it had when the box opened. - /// - /// A custom group *is* its name — there is no group record anywhere for - /// an id to point at, only the tabs that claim it. So renaming one means - /// rewriting every tab that says the old name, and this is what says - /// which those are. - pub(crate) key: crate::core::group_key::GroupKey, + /// The pinned group being renamed. By id, so a rename that lands after + /// another window reordered or renamed the groups still names this one. + pub(crate) group: crate::core::group_key::GroupId, pub(crate) input: Entity, pub(crate) _subs: Vec, } @@ -962,12 +974,21 @@ pub struct Tty7App { /// the sidebar — and takes no part in the reading. pub(crate) strip_slots: Rc>>>, pub(crate) sidebar_slots: Rc>>>, - /// Where each custom group's block was drawn last frame, so a tab held - /// over one can be told which group it is over. Only custom groups are - /// here: a repo group's membership is decided by cwd, so dropping a tab + /// Where each pinned group's block was drawn last frame, so a tab held + /// over one can be told which group it is over. Only pinned groups are + /// here: an auto group's membership is decided by cwd, so dropping a tab /// into one has no meaning to record. pub(crate) sidebar_group_slots: - Rc)>>>, + Rc)>>>, + /// Where the divider between the pinned groups and the rest was drawn + /// last frame. Everything below it is auto grouping's: a tab dropped + /// there leaves its pinned group, and an auto group's header lifted + /// above it is pinned. + pub(crate) sidebar_divider: Rc>>>, + /// This workspace's pinned groups and folds, as the machine tree holds + /// them. Adopted from every pull and every `GroupsChanged`, and pushed + /// back as one `WorkspaceSetGroups` whenever this window edits it. + pub(crate) sidebar_groups: crate::core::group_key::WorkspaceGroups, /// Where the active tab's panes were last drawn, which is the frame of /// reference a drag's landing is worked out in. pub(crate) pane_area: Rc>>>, @@ -1579,6 +1600,8 @@ impl Tty7App { strip_slots: Rc::new(RefCell::new(Vec::new())), sidebar_slots: Rc::new(RefCell::new(Vec::new())), sidebar_group_slots: Rc::new(RefCell::new(Vec::new())), + sidebar_divider: Rc::new(Cell::new(None)), + sidebar_groups: Default::default(), pane_area: Rc::new(Cell::new(None)), sidebar_search, _sidebar_search_sub: sidebar_search_sub, @@ -1938,7 +1961,11 @@ impl Tty7App { code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new(st.sidebar_group), + group: std::cell::Cell::new(st.group), + auto_group: std::cell::RefCell::new(st.last_auto), + // Reopened here, so it is this window's new tab: walking into + // a pinned folder files it there like any other. + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::fresh()), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), last_used: std::cell::Cell::new(0), focus_origin: Default::default(), @@ -3584,12 +3611,8 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.tab_bar_position = pos); } - pub(crate) fn set_sidebar_grouping( - &mut self, - grouping: crate::core::config::SidebarGrouping, - cx: &mut Context, - ) { - self.update_config(cx, |cfg| cfg.sidebar_grouping = grouping); + pub(crate) fn set_sidebar_auto_grouping(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.sidebar_auto_grouping = on); } pub(crate) fn set_sidebar_diff_preview(&mut self, on: bool, cx: &mut Context) { @@ -4077,9 +4100,7 @@ impl Tty7App { self.maximized = None; let insert_at = self.new_tab_insert_at(cx); let new_tab = Tab::new(Pane::leaf(tab.clone())); - if let Some(group) = group { - *new_tab.sidebar_group.borrow_mut() = group; - } + group.seat(&new_tab); self.tabs.insert(insert_at, new_tab); self.active = insert_at; self.focus_active(window, cx); @@ -4749,9 +4770,7 @@ impl Tty7App { .and_then(|view| view.read(cx).spawnable_cwd()); let group = self.spawn_group(cwd.as_deref(), cx); let fresh = Tab::new(crate::ui::pane::Pane::leaf(slot)); - if let Some(group) = group { - *fresh.sidebar_group.borrow_mut() = group; - } + group.seat(&fresh); let at = at.min(self.tabs.len()); self.tabs.insert(at, fresh); self.maximized = None; @@ -5415,9 +5434,7 @@ impl Tty7App { self.maximized = None; let insert_at = self.new_tab_insert_at(cx); let tab = Tab::new(Pane::leaf(new)); - if let Some(group) = group { - *tab.sidebar_group.borrow_mut() = group; - } + group.seat(&tab); self.tabs.insert(insert_at, tab); self.active = insert_at; self.focus_active(window, cx); @@ -5765,6 +5782,32 @@ impl Tty7App { // saved host has nothing to save, and a pane that is not an SSH one has // no connection at all — either would be a row that quietly did nothing // (#549). + // Groups are something the sidebar draws, so they are offered only + // while the tabs are in it. Opening a folder as a group asks the + // system picker, which browses this computer: a path picked there + // names nothing on a remote workspace's machine, where "Pin as + // Group" in the file tree is the way in instead. + if cx.global::().tab_bar_position == TabBarPosition::Left { + commands.push( + Command::localized(L10nKey::CmdNewGroup, CommandKind::NewGroup) + .with_subtitle(t(L10nKey::CmdNewGroupSubtitle)) + .in_group(CommandGroup::TabsPanes), + ); + if !WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()) + { + commands.push( + Command::localized( + L10nKey::CmdOpenFolderAsGroup, + CommandKind::OpenFolderAsGroup, + ) + .with_subtitle(t(L10nKey::CmdOpenFolderAsGroupSubtitle)) + .in_group(CommandGroup::TabsPanes), + ); + } + } + if self.unsaved_ssh_session(window, cx).is_some() { commands.push( Command::localized( @@ -5984,6 +6027,8 @@ impl Tty7App { ReopenClosedTab => self.reopen_closed_tab(window, cx), RenameTab => self.start_rename(self.active, window, cx), NewWorktreeTab => self.new_worktree_tab(self.active, window, cx), + NewGroup => self.new_empty_group(window, cx), + OpenFolderAsGroup => self.open_folder_as_group(cx), CloseOtherTabs => self.close_other_tabs(self.active, window, cx), CloseTabsToTheRight => self.close_tabs_right_of(self.active, window, cx), CopyWorkingDirectory => self.copy_active_cwd(window, cx), @@ -6182,7 +6227,7 @@ impl Tty7App { self.set_tab_bar_position(defaults.tab_bar_position, cx) } L10nKey::SettingsSidebarGrouping => { - self.set_sidebar_grouping(defaults.sidebar_grouping, cx) + self.set_sidebar_auto_grouping(defaults.sidebar_auto_grouping, cx) } L10nKey::SettingsDiffPreviewFromCounts => { self.set_sidebar_diff_preview(defaults.sidebar_diff_preview, cx) @@ -8639,6 +8684,9 @@ impl Render for Tty7App { // terminal grid, sized in absolute px from `font_size`, does not move. window.set_rem_size(px(cx.global::().ui_font_size)); self.claim_pending_tab(window, cx); + // Before anything asks where a tab is drawn: a tab that walked into a + // pinned folder since the last frame is filed there on this one. + self.settle_sidebar_groups(cx); self.touch_active_tab(); self.declare_displayed_panes(cx); self.scm_sync_watchers(window, cx); @@ -8655,14 +8703,23 @@ impl Render for Tty7App { let landed = crate::ui::reorder::take_landed(&self.reorder); if let Some((tab, zone)) = self.tab_merge.take() { self.merge_tab(tab, zone, window, cx); - } else if let Some((tab, key)) = landed.regroup { + } else if let Some((tab, target)) = landed.regroup { // A drop into another group outranks the reordering the drag // did on its way out of the one it came from. The pointer // left that group; the shuffle it caused before leaving is // not what was being asked for. - self.regroup_tab(tab, key, cx); + self.regroup_tab(tab, target, cx); + } else if let Some(key) = landed.pin { + // The same for a header let go above the divider: it was + // carried there to be pinned, not to be reordered on the way. + self.pin_auto_group(key, cx); } else if let Some(order) = landed.order { - self.apply_tab_order(&order, cx); + match landed.surface { + Some(crate::ui::reorder::Surface::PinnedGroups) => { + self.apply_pinned_order(&order, cx) + } + _ => self.apply_tab_order(&order, cx), + } } // Also what ends the pane drag, so it is taken whichever of the two // readings the last frame left behind. @@ -9437,7 +9494,8 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { Some(layout) => layout.clone(), None => pane_to_session(&tab.pane, cx), }, - sidebar_group: tab.sidebar_group.borrow().clone(), + group: tab.group.get(), + last_auto: tab.auto_group.borrow().clone(), tree_id: None, hibernated: false, asleep_view: None, @@ -9652,7 +9710,11 @@ fn tabs_from_session( code: None, overlay_top: OverlayTop::default(), document_layout: None, - sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), + group: std::cell::Cell::new(st.group), + // The hint the tree kept: the tab is drawn in its auto group from + // the first frame, not parked in Ungrouped until its probe lands. + auto_group: std::cell::RefCell::new(st.last_auto.clone()), + folder_watch: std::cell::Cell::new(crate::core::group_key::EntryWatch::baseline()), tree_id: std::cell::Cell::new( st.tree_id .unwrap_or_else(tty7_core::core::machine::TabId::new), @@ -9671,7 +9733,8 @@ fn tabs_from_session( fn asleep_tab(st: &SessionTab, home: Option) -> Tab { let mut tab = Tab::new(Pane::Empty); tab.name = st.name.clone(); - *tab.sidebar_group.borrow_mut() = st.sidebar_group.clone(); + tab.group.set(st.group); + *tab.auto_group.borrow_mut() = st.last_auto.clone(); if let Some(id) = st.tree_id { tab.tree_id.set(id); } @@ -11548,7 +11611,8 @@ mod ssh_rebuild_gpui_tests { let tab = TreeTab { id: app.tabs[0].tree_id.get(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }; diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 1731232b..73ffca40 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -34,6 +34,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), + "icons/pin.svg" => include_bytes!("../../assets/icons/pin.svg"), "icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"), "icons/folder-closed.svg" => include_bytes!("../../assets/icons/folder-closed.svg"), "icons/folder-open.svg" => include_bytes!("../../assets/icons/folder-open.svg"), diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index e290a5ff..dfaf5553 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1962,7 +1962,6 @@ impl Tty7App { let show_hidden = self.file_tree.show_hidden; let paths_are_local = self.spawn_host(cx).is_local(); move |menu, _window, cx| { - let danger = cx.theme().danger; Self::tree_row_context_menu( menu, &path, @@ -1970,8 +1969,8 @@ impl Tty7App { is_root, show_hidden, paths_are_local, - danger, &app, + cx, ) } }); @@ -2012,9 +2011,16 @@ impl Tty7App { // here — silently opening nothing, or the wrong thing if a local path // happens to collide. paths_are_local: bool, - danger: gpui::Hsla, app: &gpui::WeakEntity, + cx: &App, ) -> PopupMenu { + let danger = cx.theme().danger; + // Whether "Pin as Group" has a sidebar to put the group in: with the + // tabs along the top there is nowhere to show one. Offered on a remote + // workspace too, unlike the file manager above — the tree and a pinned + // folder are both on the workspace's own host. + let groups_shown = cx.global::().tab_bar_position + == crate::core::config::TabBarPosition::Left; let mut menu = menu.min_w(px(200.)); let p = path.to_path_buf(); @@ -2039,6 +2045,19 @@ impl Tty7App { } }), ); + if groups_shown { + menu = menu.item( + PopupMenuItem::new(t(L10nKey::FileTreeContextPinAsGroup)).on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| { + this.pin_folder(p.clone(), cx); + }); + } + }), + ); + } } menu = menu .item( diff --git a/src/ui/home.rs b/src/ui/home.rs index 6f2903de..e3fcac18 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -494,7 +494,8 @@ mod tests { let tab = SessionTab { name: Some("build".into()), tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/work/getty")), @@ -507,7 +508,8 @@ mod tests { let tab = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/work/getty")), @@ -517,7 +519,8 @@ mod tests { let tab = SessionTab { name: Some(" ".into()), tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/work/getty")), @@ -530,7 +533,8 @@ mod tests { let tab = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: SessionPane::Split { @@ -548,7 +552,8 @@ mod tests { let unnamed = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(None), @@ -557,7 +562,8 @@ mod tests { let root = SessionTab { name: None, tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(Some("/")), @@ -570,7 +576,8 @@ mod tests { let tab = SessionTab { name: Some("a".repeat(40)), tree_id: None, - sidebar_group: None, + group: None, + last_auto: None, hibernated: false, asleep_view: None, pane: leaf(None), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index e5efd553..cd1991b9 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -612,9 +612,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsTabBarPositionDesc => { "Show tabs as a horizontal strip on top or a vertical sidebar on the left." } - L10nKey::SettingsSidebarGrouping => "Sidebar grouping", + L10nKey::SettingsSidebarGrouping => "Auto grouping", L10nKey::SettingsSidebarGroupingDesc => { - "Group sidebar tabs by git repository. Tabs outside a repo collect under Scratch, or under their working directory with \"By repo or folder\". Left sidebar only." + "Group unpinned sidebar tabs by git repository, and SSH tabs by host. Pinned groups always show. Left sidebar only." } L10nKey::SettingsDiffPreviewFromCounts => "Open diff preview from sidebar counts", L10nKey::SettingsDiffPreviewFromCountsDesc => { @@ -649,9 +649,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAtEnd => "At end", L10nKey::SettingsTop => "Top", L10nKey::SettingsLeft => "Left", - L10nKey::SettingsByRepo => "By repo", - L10nKey::SettingsByRepoOrFolder => "By repo or folder", - L10nKey::SettingsFlat => "Flat", L10nKey::SettingsPreset => "Preset", L10nKey::SettingsPresetDesc => { "tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C)." @@ -948,7 +945,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { "tray menu bar status item agent attention system icon" } L10nKey::SettingsSearchSidebarGroupingKeywords => { - "tabs group repo repository git scratch header sidebar flat folder directory cwd" + "tabs group grouping auto repo repository git ssh host pinned pin ungrouped header sidebar flat folder" } L10nKey::SettingsSearchSshTabTitleKeywords => { "ssh tab title name host hostname profile alias pin fixed osc remote" @@ -1071,6 +1068,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::FileTreeContextOpen => "Open", L10nKey::FileTreeContextCdHere => "cd Here", + L10nKey::FileTreeContextPinAsGroup => "Pin as Group", L10nKey::FileTreeContextInsertPath => "Insert Path in Terminal", L10nKey::FileTreeContextAttachAgent => "Attach to Agent", L10nKey::FileTreeContextNewFile => "New File", @@ -1502,6 +1500,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdNewWindow => "New Window", L10nKey::CmdNewWorktreeTab => "New Worktree Tab…", L10nKey::CmdNewWorktreeTabSubtitle => "isolated checkout on a fresh branch", + L10nKey::CmdNewGroup => "New Group", + L10nKey::CmdNewGroupSubtitle => "an empty pinned group in the sidebar", + L10nKey::CmdOpenFolderAsGroup => "Open Folder as Group…", + L10nKey::CmdOpenFolderAsGroupSubtitle => "pin a folder; tabs opened in it join", L10nKey::CmdRenameTab => "Rename Tab…", L10nKey::CmdSplitRight => "Split Right", L10nKey::CmdSplitDown => "Split Down", @@ -1924,12 +1926,19 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::TabMenuSplitHint => "Hold {key} to split", L10nKey::TabUnnamedShell => "Shell {n}", L10nKey::ShellDefault => "default", - L10nKey::SidebarScratchGroup => "Scratch", + L10nKey::SidebarUngroupedGroup => "Ungrouped", L10nKey::SidebarMoveToGroup => "Move to Group", L10nKey::SidebarNewGroup => "New Group…", - L10nKey::SidebarAutoGroup => "Group Automatically", L10nKey::SidebarNewGroupName => "New Group", L10nKey::SidebarRenameGroup => "Rename Group", + L10nKey::SidebarPinGroup => "Pin Group", + L10nKey::SidebarUnpinGroup => "Unpin", + L10nKey::SidebarGroupNewTab => "New Tab", + L10nKey::SidebarSetGroupFolder => "Set Folder…", + L10nKey::SidebarUseCurrentTabFolder => "Use Current Tab's Folder", + L10nKey::SidebarClearGroupFolder => "Clear Folder", + L10nKey::SidebarDeleteGroup => "Delete Group", + L10nKey::SidebarDropToPin => "Drop here to pin", L10nKey::TabContextCloseTab => "Close Tab", L10nKey::TabContextCloseTabsBelow => "Close Tabs Below", L10nKey::AppAgentHooksOpFailed => "Failed: {error}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 74e7a74a..a2502336 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -620,9 +620,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsTabBarPositionDesc => { "タブを上部の横一列または左側の縦サイドバーとして表示" } - L10nKey::SettingsSidebarGrouping => "サイドバーのグループ化", + L10nKey::SettingsSidebarGrouping => "自動グループ化", L10nKey::SettingsSidebarGroupingDesc => { - "サイドバータブを git リポジトリごとにまとめます。リポジトリ外のタブはスクラッチに、「リポジトリ/フォルダ別」なら作業ディレクトリごとに。左サイドバーのみ" + "固定していないサイドバータブを git リポジトリごとに、SSH タブをホストごとにまとめます。固定したグループは常に表示されます。左サイドバーのみ" } L10nKey::SettingsDiffPreviewFromCounts => "サイドバーのカウントから Diff プレビューを開く", L10nKey::SettingsDiffPreviewFromCountsDesc => { @@ -657,9 +657,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAtEnd => "末尾", L10nKey::SettingsTop => "上部", L10nKey::SettingsLeft => "左側", - L10nKey::SettingsByRepo => "リポジトリ別", - L10nKey::SettingsByRepoOrFolder => "リポジトリ/フォルダ別", - L10nKey::SettingsFlat => "フラット表示", L10nKey::SettingsPreset => "プリセット", L10nKey::SettingsPresetDesc => { "tmux では、ペイン/タブの操作をプレフィックスキーの後に行います(例: Ctrl-B の後に C)" @@ -1010,7 +1007,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "トレイ メニューバー ステータス アイコン エージェント 通知 システム tray icon menu bar status system attention" } L10nKey::SettingsSearchSidebarGroupingKeywords => { - "タブ グループ リポジトリ git スクラッチ ヘッダー サイドバー フラット フォルダ ディレクトリ sidebar grouping tabs repo repository git scratch header flat folder directory" + "タブ グループ 自動 リポジトリ git ssh ホスト 固定 未分類 サイドバー sidebar auto grouping tabs repo repository pinned pin host ungrouped" } L10nKey::SettingsSearchSshTabTitleKeywords => { "SSH タブ タイトル 名前 ホスト ホスト名 プロファイル エイリアス 固定 ssh tab title name host hostname profile alias pin" @@ -1137,6 +1134,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::FileTreeContextOpen => "開く", L10nKey::FileTreeContextCdHere => "ここで cd", + L10nKey::FileTreeContextPinAsGroup => "グループとして固定", L10nKey::FileTreeContextInsertPath => "ターミナルにパスを挿入", L10nKey::FileTreeContextAttachAgent => "エージェントをアタッチ", L10nKey::FileTreeContextNewFile => "新しいファイル", @@ -1566,6 +1564,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdNewWindow => "新しいウィンドウ", L10nKey::CmdNewWorktreeTab => "新しいワークツリータブ…", L10nKey::CmdNewWorktreeTabSubtitle => "新しいブランチでの独立したチェックアウト", + L10nKey::CmdNewGroup => "新規グループ", + L10nKey::CmdNewGroupSubtitle => "サイドバーに空の固定グループを作成", + L10nKey::CmdOpenFolderAsGroup => "フォルダをグループとして開く…", + L10nKey::CmdOpenFolderAsGroupSubtitle => "フォルダを固定し、そこで開いたタブをまとめる", L10nKey::CmdRenameTab => "タブの名前を変更…", L10nKey::CmdSplitRight => "右に分割", L10nKey::CmdSplitDown => "下に分割", @@ -1996,12 +1998,19 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuSplitHint => "{key} を押しながら選ぶと分割", L10nKey::TabUnnamedShell => "シェル {n}", L10nKey::ShellDefault => "デフォルト", - L10nKey::SidebarScratchGroup => "スクラッチ", + L10nKey::SidebarUngroupedGroup => "未分類", L10nKey::SidebarMoveToGroup => "グループへ移動", L10nKey::SidebarNewGroup => "新規グループ…", - L10nKey::SidebarAutoGroup => "自動グループ化に戻す", L10nKey::SidebarNewGroupName => "新規グループ", L10nKey::SidebarRenameGroup => "グループ名を変更", + L10nKey::SidebarPinGroup => "グループを固定", + L10nKey::SidebarUnpinGroup => "固定を解除", + L10nKey::SidebarGroupNewTab => "新規タブ", + L10nKey::SidebarSetGroupFolder => "フォルダを設定…", + L10nKey::SidebarUseCurrentTabFolder => "現在のタブのフォルダを使用", + L10nKey::SidebarClearGroupFolder => "フォルダを解除", + L10nKey::SidebarDeleteGroup => "グループを削除", + L10nKey::SidebarDropToPin => "ここにドロップして固定", L10nKey::TabContextCloseTab => "タブを閉じる", L10nKey::TabContextCloseTabsBelow => "下のタブを閉じる", L10nKey::AppAgentHooksOpFailed => "失敗: {error}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 43f94f69..4e8eaf92 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -521,9 +521,6 @@ l10n_keys! { SettingsAtEnd, SettingsTop, SettingsLeft, - SettingsByRepo, - SettingsByRepoOrFolder, - SettingsFlat, SettingsPreset, SettingsPresetDesc, SettingsPrefix, @@ -778,6 +775,7 @@ l10n_keys! { FileTreeDownloadTooLarge, FileTreeContextOpen, FileTreeContextCdHere, + FileTreeContextPinAsGroup, FileTreeContextInsertPath, FileTreeContextAttachAgent, FileTreeContextNewFile, @@ -1079,12 +1077,19 @@ l10n_keys! { TabMenuSplitHint, TabUnnamedShell, ShellDefault, - SidebarScratchGroup, + SidebarUngroupedGroup, SidebarMoveToGroup, SidebarNewGroup, - SidebarAutoGroup, SidebarNewGroupName, SidebarRenameGroup, + SidebarPinGroup, + SidebarUnpinGroup, + SidebarGroupNewTab, + SidebarSetGroupFolder, + SidebarUseCurrentTabFolder, + SidebarClearGroupFolder, + SidebarDeleteGroup, + SidebarDropToPin, TabContextCloseTab, TabContextCloseTabsBelow, TabContextMarkUnread, @@ -1211,6 +1216,10 @@ l10n_keys! { CmdNewWindow, CmdNewWorktreeTab, CmdNewWorktreeTabSubtitle, + CmdNewGroup, + CmdNewGroupSubtitle, + CmdOpenFolderAsGroup, + CmdOpenFolderAsGroupSubtitle, CmdRenameTab, CmdSplitRight, CmdSplitDown, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 7e5dc6c3..092a77dd 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -541,9 +541,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsNewTabPositionDesc => "新打开的标签页插入的位置。", L10nKey::SettingsTabBarPosition => "标签栏位置", L10nKey::SettingsTabBarPositionDesc => "将标签页显示为顶部横向条或左侧垂直侧栏。", - L10nKey::SettingsSidebarGrouping => "侧栏分组", + L10nKey::SettingsSidebarGrouping => "自动分组", L10nKey::SettingsSidebarGroupingDesc => { - "按 git 仓库给侧栏标签页分组。仓库外的标签页归到“草稿”,选“按仓库或文件夹”时则按工作目录分。仅左侧栏。" + "把未固定的侧栏标签页按 git 仓库分组,SSH 标签页按主机分组。固定的分组始终显示。仅左侧栏。" } L10nKey::SettingsDiffPreviewFromCounts => "从侧栏计数打开 diff 预览", L10nKey::SettingsDiffPreviewFromCountsDesc => { @@ -574,9 +574,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAtEnd => "末尾", L10nKey::SettingsTop => "顶部", L10nKey::SettingsLeft => "左侧", - L10nKey::SettingsByRepo => "按仓库", - L10nKey::SettingsByRepoOrFolder => "按仓库或文件夹", - L10nKey::SettingsFlat => "平铺", L10nKey::SettingsPreset => "预设", L10nKey::SettingsPresetDesc => { "tmux 预设把窗格/标签页操作映射为前缀序列(例如 Ctrl-B 后按 C)。" @@ -901,7 +898,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "显示托盘图标 托盘 菜单栏 状态 图标 show tray icon menu bar status" } L10nKey::SettingsSearchSidebarGroupingKeywords => { - "侧栏分组 标签页 分组 仓库 git 侧栏 文件夹 目录 sidebar grouping tabs repo repository folder directory" + "自动分组 标签页 分组 仓库 git ssh 主机 固定 未分组 侧栏 sidebar auto grouping tabs repo repository pinned pin host ungrouped" } L10nKey::SettingsSearchSshTabTitleKeywords => { "SSH 标签页 标题 名称 主机 主机名 配置 别名 固定 ssh tab title name host hostname profile alias pin" @@ -1022,6 +1019,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::FileTreeDownloadTooLarge => "超过 {limit} MB,请改用 scp 或 rsync 下载。", L10nKey::FileTreeContextOpen => "打开", L10nKey::FileTreeContextCdHere => "cd 到此处", + L10nKey::FileTreeContextPinAsGroup => "固定为分组", L10nKey::FileTreeContextInsertPath => "在终端中插入路径", L10nKey::FileTreeContextAttachAgent => "附加到 agent", L10nKey::FileTreeContextNewFile => "新建文件", @@ -1409,6 +1407,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdNewWindow => "新建窗口", L10nKey::CmdNewWorktreeTab => "新建 worktree 标签页…", L10nKey::CmdNewWorktreeTabSubtitle => "在全新分支上独立检出", + L10nKey::CmdNewGroup => "新建分组", + L10nKey::CmdNewGroupSubtitle => "在侧栏新建一个空的固定分组", + L10nKey::CmdOpenFolderAsGroup => "将文件夹作为分组打开…", + L10nKey::CmdOpenFolderAsGroupSubtitle => "固定一个文件夹;在其中打开的标签页会加入", L10nKey::CmdRenameTab => "重命名标签页…", L10nKey::CmdSplitRight => "向右分屏", L10nKey::CmdSplitDown => "向下分屏", @@ -1815,12 +1817,19 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::TabMenuSplitHint => "按住 {key} 可分屏打开", L10nKey::TabUnnamedShell => "终端 {n}", L10nKey::ShellDefault => "默认", - L10nKey::SidebarScratchGroup => "草稿", + L10nKey::SidebarUngroupedGroup => "未分组", L10nKey::SidebarMoveToGroup => "移到分组", L10nKey::SidebarNewGroup => "新建分组…", - L10nKey::SidebarAutoGroup => "恢复自动分组", L10nKey::SidebarNewGroupName => "新建分组", L10nKey::SidebarRenameGroup => "重命名分组", + L10nKey::SidebarPinGroup => "固定分组", + L10nKey::SidebarUnpinGroup => "取消固定", + L10nKey::SidebarGroupNewTab => "新建标签页", + L10nKey::SidebarSetGroupFolder => "设置文件夹…", + L10nKey::SidebarUseCurrentTabFolder => "使用当前标签页的文件夹", + L10nKey::SidebarClearGroupFolder => "清除文件夹", + L10nKey::SidebarDeleteGroup => "删除分组", + L10nKey::SidebarDropToPin => "拖到此处固定", L10nKey::TabContextCloseTab => "关闭标签页", L10nKey::TabContextCloseTabsBelow => "关闭下方标签页", L10nKey::AppAgentHooksOpFailed => "失败:{error}", diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 90245d01..405a427d 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; use gpui::{App, Global}; +use tty7_core::core::group_key::AutoKey; use tty7_core::core::machine::{LayoutDelta, Machine, PaneRecord, Tab, TabId, Workspace}; use tty7_core::daemon::control::{ControlRequest, ReplyOk}; use tty7_core::host::HostId; -use crate::core::group_key::GroupKey; use crate::core::session::WorkspaceId; use crate::ui::i18n::{L10nKey, t}; @@ -300,11 +300,20 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> t.name = name.clone(); true } - LayoutDelta::TabRegrouped { tab, group } => { + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + } => { let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else { return false; }; - t.sidebar_group = group.clone(); + t.group = *group; + t.last_auto = last_auto.clone(); + true + } + LayoutDelta::GroupsChanged { groups } => { + ws.groups = groups.clone(); true } LayoutDelta::TabMoved { tab, to } => { @@ -375,23 +384,31 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String { } pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option { - let mut counts: Vec<(String, usize)> = Vec::new(); - // Repo groups only. This answers with a *path*, and its callers treat it - // as one — `display_name_of` names the window after its last component. - // A custom group is a name the user typed, so putting one here would - // title a window `custom:work`, or chop `work/urgent` down to `urgent`. - // A workspace grouped entirely by hand falls through to a pane's cwd, - // which is a real path and is what the window showed before any of this. - for group in ws.tabs.iter().filter_map(|t| { - let key = GroupKey::decode(t.sidebar_group.as_deref()?)?; - Some(key.repo_root()?.to_string_lossy().into_owned()) - }) { - match counts.iter_mut().find(|(g, _)| *g == group) { - Some((_, n)) => *n += 1, - None => counts.push((group, 1)), + // Paths only. This answers with a *path*, and its callers treat it as one + // — `display_name_of` names the window after its last component. A label + // group is a name the user typed, so putting one here would chop + // `work/urgent` down to `urgent`. + // + // A pinned folder is the strongest statement of what a workspace is + // about, so the most common one wins. Failing that, the repo most of the + // auto-grouped tabs were last filed under — a worktree counts toward the + // repo it belongs to, since the hint is the repo home — which is what the + // title read before groups could be pinned. A tab kept in a pinned group + // casts no repo vote: it was put there by hand, as a hand-made group's tab + // was before. Failing both, a pane's cwd. + let folders = ws.tabs.iter().filter_map(|t| { + let group = ws.groups.get(t.group?)?; + group.folder.clone() + }); + let repos = ws.tabs.iter().filter_map(|t| { + if t.group.is_some_and(|g| ws.groups.contains(g)) { + return None; } - } - let dominant = counts.into_iter().max_by_key(|(_, n)| *n).map(|(g, _)| g); + match t.last_auto.as_ref()? { + AutoKey::Repo(home) => Some(home.to_string_lossy().into_owned()), + AutoKey::SshHost(_) => None, + } + }); let first_cwd = ws .tabs .iter() @@ -402,7 +419,22 @@ pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option { .find(|p| p.id == id) .and_then(|p| p.cwd.as_deref()) }); - dominant.or_else(|| first_cwd.map(str::to_string)) + most_common(folders) + .or_else(|| most_common(repos)) + .or_else(|| first_cwd.map(str::to_string)) +} + +/// The value seen most often, ties going to the one that reached the count +/// last — the rule `max_by_key` has always settled titles by here. +fn most_common(values: impl Iterator) -> Option { + let mut counts: Vec<(String, usize)> = Vec::new(); + for value in values { + match counts.iter_mut().find(|(v, _)| *v == value) { + Some((_, n)) => *n += 1, + None => counts.push((value, 1)), + } + } + counts.into_iter().max_by_key(|(_, n)| *n).map(|(v, _)| v) } pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option { @@ -506,6 +538,7 @@ pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option< #[cfg(test)] mod tests { + use tty7_core::core::group_key::PinnedGroup; use tty7_core::core::machine::{Axis, PaneNode, PaneSeed, Tab, TabId}; use super::*; @@ -928,7 +961,8 @@ mod tests { let restructured = Tab { id: tab_id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Split { axis: Axis::Vertical, ratio: 0.5, @@ -1034,11 +1068,13 @@ mod tests { "a pane's process title must not rename its workspace" ); - ws.tabs[0].sidebar_group = Some("/repo/tty7".into()); + let tty7 = PinnedGroup::folder(std::path::Path::new("/repo/tty7")); + ws.tabs[0].group = Some(tty7.id); + ws.groups.pinned.push(tty7); assert_eq!( display_name_of(&ws, &panes), "tty7", - "the repo group wins over the raw cwd" + "a pinned folder group wins over the raw cwd" ); ws.name = Some(" Release prep ".into()); @@ -1047,12 +1083,11 @@ mod tests { assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled"); } - /// A custom group is a name, not a path, and this answers with a path — + /// A label group is a name, not a path, and this answers with a path — /// its caller names the window after the last component. Left in, a - /// workspace grouped by hand would be titled `custom:work`, and one - /// grouped as `work/urgent` would be titled `urgent`. + /// workspace grouped as `work/urgent` would be titled `urgent`. #[test] - fn a_custom_group_is_not_a_subject_path() { + fn a_label_group_is_not_a_subject_path() { let mut ws = Workspace::default(); let panes = vec![PaneRecord { cwd: Some("/home/me/scratch".into()), @@ -1060,18 +1095,69 @@ mod tests { }]; ws.tabs = vec![leaf_tab(1)]; - ws.tabs[0].sidebar_group = Some("custom:work/urgent".into()); + let work = PinnedGroup::label("work/urgent"); + ws.tabs[0].group = Some(work.id); + ws.groups.pinned.push(work); assert_eq!( display_name_of(&ws, &panes), "scratch", "the cwd answers instead, the way it did before groups existed" ); - ws.tabs[0].sidebar_group = Some("/repo/tty7".into()); + let tty7 = PinnedGroup::folder(std::path::Path::new("/repo/tty7")); + ws.tabs[0].group = Some(tty7.id); + ws.groups.pinned.push(tty7); assert_eq!( display_name_of(&ws, &panes), "tty7", - "and a repo group still outranks the cwd" + "and a folder group still outranks the cwd" ); } + + /// With nothing pinned the title falls back the way it did before groups + /// could be pinned: to the repo most tabs are in — a worktree counting + /// toward its repo home, which is what the hint holds — and only then to + /// a pane's cwd. + #[test] + fn with_nothing_pinned_the_most_common_repo_names_the_workspace() { + let mut ws = Workspace::default(); + let panes = vec![PaneRecord { + cwd: Some("/home/me/scratch".into()), + ..PaneRecord::new(1) + }]; + ws.tabs = vec![leaf_tab(1), leaf_tab(2), leaf_tab(3)]; + assert_eq!(display_name_of(&ws, &panes), "scratch", "no hints: the cwd"); + + // Two tabs in tty7 (one of them a worktree, filed under its home) and + // one in api: tty7 wins. + ws.tabs[0].last_auto = Some(AutoKey::Repo("/repo/api".into())); + ws.tabs[1].last_auto = Some(AutoKey::Repo("/repo/tty7".into())); + ws.tabs[2].last_auto = Some(AutoKey::Repo("/repo/tty7".into())); + assert_eq!(display_name_of(&ws, &panes), "tty7"); + + // A tab kept in a label group casts no repo vote. + let work = PinnedGroup::label("work"); + ws.tabs[2].group = Some(work.id); + ws.groups.pinned.push(work); + ws.tabs[0].last_auto = Some(AutoKey::Repo("/repo/api".into())); + ws.tabs.push(leaf_tab(4)); + ws.tabs[3].last_auto = Some(AutoKey::Repo("/repo/api".into())); + assert_eq!(display_name_of(&ws, &panes), "api"); + } + + /// A pinned folder outranks any number of repo hints. + #[test] + fn a_pinned_folder_outranks_the_repo_majority() { + let mut ws = Workspace { + tabs: vec![leaf_tab(1), leaf_tab(2), leaf_tab(3)], + ..Default::default() + }; + for t in &mut ws.tabs { + t.last_auto = Some(AutoKey::Repo("/repo/tty7".into())); + } + let site = PinnedGroup::folder(std::path::Path::new("/w/site")); + ws.tabs[0].group = Some(site.id); + ws.groups.pinned.push(site); + assert_eq!(display_name_of(&ws, &[]), "site"); + } } diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 50bde72e..bc7d16ff 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -29,6 +29,8 @@ pub enum CommandKind { ClosePane, RenameTab, NewWorktreeTab, + NewGroup, + OpenFolderAsGroup, CloseOtherTabs, CloseTabsToTheRight, CopyWorkingDirectory, @@ -143,6 +145,8 @@ impl CommandKind { ClosePane => "close-pane", RenameTab => "rename-tab", NewWorktreeTab => "new-worktree-tab", + NewGroup => "new-sidebar-group", + OpenFolderAsGroup => "open-folder-as-group", CloseOtherTabs => "close-other-tabs", CloseTabsToTheRight => "close-tabs-right", CopyWorkingDirectory => "copy-cwd", @@ -342,6 +346,8 @@ impl CommandKind { | SelectAllText | SendSelectionToAgent | SendGitDiffToAgent + | NewGroup + | OpenFolderAsGroup | UpdateLocalServer | UpdateRemoteServer | OpenWorkspacePicker diff --git a/src/ui/reorder.rs b/src/ui/reorder.rs index 01ff29df..c5f8f940 100644 --- a/src/ui/reorder.rs +++ b/src/ui/reorder.rs @@ -1,7 +1,7 @@ use gpui::{Axis, Bounds, Pixels, Point, Styled, px}; use std::cell::{Cell, RefCell}; use std::rc::Rc; -use tty7_core::core::group_key::GroupKey; +use tty7_core::core::group_key::{AutoKey, GroupId, GroupKey}; use tty7_core::core::machine::TabId; pub(crate) type ReorderState = Rc>>; @@ -63,28 +63,53 @@ pub(crate) fn clear_pending(state: &ReorderState) { if let Some(r) = state.borrow().as_ref() { r.pending.borrow_mut().take(); r.regroup.borrow_mut().take(); + r.pin.set(false); } } -/// Offer the custom group the pointer is currently over. +/// Where a tab being dragged in the sidebar would go if it were let go now, +/// when that is somewhere other than a new place in its own group. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Regroup { + /// Into this pinned group. + Into(GroupId), + /// Below the divider: out of its pinned group, back to auto grouping. + ToAuto, +} + +/// Offer the group the pointer is currently over. /// /// Reordering answers "where in this group", and this answers "which group" — /// two questions one drag can ask, so they are recorded side by side and /// resolved together when it lands. A drag held over a group it did not come /// from stops reordering (the surface it belongs to no longer sees the /// pointer) and starts offering this instead. -pub(crate) fn set_regroup(state: &ReorderState, key: GroupKey) { +pub(crate) fn set_regroup(state: &ReorderState, target: Regroup) { if let Some(r) = state.borrow().as_ref().filter(|r| !r.suspended.get()) { - *r.regroup.borrow_mut() = Some(key); + *r.regroup.borrow_mut() = Some(target); + } +} + +/// Offer to pin the auto group being dragged — its header is above the +/// divider. Cleared with everything else each frame, so a header carried back +/// down before it is let go reorders instead. +pub(crate) fn set_pin(state: &ReorderState) { + if let Some(r) = state.borrow().as_ref().filter(|r| !r.suspended.get()) { + r.pin.set(r.auto.is_some()); } } /// What a finished drag asks for. pub(crate) struct Landed { - /// A new order for the tabs, from the surface the drag ran over. + /// A new order, from the surface the drag ran over — of the tabs, or for + /// [`Surface::PinnedGroups`] of the pinned groups. pub(crate) order: Option>, + /// The surface `order` is an order of. + pub(crate) surface: Option, /// A tab to put in another group, from the group it was held over. - pub(crate) regroup: Option<(TabId, GroupKey)>, + pub(crate) regroup: Option<(TabId, Regroup)>, + /// An auto group to pin, from its header being let go above the divider. + pub(crate) pin: Option, } /// Ends the drag and answers what it was asking for when it ended. @@ -92,13 +117,18 @@ pub(crate) fn take_landed(state: &ReorderState) -> Landed { let Some(r) = state.borrow_mut().take() else { return Landed { order: None, + surface: None, regroup: None, + pin: None, }; }; let regroup = r.regroup.into_inner(); + let pin = r.pin.get().then_some(r.auto).flatten(); Landed { order: r.pending.into_inner(), + surface: Some(r.surface), regroup: r.tab.zip(regroup), + pin, } } @@ -111,6 +141,12 @@ pub(crate) fn dragged_tab(state: &ReorderState) -> Option { state.borrow().as_ref()?.tab } +/// The auto group a *sidebar header* drag picked up, if it is one. +pub(crate) fn dragged_auto_group(state: &ReorderState) -> Option { + let state = state.borrow(); + state.as_ref().filter(|r| !r.suspended.get())?.auto.clone() +} + /// The tab a *sidebar row* drag picked up. /// /// `None` for a drag that started anywhere else. The strip drags tabs too, @@ -142,7 +178,13 @@ pub(crate) fn suspend(state: &ReorderState, yes: bool) { pub(crate) enum Surface { Strip, SidebarRows(Option), + /// The auto groups' headers, below the divider. SidebarGroups, + /// The pinned groups' headers, above it. A surface of its own so a + /// pinned header can only be dropped among the pinned ones: the two + /// halves of the sidebar are ordered by different things — the user's + /// hand above, first appearance below. + PinnedGroups, } pub(crate) struct Reorder { @@ -155,9 +197,14 @@ pub(crate) struct Reorder { prev: Cell, generation: Cell, pending: RefCell>>, - /// The custom group this drag is being held over, when the pointer has - /// left the group the tab came from. - regroup: RefCell>, + /// The group this drag is being held over, when the pointer has left the + /// group the tab came from. + regroup: RefCell>, + /// The auto group whose header this drag picked up, for the one gesture + /// that pins: carrying it above the divider. + auto: Option, + /// Set on the frames the carried auto header is above the divider. + pin: Cell, /// The tab this drag picked up, for the surfaces that drag tabs. `None` on /// a surface that drags something else — a sidebar group, say, which is /// several tabs and cannot be merged into one. @@ -185,6 +232,8 @@ impl Reorder { generation: Cell::new(0), pending: RefCell::new(None), regroup: RefCell::new(None), + auto: None, + pin: Cell::new(false), tab: None, suspended: Cell::new(false), } @@ -196,6 +245,12 @@ impl Reorder { self } + /// Names the auto group whose header this drag is carrying. + pub(crate) fn of_auto(mut self, key: AutoKey) -> Self { + self.auto = Some(key); + self + } + pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool { self.surface == *surface && self.rects.len() == len && self.from < len } @@ -448,18 +503,18 @@ mod tests { #[test] fn a_regroup_lasts_only_as_long_as_the_pointer_is_over_the_group() { let tab = TabId::new(); - let work = GroupKey::custom("work").expect("non-blank"); + let work = Regroup::Into(GroupId::new()); let fresh = || Some(column(3, 30., 2., 0).of_tab(tab)); let state: ReorderState = Rc::new(RefCell::new(fresh())); clear_pending(&state); - set_regroup(&state, work.clone()); + set_regroup(&state, work); clear_pending(&state); assert_eq!(take_landed(&state).regroup, None, "the frame moved on"); *state.borrow_mut() = fresh(); clear_pending(&state); - set_regroup(&state, work.clone()); + set_regroup(&state, work); assert_eq!(take_landed(&state).regroup, Some((tab, work))); } @@ -469,10 +524,59 @@ mod tests { fn a_drag_with_no_tab_never_lands_in_a_group() { let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0)))); clear_pending(&state); - set_regroup(&state, GroupKey::custom("work").expect("non-blank")); + set_regroup(&state, Regroup::Into(GroupId::new())); assert_eq!(take_landed(&state).regroup, None); } + /// Pinning is offered frame by frame, like a regroup: a header carried + /// above the divider and back down before it is let go only reorders. + #[test] + fn a_pin_lasts_only_as_long_as_the_header_is_above_the_divider() { + let key = AutoKey::Repo("/w/r".into()); + let fresh = || Some(column(3, 30., 2., 0).of_auto(key.clone())); + + let state: ReorderState = Rc::new(RefCell::new(fresh())); + clear_pending(&state); + set_pin(&state); + clear_pending(&state); + assert_eq!(take_landed(&state).pin, None, "carried back down"); + + *state.borrow_mut() = fresh(); + clear_pending(&state); + set_pin(&state); + assert_eq!(take_landed(&state).pin, Some(key)); + } + + /// A finished drag says which surface its order is for: the pinned + /// headers' order is the list's, not the tabs'. + #[test] + fn a_landing_names_the_surface_its_order_is_for() { + let r = Reorder::new( + Surface::PinnedGroups, + 0, + column(3, 30., 2., 0).rects, + Axis::Vertical, + px(2.), + point(px(0.), px(0.)), + ); + let state: ReorderState = Rc::new(RefCell::new(Some(r))); + clear_pending(&state); + set_pending(&state, &Surface::PinnedGroups, vec![1, 0, 2]); + let landed = take_landed(&state); + assert_eq!(landed.surface, Some(Surface::PinnedGroups)); + assert_eq!(landed.order, Some(vec![1, 0, 2])); + } + + /// Only an auto header can be pinned by dragging; a drag carrying + /// anything else offers nothing. + #[test] + fn a_drag_with_no_auto_group_never_pins() { + let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0)))); + clear_pending(&state); + set_pin(&state); + assert_eq!(take_landed(&state).pin, None); + } + #[test] fn begin_frame_bumps_the_generation_only_on_change() { let r = column(3, 30., 2., 0); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 6e6ecdf5..bd2ebc97 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -901,7 +901,7 @@ impl SearchEntry { L10nKey::SettingsScrollback => "scrollback_limit", L10nKey::SettingsNewTabPosition => "new_tab_position", L10nKey::SettingsTabBarPosition => "tab_bar_position", - L10nKey::SettingsSidebarGrouping => "sidebar_grouping", + L10nKey::SettingsSidebarGrouping => "sidebar_auto_grouping", L10nKey::SettingsDiffPreviewFromCounts => "sidebar_diff_preview", L10nKey::SettingsSshTabTitle => "ssh_tab_title", L10nKey::SettingsNotifyOnCommandFinish => "notify_on_command_finish", @@ -1053,7 +1053,9 @@ impl SearchEntry { L10nKey::SettingsScrollback => cfg.scrollback_limit != defaults.scrollback_limit, L10nKey::SettingsNewTabPosition => cfg.new_tab_position != defaults.new_tab_position, L10nKey::SettingsTabBarPosition => cfg.tab_bar_position != defaults.tab_bar_position, - L10nKey::SettingsSidebarGrouping => cfg.sidebar_grouping != defaults.sidebar_grouping, + L10nKey::SettingsSidebarGrouping => { + cfg.sidebar_auto_grouping != defaults.sidebar_auto_grouping + } L10nKey::SettingsDiffPreviewFromCounts => { cfg.sidebar_diff_preview != defaults.sidebar_diff_preview } @@ -7691,16 +7693,12 @@ impl Tty7App { TabBarPosition::Left => 1, }; let sidebar_diff_preview = cfg.sidebar_diff_preview; + let sidebar_auto_grouping = cfg.sidebar_auto_grouping; let ssh_tab_title_idx = match cfg.ssh_tab_title { SshTabTitle::Dynamic => 0, SshTabTitle::ProfileName => 1, SshTabTitle::Hostname => 2, }; - let sidebar_grouping_idx = match cfg.sidebar_grouping { - crate::core::config::SidebarGrouping::Repo => 0, - crate::core::config::SidebarGrouping::RepoOrDirectory => 1, - crate::core::config::SidebarGrouping::None => 2, - }; let notify_idx = match cfg.notify_on_command_finish { NotifyMode::Never => 0, NotifyMode::Unfocused => 1, @@ -7808,24 +7806,12 @@ impl Tty7App { .checked(sidebar_diff_preview) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_sidebar_diff_preview(*on, cx))) .into_any_element(); - let sidebar_grouping_radio = self.segmented( - "wt-sidebar-grouping", - &[ - t(L10nKey::SettingsByRepo), - t(L10nKey::SettingsByRepoOrFolder), - t(L10nKey::SettingsFlat), - ], - sidebar_grouping_idx, - cx, - |this, ix, _w, cx| { - let grouping = match ix { - 0 => crate::core::config::SidebarGrouping::Repo, - 1 => crate::core::config::SidebarGrouping::RepoOrDirectory, - _ => crate::core::config::SidebarGrouping::None, - }; - this.set_sidebar_grouping(grouping, cx); - }, - ); + let sidebar_grouping_switch = crate::ui::theme::switch("wt-sidebar-auto-grouping", cx) + .checked(sidebar_auto_grouping) + .on_click( + cx.listener(|this, on: &bool, _w, cx| this.set_sidebar_auto_grouping(*on, cx)), + ) + .into_any_element(); let ssh_tab_title_radio = self.segmented( "wt-ssh-tab-title", @@ -7892,7 +7878,7 @@ impl Tty7App { .child(self.settings_row( t(L10nKey::SettingsSidebarGrouping), t(L10nKey::SettingsSidebarGroupingDesc), - sidebar_grouping_radio, + sidebar_grouping_switch, cx, )) .child(self.settings_row( @@ -9938,7 +9924,7 @@ mod tests { "Terminal bell", "Report mouse to apps", "Open files with", - "Sidebar grouping", + "Auto grouping", "Tab completion", "Command history search", "Dim inactive panes", diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 7ef995d4..55d5db75 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -1,7 +1,8 @@ use gpui::{ - Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, canvas, - deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px, + Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, ExternalPaths, + FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, + Stateful, Window, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient, + prelude::*, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent}; @@ -12,8 +13,10 @@ use std::rc::Rc; use std::path::{Path, PathBuf}; -use crate::core::config::{Config, SidebarGrouping}; -use crate::core::group_key::{GroupKey, collapse_key}; +use crate::core::config::Config; +use crate::core::group_key::{ + AutoKey, GroupId, GroupKey, PinnedGroup, WorkspaceGroups, auto_key, place, +}; use crate::terminal::git_status::GitStatusCache; use crate::ui::app::{TITLE_BAR_HEIGHT, Tab, Tty7App}; use crate::ui::hints::tab_badge_label; @@ -60,7 +63,7 @@ mod row_metrics { pub(super) const BRANCH_ICON: f32 = 11.; /// `pl_2` + `pr_1p5` on a group header. pub(super) const HEADER_PAD: f32 = 8. + 6.; - /// The chevron a header opens with, and the asterisk that marks a custom + /// The chevron a header opens with, and the pin that marks a folder /// group: both `xsmall` icons, which resolve to 12px. pub(super) const HEADER_ICON: f32 = 12.; @@ -91,7 +94,7 @@ const ROW_CWD_FLOOR: f32 = 24.; /// rows share. The branch takes what it wants up to half the line, and the /// heading keeps the rest — so a long branch can no longer crush the name /// (flex used to hand the overflow to them in proportion to what each asked -/// for, which gave the longer string the smaller cut), and a long custom name +/// for, which gave the longer string the smaller cut), and a long group name /// cannot crush the branch in return. `git_want` is `None` for a header with /// no shared branch on it, which then owns the whole line. fn header_name_avail(avail: f32, git_want: Option) -> f32 { @@ -230,12 +233,15 @@ impl Tty7App { // same way a pane dropped here is. Blanked and written again below // so a group that folds or filters away stops accepting drops. let over_group = self.sidebar_regroup_target(window); + // Taken rather than read, for the same reason: last frame's divider is + // what a drag is measured against, and the one drawn below replaces it. + let divider_last = self.sidebar_divider.take(); let lifting_row = crate::ui::reorder::dragged_sidebar_tab(&self.reorder).is_some(); // Offered every frame the pointer is over a group, and cleared with // the rest of the drag's pending state on the frames it is not — so // letting go anywhere else drops on nothing. - if let Some(key) = over_group.clone() { - crate::ui::reorder::set_regroup(&self.reorder, key); + if let Some(target) = over_group { + crate::ui::reorder::set_regroup(&self.reorder, target); } self.sidebar_group_slots.borrow_mut().clear(); // Every group drops itself when its rows filter out, so a query that @@ -244,6 +250,11 @@ impl Tty7App { let mut list = v_flex() .id("tab-sidebar-list") + // A folder dragged in from Finder is pinned as a group of its own. + .drag_over::(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.06))) + .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { + this.pin_dropped_folders(paths.paths(), cx) + })) .track_scroll(&self.sidebar_scroll) .flex_1() .min_h_0() @@ -254,14 +265,12 @@ impl Tty7App { .gap_0p5(); let keys: Rc>> = Rc::new(self.sidebar_group_keys(cx)); - let sections = sidebar_sections(&keys); + let groups = &self.sidebar_groups; + let sections = sidebar_sections(&keys, groups); // A search outranks a fold. Typing something that matches a row inside // a folded group has to show that row — a box that says nothing // matches while the match sits behind a chevron is just lying. - let folded_keys: Vec = match query.is_empty() { - true => cx.global::().sidebar_collapsed_groups.clone(), - false => Vec::new(), - }; + let folds_apply = query.is_empty(); // ⌘N runs ActivateTabN, which goes through `activate_visual` — the // Nth row as the sidebar lays it out, not the Nth tab in `self.tabs`. @@ -343,13 +352,52 @@ impl Tty7App { cx.theme().muted_foreground, cx.theme().sidebar, ); - let rendered = |ix: &usize| !visible_by_section[*ix].is_empty(); - // Every section that owns a key — a repo root or a custom name — draws - // a header, and a header is what there is to grab, so these are the - // slots the group-reorder surface runs over. Scratch is excluded: it - // is where the keyless tabs fall, and it always sits last. + // A pinned group is drawn with no tabs in it — it is kept, and the + // row it grows says how to fill it — except while a search is + // narrowing the list, where a group with nothing matching is noise. + let rendered = |ix: &usize| { + !visible_by_section[*ix].is_empty() + || (query.is_empty() && sections[*ix].pinned().is_some()) + }; + // Pinned headers reorder among themselves, by hand; that order is the + // workspace's and is written back whole when a drag lands. + let pinned_slots: Vec = (0..sections.len()) + .filter(|&ix| sections[ix].pinned().is_some()) + .filter(rendered) + .collect(); + let pinned_rects: Rc>>> = + Rc::new(RefCell::new(vec![Bounds::default(); pinned_slots.len()])); + let pinned_preview = reorder::preview( + &self.reorder, + &Surface::PinnedGroups, + pinned_slots.len(), + pointer, + ); + let pinned_display: Vec = match &pinned_preview { + Some(p) => { + let all: Vec = groups.pinned.iter().map(|g| g.id).collect(); + let shown: Vec = pinned_slots + .iter() + .filter_map(|&ix| sections[ix].pinned()) + .collect(); + if let Some(order) = reordered_pinned(&all, &shown, p.from, p.target) { + reorder::set_pending(&self.reorder, &Surface::PinnedGroups, order); + } + p.order.clone() + } + None => (0..pinned_slots.len()).collect(), + }; + // Every auto group draws a header, and a header is what there is to + // grab, so these are the slots the auto-group surface runs over. + // Ungrouped is excluded: it is where the keyless tabs fall, and it + // always sits last. let keyed_slots: Vec = (0..sections.len()) - .filter(|&ix| sections[ix].key.is_some()) + .filter(|&ix| { + sections[ix] + .key + .as_ref() + .is_some_and(|k| k.auto().is_some()) + }) .filter(rendered) .collect(); let keyed_groups = keyed_slots.len(); @@ -361,9 +409,9 @@ impl Tty7App { keyed_groups, pointer, ); - let keyed_keys: Vec = keyed_slots + let keyed_keys: Vec = keyed_slots .iter() - .filter_map(|&ix| sections[ix].key.clone()) + .filter_map(|&ix| sections[ix].key.as_ref()?.auto().cloned()) .collect(); let slot_display: Vec = match &group_preview { Some(p) => { @@ -376,26 +424,62 @@ impl Tty7App { } None => (0..keyed_groups).collect(), }; - let mut blocks: Vec<(Option, usize)> = slot_display + let mut blocks: Vec<(Option, usize)> = pinned_display .into_iter() - .map(|slot| (Some(slot), keyed_slots[slot])) + .map(|slot| (Some(HeaderSlot::Pinned(slot)), pinned_slots[slot])) .collect(); + let first_unpinned = blocks.len(); + blocks.extend( + slot_display + .into_iter() + .map(|slot| (Some(HeaderSlot::Auto(slot)), keyed_slots[slot])), + ); blocks.extend( (0..sections.len()) .filter(|&ix| sections[ix].key.is_none()) .filter(rendered) .map(|ix| (None, ix)), ); + // The divider: kept groups above it, derived ones below. Drawn + // whenever something is kept, and while an auto header is in the air + // even when nothing is yet — carrying it above the divider is how it + // gets pinned, so there has to be a divider to carry it above. + let lifted_auto = reorder::dragged_auto_group(&self.reorder); + let show_divider = !groups.pinned.is_empty() || lifted_auto.is_some(); + let pin_hover = lifted_auto.is_some() + && divider_last.is_some_and(|d| pointer.y < d.origin.y + d.size.height); + if pin_hover { + reorder::set_pin(&self.reorder); + } + let divider_lit = pin_hover || over_group == Some(reorder::Regroup::ToAuto); + let divider_zone = groups.pinned.is_empty(); + let mut divider_drawn = false; + // Whether the tab in the air is kept in a pinned group — then + // everything below the divider is somewhere it can go. + let lifted_is_kept = reorder::dragged_sidebar_tab(&self.reorder).is_some_and(|id| { + self.tabs + .iter() + .find(|t| t.tree_id.get() == id) + .and_then(|t| t.group.get()) + .is_some_and(|g| groups.contains(g)) + }); + let workspace_is_local = !crate::core::session::WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()); - for (group_slot, group_ix) in blocks { + for (n, (group_slot, group_ix)) in blocks.into_iter().enumerate() { + if n == first_unpinned && show_divider { + list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx)); + divider_drawn = true; + } let section = §ions[group_ix]; let group_key = section.key.clone(); // Only a group that draws a header can be folded — there is // nothing to click otherwise, and the one headerless section (the - // whole sidebar, when grouping is off) must never answer to the - // scratch group's key. + // list below the pinned groups, when nothing else is there) must + // never answer to Ungrouped's fold. let folded = - section.name.is_some() && folded_keys.contains(&collapse_key(group_key.as_ref())); + section.name.is_some() && folds_apply && groups.is_folded(group_key.as_ref()); let mut rows: Vec>> = Vec::new(); // The header keeps counting every row the group has; folding only // stops them being drawn. Nothing downstream then registers a @@ -1028,14 +1112,15 @@ impl Tty7App { })); } - if row_count == 0 { + // An empty pinned group is still drawn: it is kept until deleted. + if row_count == 0 && section.pinned().is_none() { continue; } let row_display: Vec = match &row_preview { Some(p) => { if let Some(order) = - reordered_rows(&keys, &group_key, &visible_tabs, p.from, p.target) + reordered_rows(&keys, groups, &group_key, &visible_tabs, p.from, p.target) { reorder::set_pending( &self.reorder, @@ -1082,16 +1167,22 @@ impl Tty7App { .into_any_element(), }) .collect(); - // A custom group carries a pin. It is the only thing separating + // A pinned group carries a mark. It is the only thing separating // it on sight from a derived one — they behave differently (a // `cd` moves a tab out of a repo group and never out of this - // one), and a custom group named after a real repo would - // otherwise print a header identical to that repo's. - let pinned = group_key.as_ref().is_some_and(GroupKey::is_custom); + // one), and a group pinned on a real repo would otherwise print a + // header identical to that repo's. + let pinned_id = section.pinned(); + let pinned = pinned_id.is_some(); + let pinned_folder: Option = pinned_id + .and_then(|id| groups.get(id)) + .and_then(|g| g.folder.clone()) + .map(SharedString::from); + let auto_key_here = group_key.as_ref().and_then(GroupKey::auto).cloned(); let renaming_group = self .group_rename .as_ref() - .filter(|r| Some(&r.key) == group_key.as_ref()) + .filter(|r| Some(r.group) == pinned_id) .map(|r| r.input.clone()); let header = section.name.clone().map(|name| { // The header packs a heading and the branch its whole group @@ -1107,7 +1198,7 @@ impl Tty7App { // row already elides its own. let ts = window.text_system(); let mut avail = row_metrics::header_budget(width); - if pinned { + if pinned_folder.is_some() { avail -= row_metrics::HEADER_ICON + row_metrics::META_GAP; } let count_label = row_count.to_string(); @@ -1130,8 +1221,11 @@ impl Tty7App { let name_avail = header_name_avail(avail, git_want); let label = elide_label(&ts, &header_font, header_size, &name, name_avail); let name_w = measure_text(&ts, &header_font, header_size, &label); + let hover_group = SharedString::from(format!("sidebar-group-{group_ix}")); let bar = h_flex() .id(("sidebar-group", group_ix)) + .group(hover_group.clone()) + .relative() .w_full() .items_center() .gap_1p5() @@ -1150,19 +1244,31 @@ impl Tty7App { move |this, _, _window, cx| this.toggle_sidebar_group(key.as_ref(), cx) })) .when_some(group_slot, |header, slot| { + let (surface, slot, slots) = match slot { + HeaderSlot::Pinned(s) => { + (Surface::PinnedGroups, s, pinned_rects.clone()) + } + HeaderSlot::Auto(s) => (Surface::SidebarGroups, s, group_slots.clone()), + }; + let auto = auto_key_here.clone(); crate::ui::reorder::cursor_grab(header).on_drag(DragGroup, { let state = self.reorder.clone(); - let slots = group_slots.clone(); move |_drag, grab, _window, cx| { cx.stop_propagation(); - *state.borrow_mut() = Some(Reorder::new( - Surface::SidebarGroups, + let reorder = Reorder::new( + surface.clone(), slot, slots.borrow().clone(), Axis::Vertical, px(ROW_GAP), grab, - )); + ); + // An auto header says which group it is, so + // letting it go above the divider can pin it. + *state.borrow_mut() = Some(match auto.clone() { + Some(key) => reorder.of_auto(key), + None => reorder, + }); cx.new(|_| DragGroup) } }) @@ -1177,13 +1283,37 @@ impl Tty7App { .child(Icon::new(IconName::ChevronRight).xsmall()), ) }) - .when(pinned, |header| { - header.child( - div() - .flex_shrink_0() - .child(Icon::new(IconName::Asterisk).xsmall()), - ) - }) + // The pin marks a folder group: kept, and keeping a folder. + // It is also the way to stop keeping it — a click unpins, + // and the folder's tabs fall back to the groups their cwds + // resolve to. A label group has no such mark: it sits above + // the divider, and there is no folder to fall back on. + .when_some( + pinned_id.filter(|_| pinned_folder.is_some()), + |header, id| { + header.child( + div() + .id(("sidebar-group-unpin", group_ix)) + .flex_shrink_0() + .cursor_pointer() + .hover(|s| s.text_color(cx.theme().foreground)) + .child(Icon::empty().path("icons/pin.svg").xsmall()) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t( + L10nKey::SidebarUnpinGroup, + )) + .build(window, cx) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.delete_group(id, cx); + })), + ) + }, + ) .child(match renaming_group { Some(input) => div() .id(("sidebar-group-rename", group_ix)) @@ -1201,6 +1331,7 @@ impl Tty7App { // backstop for a face that measures wider than it // paints; the name no longer gives room to the branch. None => div() + .id(("sidebar-group-name", group_ix)) .flex_shrink_0() .min_w_0() .truncate() @@ -1210,6 +1341,14 @@ impl Tty7App { // counts beside it are the metadata. .text_color(cx.theme().foreground) .child(label) + // A folder group may have been renamed to anything; + // the folder it keeps is what the name stands for. + .when_some(pinned_folder.clone(), |name, folder| { + name.tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(folder.clone()) + .build(window, cx) + }) + }) .into_any_element(), }) .when_some(shared_git, |bar, shared| { @@ -1299,34 +1438,41 @@ impl Tty7App { // is what a shut group has instead of them. .when(folded, |bar| { bar.child(div().flex_shrink_0().child(count_label)) - }); + }) + .children(self.header_actions(group_ix, group_key.clone(), hover_group, cx)); // Renaming is offered on a menu rather than a double click: // the first click of a double would fold the group, so the - // name would be edited on a box that just shut. A repo group - // gets no menu — it is named after its root, and a rename - // there could only lie about where its tabs are. + // name would be edited on a box that just shut. An auto group + // offers no rename — it is named after its repo or host, and a + // rename there could only lie about where its tabs are; pin it + // first, and it is the user's to name. // // Attached last and erased to `AnyElement`, because the menu - // wrapper changes the element's type and the two arms have to + // wrapper changes the element's type and the arms have to // agree. - match (pinned, group_key.clone()) { - (true, Some(key)) => { - let app = cx.entity().downgrade(); - bar.context_menu(move |menu, _window, _cx| { - let app = app.clone(); - let key = key.clone(); - menu.item(PopupMenuItem::new(t(L10nKey::SidebarRenameGroup)).on_click( - move |_, window, cx| { - let key = key.clone(); - let _ = app.update(cx, |this, cx| { - this.start_group_rename(key, window, cx) - }); - }, - )) + let app = cx.entity().downgrade(); + match (pinned_id, auto_key_here.clone()) { + (Some(id), _) => { + let has_folder = pinned_folder.is_some(); + bar.context_menu(move |menu, window, cx| { + pinned_header_menu( + menu, + id, + has_folder, + workspace_is_local, + &app, + window, + cx, + ) }) .into_any_element() } - _ => bar.into_any_element(), + (None, Some(key)) => bar + .context_menu(move |menu, _window, _cx| { + auto_header_menu(menu, key.clone(), &app) + }) + .into_any_element(), + (None, None) => bar.into_any_element(), } }); @@ -1335,50 +1481,91 @@ impl Tty7App { // that cannot fade back. Until a drag is under way they look // alike, and this is where a user finds out which is which // without being told. - let takes_drops = group_key.as_ref().is_some_and(GroupKey::is_custom); + let takes_drops = pinned || (lifted_is_kept && n >= first_unpinned); + let (preview, slot) = match group_slot { + Some(HeaderSlot::Pinned(s)) => (pinned_preview.as_ref(), Some(s)), + Some(HeaderSlot::Auto(s)) => (group_preview.as_ref(), Some(s)), + None => (None, None), + }; + // An empty pinned group says how to fill it, in the place its + // rows would be: a tab can be dragged onto it, or opened here. + let empty_row = (row_count == 0 && !folded) + .then_some(pinned_id) + .flatten() + .map(|id| { + h_flex() + .id(("sidebar-group-empty", group_ix)) + .w_full() + .h(px(ROW_HEIGHT)) + .items_center() + .gap_2() + .pl_2() + .rounded(crate::ui::rounding::CARD_RADIUS) + .cursor_pointer() + .text_sm() + .text_color(cx.theme().muted_foreground) + .hover(|s| { + s.bg(gpui::rgb(sf.hover)) + .text_color(cx.theme().sidebar_foreground) + }) + .child( + div() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(row_metrics::AVATAR)) + .child(Icon::empty().path("icons/plus.svg").xsmall()), + ) + .child(t(L10nKey::SidebarGroupNewTab)) + .on_click(cx.listener(move |this, _, window, cx| { + this.new_tab_in_group(GroupKey::Pinned(id), window, cx) + })) + }); let block = v_flex() .w_full() .gap(px(ROW_GAP)) - .when( - group_preview - .as_ref() - .is_some_and(|p| Some(p.from) == group_slot), - |b| b.opacity(0.75), - ) - .when(lifting_row && !takes_drops, |b| b.opacity(0.4)) - .when(over_group.is_some() && over_group == group_key, |b| { - b.rounded_md().bg(cx.theme().drag_border.opacity(0.15)) + .when(preview.is_some_and(|p| Some(p.from) == slot), |b| { + b.opacity(0.75) }) + .when(lifting_row && !takes_drops, |b| b.opacity(0.4)) + .when( + pinned_id.is_some_and(|id| over_group == Some(reorder::Regroup::Into(id))), + |b| b.rounded_md().bg(cx.theme().drag_border.opacity(0.15)), + ) .children(header) .children(rows) - .when_some(group_slot, |block, slot| { - block.child( - canvas( - { - let slots = group_slots.clone(); - let landing = self.sidebar_group_slots.clone(); - // Only a custom group is recorded, so a drag - // looking for somewhere to land finds nothing - // over a repo group or over Scratch. - let key = group_key.clone().filter(GroupKey::is_custom); - move |bounds, _window, _cx| { - if let Some(s) = slots.borrow_mut().get_mut(slot) { - *s = bounds; - } - if let Some(key) = key.clone() { - landing.borrow_mut().push((key, bounds)); - } + .children(empty_row) + .child( + canvas( + { + let slots = match group_slot { + Some(HeaderSlot::Pinned(_)) => pinned_rects.clone(), + _ => group_slots.clone(), + }; + let landing = self.sidebar_group_slots.clone(); + // Only a pinned group is recorded, so a drag + // looking for somewhere to land finds nothing over + // an auto group or over Ungrouped. + move |bounds, _window, _cx| { + if let Some(slot) = slot + && let Some(s) = slots.borrow_mut().get_mut(slot) + { + *s = bounds; } - }, - |_, _, _, _| {}, - ) - .absolute() - .inset_0(), + if let Some(id) = pinned_id { + landing.borrow_mut().push((id, bounds)); + } + } + }, + |_, _, _, _| {}, ) - }); + .absolute() + .inset_0(), + ); any_rows = true; - list = list.child(match (&group_preview, group_slot) { + list = list.child(match (preview, slot) { (Some(p), Some(slot)) if p.from == slot => { deferred(block.relative().top(p.held)).into_any_element() } @@ -1399,6 +1586,9 @@ impl Tty7App { _ => block.into_any_element(), }); } + if show_divider && !divider_drawn { + list = list.child(self.sidebar_divider(divider_lit, divider_zone, cx)); + } if !any_rows && !query.is_empty() { list = list.child( @@ -1633,6 +1823,142 @@ impl Tty7App { .child(handle) } + /// The line between the kept groups and the derived ones, recording where + /// it was drawn so a drag next frame can tell which side it is on. + /// + /// With nothing pinned yet it only appears while an auto header is being + /// carried, and then as a drop zone rather than a hairline: a one-pixel + /// line at the very top of the list is not something anyone could aim + /// above. `lit` while letting go would pin the header, or hand a kept tab + /// back to auto grouping. + fn sidebar_divider(&self, lit: bool, zone: bool, cx: &Context) -> AnyElement { + let bounds = self.sidebar_divider.clone(); + let ink = match lit { + true => cx.theme().drag_border, + false => cx.theme().sidebar_border, + }; + let body = match zone { + true => h_flex() + .h(px(ROW_HEIGHT)) + .w_full() + .items_center() + .justify_center() + .rounded(crate::ui::rounding::CARD_RADIUS) + .border_1() + .border_color(ink) + .when(lit, |d| d.bg(cx.theme().drag_border.opacity(0.15))) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t(L10nKey::SidebarDropToPin)) + .into_any_element(), + false => div() + .w_full() + .h(px(if lit { 2. } else { 1. })) + .bg(ink) + .into_any_element(), + }; + v_flex() + .id("sidebar-divider") + .relative() + .w_full() + .px_2() + .pt(px(10.)) + .pb(px(2.)) + .child( + canvas(move |b, _window, _cx| bounds.set(Some(b)), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) + .child(body) + .into_any_element() + } + + /// The buttons a header shows on hover, laid over its right end: pin for + /// an auto group, and "+" to open a tab in the group. `None` for + /// Ungrouped, which has neither. + /// + /// Laid over the branch rather than beside it, the way a row's close + /// button covers its badge, so a header does not give up width it would + /// only use while the pointer is on it. Acted on at the press, as a row's + /// diff counts are: the header folds on click and picks itself up on a + /// drag, and swallowing the press is what keeps both from happening too. + fn header_actions( + &self, + group_ix: usize, + key: Option, + hover: SharedString, + cx: &mut Context, + ) -> Option { + let key = key?; + let backing = crate::ui::theme::workspace_surface_color(cx); + let mut fade = backing; + fade.a = 0.; + let muted = cx.theme().muted_foreground; + let ink = cx.theme().foreground; + let button = |id: &'static str, path: &'static str, tip: L10nKey| { + div() + .id((id, group_ix)) + .flex_shrink_0() + .p(px(2.)) + .rounded_sm() + .cursor_pointer() + .text_color(muted) + .hover(move |s| s.text_color(ink)) + .child(Icon::empty().path(path).xsmall()) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx) + }) + }; + let mut buttons = h_flex().gap_0p5().bg(backing); + if let Some(auto) = key.auto().cloned() { + buttons = buttons.child( + button( + "sidebar-group-pin", + "icons/pin.svg", + L10nKey::SidebarPinGroup, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + this.pin_auto_group(auto.clone(), cx); + }), + ), + ); + } + if !matches!(key, GroupKey::Auto(AutoKey::SshHost(_))) { + buttons = buttons.child( + button( + "sidebar-group-add", + "icons/plus.svg", + L10nKey::SidebarGroupNewTab, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + this.new_tab_in_group(key.clone(), window, cx); + }), + ), + ); + } + Some( + h_flex() + .absolute() + .right(px(4.)) + .bottom(px(2.)) + .opacity(0.) + .group_hover(hover, |s| s.opacity(1.)) + .child(div().w(px(10.)).h(px(16.)).bg(linear_gradient( + 90., + linear_color_stop(fade, 0.), + linear_color_stop(backing, 1.), + ))) + .child(buttons) + .into_any_element(), + ) + } + /// What the sidebar row hid: the full title, the full branch and diff /// counts, the working directory, and the remote host the avatar only /// dots. `None` when the row showed everything — a card would add noise, @@ -1683,139 +2009,423 @@ impl Tty7App { .then_some(info) } - /// Fold the sidebar group `key` names, or unfold it if it is already - /// shut. Persisted: a group folded away is a statement about a repo you - /// are done with for now, and it should still be shut tomorrow. - /// The custom group a tab being dragged is currently held over, if any. + /// Where a tab being dragged in the sidebar would land if let go now, + /// when that is somewhere other than a new place in its own group. /// - /// Answers `None` unless a tab is in the air, the pointer is inside a - /// custom group's block, and that is not the group the tab is already in - /// — a drag that would change nothing offers nothing, so it falls back - /// to plain reordering. + /// Over a pinned group it did not come from, into that group; anywhere + /// below the divider, back to auto grouping — but only for a tab that is + /// in a pinned group, since one that is not already is auto grouped and + /// a drag that would change nothing offers nothing, falling back to plain + /// reordering. /// - /// Only custom groups are candidates. A repo group's membership is - /// decided by its tabs' cwds, so "put this tab in tty7" is a request the - /// sidebar has no honest way to honour; those blocks are never recorded, - /// so the pointer finds nothing over them. The same goes for Scratch, - /// which is where tabs land when no group claims them. - fn sidebar_regroup_target(&self, window: &Window) -> Option { + /// No auto group is a target. Its membership is decided by its tabs' + /// cwds, so "put this tab in tty7" is a request the sidebar has no honest + /// way to honour; the only thing a drop below the divider can mean is + /// "stop keeping this tab by hand". + fn sidebar_regroup_target(&self, window: &Window) -> Option { let dragged = crate::ui::reorder::dragged_sidebar_tab(&self.reorder)?; let here = self .tabs .iter() .find(|t| t.tree_id.get() == dragged) - .and_then(|t| t.sidebar_group.borrow().clone()); + .and_then(|t| t.group.get()) + .filter(|g| self.sidebar_groups.contains(*g)); let pointer = window.mouse_position(); - self.sidebar_group_slots + if let Some((id, _)) = self + .sidebar_group_slots .borrow() .iter() .find(|(_, bounds)| bounds.contains(&pointer)) - .map(|(key, _)| key.clone()) - .filter(|key| Some(key) != here.as_ref()) - } - - /// The custom groups that exist right now, in sidebar order. - /// - /// A group exists only while a tab says it does — there is no list of - /// groups anywhere else. That is what makes "move the last tab out" the - /// same gesture as "delete the group", and it is the same rule a repo - /// group already lives by. - /// - /// Read off the tabs rather than off [`Self::sidebar_group_keys`] so - /// that building a menu never runs the cwd probe, which writes back. - pub(crate) fn custom_group_names(&self) -> Vec { - let mut out: Vec = Vec::new(); - for tab in &self.tabs { - if let Some(GroupKey::Custom(name)) = tab.sidebar_group.borrow().as_ref() - && !out.iter().any(|n| n == name) - { - out.push(name.clone()); - } + { + return (Some(*id) != here).then_some(reorder::Regroup::Into(*id)); } - out + let below = self + .sidebar_divider + .get() + .is_some_and(|d| pointer.y >= d.origin.y); + (below && here.is_some()).then_some(reorder::Regroup::ToAuto) } - /// Put the dragged tab in the group it was dropped on. + /// The pinned groups there are, in sidebar order, with the name each + /// header reads — for menus that offer them. + pub(crate) fn pinned_group_names(&self) -> Vec<(GroupId, String)> { + let names = pinned_names(&self.sidebar_groups.pinned); + self.sidebar_groups + .pinned + .iter() + .map(|g| g.id) + .zip(names) + .collect() + } + + /// Change this workspace's groups, and send the change to the machine so + /// every other window onto the workspace draws it too. + pub(crate) fn edit_groups( + &mut self, + cx: &mut Context, + edit: impl FnOnce(&mut WorkspaceGroups), + ) { + edit(&mut self.sidebar_groups); + // Up as one `WorkspaceSetGroups`, queued ahead of any `TabSetGroup` + // the same edit made — which the save below sends. + crate::ui::tree_sync::push_groups(cx, self.workspace, self.sidebar_groups.clone()); + self.save_session(cx); + cx.notify(); + } + + /// Take the workspace's groups as another window, or the machine, has them. + /// + /// Every tab's [`EntryWatch`](crate::core::group_key::EntryWatch) starts + /// over from where it is. A folder that arrives from elsewhere is not one + /// this window's tabs walked into — and in particular, a window that drew + /// a frame before its copy of the groups landed saw no folders at all, so + /// without this every tab sitting in a pinned folder at launch would read + /// the groups landing as an entry, and a tab dragged out of its folder + /// group would be pulled back in on every start. The window that pinned + /// the folder gathers the tabs itself and says so tab by tab. + pub(crate) fn adopt_sidebar_groups(&mut self, groups: WorkspaceGroups, cx: &mut Context) { + if self.sidebar_groups == groups { + return; + } + self.sidebar_groups = groups; + for tab in &self.tabs { + tab.folder_watch + .set(crate::core::group_key::EntryWatch::baseline()); + } + cx.notify(); + } + + /// Put the dragged tab where it was dropped. /// /// By id rather than index: a drag is several frames long, and a tab /// closing anywhere else in that time would shift every index after it. pub(crate) fn regroup_tab( &mut self, tab: tty7_core::core::machine::TabId, - key: GroupKey, + target: reorder::Regroup, cx: &mut Context, ) { let Some(index) = self.tabs.iter().position(|t| t.tree_id.get() == tab) else { return; }; - self.set_tab_group(index, Some(key), cx); + let group = match target { + reorder::Regroup::Into(id) => Some(id), + reorder::Regroup::ToAuto => None, + }; + self.set_tab_group(index, group, cx); } - /// Put tab `index` in `key`, or hand it back to the cwd probe when `key` - /// is `None`. - /// - /// Clearing is the only way back. A stated group locks the probe out of - /// that tab for good, so without a way to clear it a tab moved into a - /// custom group could never follow its cwd again — and the user has no - /// way to tell that is what they just did. + /// Put tab `index` in pinned group `group`, or hand it back to auto + /// grouping when `group` is `None`. pub(crate) fn set_tab_group( &mut self, index: usize, - key: Option, + group: Option, cx: &mut Context, ) { let Some(tab) = self.tabs.get(index) else { return; }; - *tab.sidebar_group.borrow_mut() = key; + tab.group.set(group); // Carries the move to the daemon as a `TabSetGroup`, so another // window on the same workspace sees it too. self.save_session(cx); cx.notify(); } - /// Make a new custom group, put tab `index` in it, and open its header - /// for renaming. + /// A name no pinned group is using yet, for a group about to be made. + /// + /// The placeholder only has to be unique — the rename box opens on it + /// selected, so the first keystroke replaces it. Unique still matters: + /// two headers reading the same name are two groups nobody can tell + /// apart. + fn fresh_group_name(&self) -> String { + let taken: Vec = self + .pinned_group_names() + .into_iter() + .map(|(_, n)| n) + .collect(); + let base = t(L10nKey::SidebarNewGroupName).to_string(); + (1..) + .map(|n| match n { + 1 => base.clone(), + n => format!("{base} {n}"), + }) + .find(|candidate| !taken.contains(candidate)) + .expect("an unbounded range always reaches an untaken name") + } + + /// Make a new label group, put tab `index` in it, and open its header for + /// renaming. /// /// No dialog: the tab is in the group before a character is typed, so - /// what the name is being given to is on screen while it is chosen. The - /// placeholder only has to be unique — the box opens selected, so the - /// first keystroke replaces it. + /// what the name is being given to is on screen while it is chosen. pub(crate) fn new_tab_group( &mut self, index: usize, window: &mut Window, cx: &mut Context, ) { - let taken = self.custom_group_names(); - let base = t(L10nKey::SidebarNewGroupName).to_string(); - let name = (1..) - .map(|n| match n { - 1 => base.clone(), - n => format!("{base} {n}"), - }) - .find(|candidate| !taken.contains(candidate)) - .expect("an unbounded range always reaches an untaken name"); - let Some(key) = GroupKey::custom(&name) else { - return; - }; - self.set_tab_group(index, Some(key.clone()), cx); - self.start_group_rename(key, window, cx); + let group = PinnedGroup::label(self.fresh_group_name()); + let id = group.id; + if let Some(tab) = self.tabs.get(index) { + tab.group.set(Some(id)); + } + self.edit_groups(cx, |groups| groups.pinned.push(group)); + self.start_group_rename(id, window, cx); } - /// Open the header of custom group `key` for renaming. - pub(crate) fn start_group_rename( + /// Make a new, empty label group and open its header for renaming — the + /// palette's "New Group". It stays on screen empty until a tab is dragged + /// into it or opened from its header. + pub(crate) fn new_empty_group(&mut self, window: &mut Window, cx: &mut Context) { + let group = PinnedGroup::label(self.fresh_group_name()); + let id = group.id; + self.edit_groups(cx, |groups| groups.pinned.push(group)); + self.start_group_rename(id, window, cx); + } + + /// Keep the auto group `key`: pin it, with the tabs in it now. + /// + /// A repo group becomes a folder group on the repo's home, so tabs that + /// walk into the repo later join it too, and it reads the repo's name + /// until renamed. A host group has no folder on this workspace's machine + /// to keep, so it becomes a label group named after the host. + /// + /// The fold comes along: a group that was shut stays shut, rather than + /// springing open in its new place. + pub(crate) fn pin_auto_group(&mut self, key: AutoKey, cx: &mut Context) { + let mut group = match &key { + AutoKey::Repo(root) => PinnedGroup::folder(root), + AutoKey::SshHost(host) => PinnedGroup::label(host.clone()), + }; + group.collapsed = self.sidebar_groups.auto_collapsed.contains(&key); + let id = group.id; + let wanted = Some(GroupKey::Auto(key.clone())); + for (tab, place) in self.tabs.iter().zip(self.sidebar_group_keys(cx)) { + if place == wanted { + tab.group.set(Some(id)); + } + } + self.edit_groups(cx, |groups| { + groups.auto_collapsed.retain(|k| *k != key); + groups.pinned.push(group); + }); + } + + /// Pin `folder` as a group of its own — a folder dropped from Finder, or + /// "Pin as Group" in the file tree. Pinning one that is already pinned + /// does nothing: two groups keeping one folder would split its tabs + /// between them by nothing but list order. + /// + /// Tabs already sitting in the folder are gathered in on the next frame, + /// the way a tab walking in would be: the folder is new, so each of them + /// has just "entered" it as far as [`EntryWatch`](crate::core::group_key::EntryWatch) can tell. + pub(crate) fn pin_folder(&mut self, folder: PathBuf, cx: &mut Context) -> GroupId { + let spelled = folder.to_string_lossy(); + if let Some(kept) = self + .sidebar_groups + .pinned + .iter() + .find(|g| g.folder.as_deref() == Some(&*spelled)) + { + return kept.id; + } + let group = PinnedGroup::folder(&folder); + let id = group.id; + self.edit_groups(cx, |groups| groups.pinned.push(group)); + id + } + + /// The palette's "Open Folder as Group…": pick a folder on this computer, + /// pin it, and open a tab in it — "open" is the promise, and an empty group + /// with nothing in it would not keep it. + pub(crate) fn open_folder_as_group(&mut self, cx: &mut Context) { + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = rx.await + && let Some(path) = paths.into_iter().next() + { + let _ = this.update_in(cx, |this, window, cx| { + let id = this.pin_folder(path, cx); + this.new_tab_in_group(GroupKey::Pinned(id), window, cx); + }); + } + }) + .detach(); + } + + /// Folders dropped on the sidebar from Finder or Explorer, pinned as + /// groups. Anything that is not a directory is let fall: a file names no + /// group, and guessing its parent would pin something nobody pointed at. + /// + /// Only on a workspace on this computer. A dropped path is this + /// machine's, and a folder group keeps a directory on the workspace's + /// host — on a remote one the same spelling names something else, or + /// nothing at all. + pub(crate) fn pin_dropped_folders(&mut self, paths: &[PathBuf], cx: &mut Context) { + if crate::core::session::WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()) + { + return; + } + for path in paths.iter().filter(|p| p.is_dir()) { + self.pin_folder(path.clone(), cx); + } + } + + /// Delete pinned group `id`. Its tabs are not closed — they go back to + /// auto grouping, which is where a tab nobody filed by hand belongs. + /// + /// Unpinning a folder group is the same act: the group stops being kept, + /// and its tabs fall back to the auto groups their cwds resolve to. + pub(crate) fn delete_group(&mut self, id: GroupId, cx: &mut Context) { + for tab in &self.tabs { + if tab.group.get() == Some(id) { + tab.group.set(None); + } + } + if self.group_rename.as_ref().is_some_and(|r| r.group == id) { + self.group_rename = None; + } + self.edit_groups(cx, |groups| groups.pinned.retain(|g| g.id != id)); + } + + /// Point pinned group `id` at `folder`, or make it a label group when + /// `folder` is `None`. + /// + /// A folder group nobody renamed reads its folder's name, and would read + /// nothing once the folder is gone — so clearing the folder keeps the + /// name it was showing. + pub(crate) fn set_group_folder( + &mut self, + id: GroupId, + folder: Option, + cx: &mut Context, + ) { + let shown = self + .pinned_group_names() + .into_iter() + .find(|(g, _)| *g == id) + .map(|(_, n)| n); + self.edit_groups(cx, |groups| { + let Some(group) = groups.get_mut(id) else { + return; + }; + if folder.is_none() && group.given_name().is_none() { + group.name = shown; + } + group.folder = folder.map(|f| f.to_string_lossy().into_owned()); + }); + } + + /// Ask for a folder for pinned group `id` with the system picker. Only + /// for a workspace on this machine: the picker browses this computer, and + /// a path picked here means nothing to a remote one. + pub(crate) fn pick_group_folder(&mut self, id: GroupId, cx: &mut Context) { + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = rx.await + && let Some(path) = paths.into_iter().next() + { + let _ = this.update(cx, |this, cx| this.set_group_folder(id, Some(path), cx)); + } + }) + .detach(); + } + + /// The directory the active tab is working in, on the workspace's host — + /// what "Use Current Tab's Folder" pins a group to. + pub(crate) fn active_tab_folder(&self, window: &Window, cx: &gpui::App) -> Option { + let leaf = self + .tabs + .get(self.active)? + .pane + .focused_or_first(window, cx)?; + leaf.read(cx).effective_host_cwd() + } + + /// Open a tab in the group drawn under `key` — a header's "+" and its + /// menu's "New Tab". + /// + /// A folder group opens in its folder, a repo group in its repo; a label + /// group has no directory of its own and opens where ⌘T would. The tab + /// joins a pinned group outright rather than waiting for its cwd to walk + /// in: a label group has nothing to walk into, and "new tab here" should + /// not depend on a probe. + pub(crate) fn new_tab_in_group( &mut self, key: GroupKey, window: &mut Window, cx: &mut Context, ) { - let GroupKey::Custom(current) = &key else { - // A repo group is named after its root. There is nothing to - // rename that would not be a lie about where the tabs are. + let before = self.tabs.len(); + let folder = match &key { + GroupKey::Pinned(id) => self + .sidebar_groups + .get(*id) + .and_then(|g| g.folder_path().map(Path::to_path_buf)), + GroupKey::Auto(AutoKey::Repo(root)) => Some(root.clone()), + GroupKey::Auto(AutoKey::SshHost(_)) => None, + }; + match folder { + Some(folder) => self.new_tab_at(folder, window, cx), + None => self.new_tab(window, cx), + } + if self.tabs.len() == before { + return; + } + if let GroupKey::Pinned(id) = key + && let Some(tab) = self.tabs.get(self.active) + { + tab.group.set(Some(id)); + self.save_session(cx); + } + } + + /// Put the pinned groups in `order` — indices into the list as it stands. + pub(crate) fn apply_pinned_order(&mut self, order: &[usize], cx: &mut Context) { + let pinned = &self.sidebar_groups.pinned; + if order.len() != pinned.len() || order.iter().enumerate().all(|(i, &o)| i == o) { + return; + } + let reordered: Vec = order + .iter() + .filter_map(|&i| pinned.get(i).cloned()) + .collect(); + if reordered.len() != pinned.len() { + return; + } + self.edit_groups(cx, |groups| groups.pinned = reordered); + } + + /// Open the header of pinned group `id` for renaming. + pub(crate) fn start_group_rename( + &mut self, + id: GroupId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(current) = self + .pinned_group_names() + .into_iter() + .find(|(g, _)| *g == id) + .map(|(_, n)| n) + else { return; }; - let input = Self::rename_box(current.clone(), window, cx); + let input = Self::rename_box(current, window, cx); let subs = vec![cx.subscribe_in( &input, window, @@ -1827,108 +2437,151 @@ impl Tty7App { }, )]; self.group_rename = Some(crate::ui::app::GroupRename { - key, + group: id, input, _subs: subs, }); cx.notify(); } - /// Write the typed name onto every tab that claims the old one. - /// - /// All in one pass. A group is only the set of tabs that name it, so a - /// rename that reached half of them would not be a half-renamed group — - /// it would be two groups. + /// Write the typed name onto the group being renamed. pub(crate) fn commit_group_rename(&mut self, window: &mut Window, cx: &mut Context) { let Some(rename) = self.group_rename.take() else { return; }; let value = rename.input.read(cx).value().trim().to_string(); - // A blank name is not a group. Taking it would drop every tab in it - // into Scratch, which is a lot to happen because a box was cleared - // and dismissed — so an empty name means "keep the name you had". - if let Some(new_key) = GroupKey::custom(&value) - && new_key != rename.key - { - for tab in &self.tabs { - let mut group = tab.sidebar_group.borrow_mut(); - if group.as_ref() == Some(&rename.key) { - *group = Some(new_key.clone()); - } + self.edit_groups(cx, |groups| { + let Some(group) = groups.get_mut(rename.group) else { + return; + }; + match (value.is_empty(), group.folder.is_some()) { + // A folder group handed a blank name goes back to reading + // its folder's — which is how a rename is undone. + (true, true) => group.name = None, + // A label group has nothing to fall back on: a blank name + // would draw an unlabelled header, so it keeps the one it had. + (true, false) => {} + (false, _) => group.name = Some(value), } - // The fold list is keyed by name too, so a shut group that is - // renamed would spring open under a key nothing folds any more. - let (was, now) = ( - collapse_key(Some(&rename.key)), - collapse_key(Some(&new_key)), - ); - self.update_config(cx, |cfg| { - if let Some(at) = cfg.sidebar_collapsed_groups.iter().position(|p| *p == was) { - cfg.sidebar_collapsed_groups[at] = now.clone(); - } - }); - self.save_session(cx); - } + }); self.focus_active(window, cx); cx.notify(); } + /// Fold the group drawn under `key` (`None`: Ungrouped), or unfold it if + /// it is already shut. Stored with the workspace: a group folded away is a + /// statement about work you are done with for now, and every window onto + /// the workspace — and the next launch — should still show it shut. pub(crate) fn toggle_sidebar_group(&mut self, key: Option<&GroupKey>, cx: &mut Context) { - let id = collapse_key(key); - self.update_config(cx, |cfg| { - match cfg.sidebar_collapsed_groups.iter().position(|p| *p == id) { - Some(at) => { - cfg.sidebar_collapsed_groups.remove(at); - } - None => cfg.sidebar_collapsed_groups.push(id), - } - }); + self.edit_groups(cx, |groups| groups.toggle_folded(key)); } - fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec> { - let grouping = cx.global::().sidebar_grouping; + /// Rule 2: a tab whose cwd walks into a pinned folder joins that group. + /// + /// Run once per frame, before anything asks where a tab is drawn. Every + /// tab's [`EntryWatch`](crate::core::group_key::EntryWatch) is fed whether or not the tab is in a pinned group + /// — that is what lets a tab dragged out of a folder group while it is + /// still inside the folder stay out: the watch already knows it is + /// inside, so staying there is no entry. Only a tab not already kept in a + /// pinned group acts on an entry; a kept tab never leaves on its own. + /// + /// A tab is only looked at once its repo probe has answered. Before that + /// the question "is it inside" has half an answer — the cwd without the + /// repo home a worktree is matched by — and recording that half as where + /// the tab *was* would read the other half landing a frame later as an + /// entry, pulling every restored worktree tab into its group at launch. + /// + /// SSH panes are never looked at: their cwd is on another machine, and a + /// folder pinned in this workspace names a directory on this one. + /// + /// Also where a tab's auto-group hint is refreshed from the live answer + /// and, when that answer moved, sent up with the tab — so the next launch + /// draws the tab where it last was instead of in Ungrouped. + pub(crate) fn settle_sidebar_groups(&mut self, cx: &mut Context) { + let mut changed = false; + for tab in &self.tabs { + let before = tab.auto_group.borrow().clone(); + changed |= self.tab_auto_group(tab, cx) != before; + } + let Some(cache) = cx.try_global::() else { + if changed { + self.save_session(cx); + } + return; + }; + let mut joined = changed; + for tab in &self.tabs { + let Some(leaf) = tab.pane.first_leaf() else { + continue; + }; + let Some(view) = leaf.terminal() else { + continue; + }; + let view = view.read(cx); + if view.remote_context().is_some() { + continue; + } + let Some(cwd) = view.git_status_cwd() else { + continue; + }; + let Some(home) = cache.known_repo_for(view.host_id(), cwd) else { + continue; + }; + let inside = self.sidebar_groups.folder_for(Some(cwd), home.as_deref()); + let mut watch = tab.folder_watch.get(); + let entered = watch.observe(inside); + tab.folder_watch.set(watch); + let kept = tab + .group + .get() + .is_some_and(|g| self.sidebar_groups.contains(g)); + if let Some(id) = entered + && !kept + { + tab.group.set(Some(id)); + joined = true; + } + } + if joined { + self.save_session(cx); + } + } + + /// The auto group tab `tab` resolves to (rule 3): the host for an SSH + /// pane, otherwise the repo its cwd is in, otherwise none (Ungrouped). + /// + /// Remembered on the tab, and the memory is what answers while there is + /// nothing to go on — a probe in flight, or a pane (a WSL one, say) whose + /// paths no probe reaches — so a tab does not bounce through Ungrouped + /// between one answer and the next. + fn tab_auto_group(&self, tab: &Tab, cx: &gpui::App) -> Option { + let resolved = tab.pane.first_leaf().and_then(|leaf| { + let view = leaf.terminal()?.read(cx); + let host = ssh_host(view.remote_context().as_ref()); + let known = view.git_status_cwd().and_then(|cwd| { + cx.try_global::()? + .known_repo_for(view.host_id(), cwd) + }); + auto_key(host.as_deref(), known) + }); + if let Some(key) = resolved { + *tab.auto_group.borrow_mut() = key; + } + tab.auto_group.borrow().clone() + } + + /// Where each tab is drawn, in `self.tabs` order: its pinned group, its + /// auto group, or `None` for Ungrouped (or for the flat list below the + /// pinned groups, with auto grouping off). + pub(crate) fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec> { + let auto_grouping = cx.global::().sidebar_auto_grouping; self.tabs .iter() .map(|tab| { - // "No grouping" means no headers, full stop. A custom group - // says where a tab goes, not that a box may be drawn when the - // user asked for none — and letting one through would leave - // the sidebar showing that group beside a "Scratch" holding - // everything else, which is two headers more than the setting - // asked for. The key stays on the tab, so turning grouping - // back on brings it straight back. - if grouping == SidebarGrouping::None { - return None; - } - // A stated group outranks anything the cwd says. Without this - // the repo probe would drag a hand-placed tab back home on - // the very next frame, and no amount of clicking would keep - // it where it was put. - let stated = tab.sidebar_group.borrow().clone(); - if stated.as_ref().is_some_and(GroupKey::is_custom) { - return stated; - } - let resolved = tab.pane.first_leaf().and_then(|leaf| { - let view = leaf.terminal()?.read(cx); - match view.git_status_cwd() { - Some(cwd) => { - let known = cx - .global::() - .known_repo_for(view.host_id(), cwd); - resolved_group(grouping, known, cwd) - } - // A native SSH pane never gets a `git_status_cwd`: - // its paths are on a machine no `Host` reaches, so - // nothing probes them (#891). Its shell still says - // where it is, though, and that is enough for the - // folder fallback. - None => unprobed_group(grouping, &view.native_ssh_cwd()?), - } - }); - if let Some(group) = resolved { - *tab.sidebar_group.borrow_mut() = group; - } - tab.sidebar_group.borrow().clone() + // Resolved even with auto grouping off, so the remembered + // answer is warm the moment it is turned back on. + let auto = self.tab_auto_group(tab, cx); + place(tab.group.get(), &self.sidebar_groups, auto_grouping, auto) }) .collect() } @@ -1938,7 +2591,7 @@ impl Tty7App { return (0..self.tabs.len()).collect(); } let keys = self.sidebar_group_keys(cx); - sidebar_sections(&keys) + sidebar_sections(&keys, &self.sidebar_groups) .into_iter() .flat_map(|s| s.tabs) .collect() @@ -1955,81 +2608,199 @@ impl Tty7App { } } - /// Which group a tab about to be spawned in `cwd` belongs to, when the - /// repo probe for that directory has already landed. A bare `None` means - /// the cache never looked; `Some` is the group [`resolved_group`] reached - /// — the repo home, the cwd itself under repo-or-directory grouping, or - /// `Some(None)` for Scratch. + /// Where a tab about to be spawned in `cwd` goes. /// - /// A tab's group otherwise starts empty and only fills in once its shell - /// has started and reported a cwd, which parks every new tab in the - /// scratch group at the bottom of the sidebar until then. A tab spawned - /// from one already sitting in a repo inherits a warm cache, so seeding - /// it here lands the tab in its group on the first frame. - pub(crate) fn spawn_group( - &self, - cwd: Option<&Path>, - cx: &gpui::App, - ) -> Option> { - // A tab spawned from one sitting in a custom group joins it, and does - // so before the cwd is consulted at all — the cwd says nothing about - // a group the user stated by hand. - // - // Every caller here spawns from the active tab (the two that start - // from a named tab activate it first), so that is the one to inherit - // from. Without this, ⌘T inside a folded custom group would draw - // nothing but the header's count going up by one: the new tab would - // land in a repo group somewhere else, and the fold would hide it. - if let Some(stated) = self + /// A tab spawned from one kept in a pinned group joins it, before the cwd + /// is consulted at all — the cwd says nothing about a group the user + /// stated by hand. Every caller here spawns from the active tab (the ones + /// that start from a named tab activate it first), so that is the one to + /// inherit from. Without this, ⌘T inside a folded group would draw + /// nothing but the header's count going up by one. + /// + /// Otherwise a cwd inside a pinned folder joins that folder's group, and + /// the auto group is seeded from the repo cache when its probe for that + /// directory has already landed. A tab's auto group otherwise starts + /// empty and only fills in once its shell has reported a cwd, which parks + /// every new tab in Ungrouped at the bottom of the sidebar until then; a + /// tab spawned from one already in a repo inherits a warm cache, so this + /// lands it in its group on the first frame. + pub(crate) fn spawn_group(&self, cwd: Option<&Path>, cx: &gpui::App) -> SpawnPlace { + let kept = self .tabs .get(self.active) - .and_then(|t| t.sidebar_group.borrow().clone()) - .filter(GroupKey::is_custom) - { - return Some(Some(stated)); + .and_then(|t| t.group.get()) + .filter(|g| self.sidebar_groups.contains(*g)); + let known = cwd.and_then(|cwd| { + let host = self + .window_workspace(cx) + .as_ref() + .map_or(crate::ui::host_ops::HostId::LOCAL, |ws| ws.target.host_id()); + cx.try_global::()?.known_repo_for(host, cwd) + }); + let entered = cwd.and_then(|cwd| { + let home = known.clone().flatten(); + self.sidebar_groups.folder_for(Some(cwd), home.as_deref()) + }); + SpawnPlace { + group: kept.or(entered), + auto: auto_key(None, known), } - let cwd = cwd?; - let host = self - .window_workspace(cx) - .as_ref() - .map_or(crate::ui::host_ops::HostId::LOCAL, |ws| ws.target.host_id()); - let known = cx.try_global::()?.known_repo_for(host, cwd); - resolved_group(cx.global::().sidebar_grouping, known, cwd) } } -/// The group a probed cwd resolves to under `grouping`: the repo home when -/// the cache found one, otherwise Scratch — or the cwd itself under -/// repo-or-directory grouping, so a shell in a plain folder still gets a -/// header. `known` is the cache's three-valued answer; a probe that never -/// ran resolves to `None`, no decision, and the tab keeps whatever group it -/// already has rather than bouncing through Scratch mid-probe. -fn resolved_group( - grouping: SidebarGrouping, - known: Option>, - cwd: &Path, -) -> Option> { - Some(match known? { - Some(root) => Some(GroupKey::Repo(root)), - None if grouping == SidebarGrouping::RepoOrDirectory => { - Some(GroupKey::Repo(cwd.to_path_buf())) - } - None => None, +/// A header that can be picked up, and which of the two orders it belongs to. +#[derive(Clone, Copy)] +enum HeaderSlot { + /// Slot `n` among the pinned headers, above the divider. + Pinned(usize), + /// Slot `n` among the auto headers, below it. + Auto(usize), +} + +/// One item of a group header's menu, acting on the app when it is chosen. +fn menu_item( + label: L10nKey, + app: &gpui::WeakEntity, + act: impl Fn(&mut Tty7App, &mut Window, &mut Context) + 'static, +) -> PopupMenuItem { + let app = app.clone(); + PopupMenuItem::new(t(label)).on_click(move |_, window, cx| { + let _ = app.update(cx, |this, cx| act(this, window, cx)); }) } -/// The group for a cwd no repo probe can ever run in — a native SSH pane's. -/// "Never probed" would leave such a tab in Scratch for good, so it is read -/// as a settled "no repo": repo-or-directory grouping files it under the -/// folder, the same as a local shell in a plain directory, and repo grouping -/// leaves it in Scratch. A remote repo therefore groups by the folder the -/// shell is in, not its root — there is nothing to ask for the root. +/// A pinned group's header menu. Everything about a kept group is the +/// user's to change: its name, the folder it keeps, and whether it is kept at +/// all. Deleting closes nothing — its tabs go back to auto grouping. /// -/// The key is the bare path, the same as every other derived group: a remote -/// `/home/ubuntu` and a local one share a header, as two remote workspaces' -/// identical paths already would. -fn unprobed_group(grouping: SidebarGrouping, cwd: &Path) -> Option> { - resolved_group(grouping, Some(None), cwd) +/// "Set Folder…" asks the system picker, which browses this computer, so it +/// is only offered for a workspace on it; "Use Current Tab's Folder" works +/// anywhere, since the tab's cwd is on the workspace's own host. +fn pinned_header_menu( + menu: gpui_component::menu::PopupMenu, + id: GroupId, + has_folder: bool, + local: bool, + app: &gpui::WeakEntity, + window: &Window, + cx: &gpui::App, +) -> gpui_component::menu::PopupMenu { + let current = app + .upgrade() + .and_then(|a| a.read(cx).active_tab_folder(window, cx)); + let mut menu = menu.min_w(px(200.)).item(menu_item( + L10nKey::SidebarRenameGroup, + app, + move |this, w, cx| this.start_group_rename(id, w, cx), + )); + if local { + menu = menu.item(menu_item( + L10nKey::SidebarSetGroupFolder, + app, + move |this, _, cx| this.pick_group_folder(id, cx), + )); + } + menu = menu.item( + menu_item( + L10nKey::SidebarUseCurrentTabFolder, + app, + move |this, w, cx| { + if let Some(folder) = this.active_tab_folder(w, cx) { + this.set_group_folder(id, Some(folder), cx); + } + }, + ) + .disabled(current.is_none()), + ); + if has_folder { + menu = menu.item(menu_item( + L10nKey::SidebarClearGroupFolder, + app, + move |this, _, cx| this.set_group_folder(id, None, cx), + )); + } + menu = menu + .separator() + .item(menu_item( + L10nKey::SidebarGroupNewTab, + app, + move |this, w, cx| this.new_tab_in_group(GroupKey::Pinned(id), w, cx), + )) + .separator(); + if has_folder { + menu = menu.item(menu_item( + L10nKey::SidebarUnpinGroup, + app, + move |this, _, cx| this.delete_group(id, cx), + )); + } + menu.item(menu_item( + L10nKey::SidebarDeleteGroup, + app, + move |this, _, cx| this.delete_group(id, cx), + )) +} + +/// An auto group's header menu: pin it, or open a tab in it. Nothing else — +/// an auto group is derived, so there is nothing about it to edit until it +/// is pinned, and pinning is always an explicit act. +fn auto_header_menu( + menu: gpui_component::menu::PopupMenu, + key: AutoKey, + app: &gpui::WeakEntity, +) -> gpui_component::menu::PopupMenu { + let pin = key.clone(); + let mut menu = menu.min_w(px(200.)).item(menu_item( + L10nKey::SidebarPinGroup, + app, + move |this, _, cx| this.pin_auto_group(pin.clone(), cx), + )); + // A host group has no directory to open a tab in, and no way to open a + // shell on the host that would not guess at how the others got there. + if let AutoKey::Repo(_) = &key { + menu = menu.item(menu_item( + L10nKey::SidebarGroupNewTab, + app, + move |this, w, cx| this.new_tab_in_group(GroupKey::Auto(key.clone()), w, cx), + )); + } + menu +} + +/// Where a tab about to be spawned goes, worked out before it exists — see +/// [`Tty7App::spawn_group`]. +pub(crate) struct SpawnPlace { + group: Option, + auto: Option>, +} + +impl SpawnPlace { + /// Put `tab` where this says. + pub(crate) fn seat(&self, tab: &Tab) { + if let Some(id) = self.group { + tab.group.set(Some(id)); + } + if let Some(auto) = &self.auto { + *tab.auto_group.borrow_mut() = auto.clone(); + } + } +} + +/// The host an SSH pane is on, when it is one. +/// +/// A native SSH pane and a shell that has ssh'd onward from a local prompt +/// are grouped alike, by the target they name: in neither case does a `Host` +/// of ours reach the far side, so neither has a repo to probe — the first +/// reports a remote cwd that names nothing here, the second only the local +/// directory the `ssh` was typed in, which says nothing about where its shell +/// now is. The host is the one thing about either that is certain. A WSL pane +/// is not an SSH one: its distro is on this machine, and it keeps the group it +/// had. +fn ssh_host(remote: Option<&crate::daemon::protocol::RemoteContext>) -> Option { + use crate::daemon::protocol::RemoteKind; + remote + .filter(|r| matches!(r.kind, RemoteKind::Ssh | RemoteKind::NativeSsh)) + .map(|r| r.target.clone()) } #[derive(Debug, PartialEq)] @@ -2039,38 +2810,70 @@ struct Section { tabs: Vec, } -fn sidebar_sections(keys: &[Option]) -> Vec
{ - let mut group_order: Vec<&GroupKey> = Vec::new(); - for k in keys.iter().flatten() { - if !group_order.contains(&k) { - group_order.push(k); +impl Section { + fn pinned(&self) -> Option { + self.key.as_ref().and_then(GroupKey::pinned) + } +} + +/// The sidebar's sections, top to bottom: every pinned group in the user's +/// order — an empty one too, since a kept group stays until it is deleted — +/// then the auto groups in the order their first tab appears, then +/// Ungrouped. +/// +/// Ungrouped only draws a header beside other groups below the divider. With +/// no auto group to set it apart from — grouping off, or nothing resolved +/// yet — it is just the list, and a header over the whole of it would be a +/// label on nothing. The same goes for a sidebar with no groups at all, which +/// comes out as one headerless section holding every tab. +fn sidebar_sections(keys: &[Option], groups: &WorkspaceGroups) -> Vec
{ + let members = |key: &GroupKey| -> Vec { + (0..keys.len()) + .filter(|&i| keys[i].as_ref() == Some(key)) + .collect() + }; + let mut auto_order: Vec<&AutoKey> = Vec::new(); + for k in keys.iter().flatten().filter_map(GroupKey::auto) { + if !auto_order.contains(&k) { + auto_order.push(k); } } - if group_order.is_empty() { + let rest: Vec = (0..keys.len()).filter(|&i| keys[i].is_none()).collect(); + if groups.pinned.is_empty() && auto_order.is_empty() { return vec![Section { key: None, name: None, - tabs: (0..keys.len()).collect(), + tabs: rest, }]; } - let names = section_names(&group_order); - let mut sections: Vec
= group_order + let pinned = pinned_names(&groups.pinned); + let auto = auto_names(&auto_order); + let mut sections: Vec
= groups + .pinned .iter() - .zip(names) - .map(|(key, name)| Section { - key: Some((*key).clone()), - name: Some(name), - tabs: (0..keys.len()) - .filter(|&i| keys[i].as_ref() == Some(*key)) - .collect(), + .zip(pinned) + .map(|(g, name)| { + let key = GroupKey::Pinned(g.id); + Section { + tabs: members(&key), + key: Some(key), + name: Some(name), + } }) .collect(); - let scratch: Vec = (0..keys.len()).filter(|&i| keys[i].is_none()).collect(); - if !scratch.is_empty() { + sections.extend(auto_order.iter().zip(auto).map(|(k, name)| { + let key = GroupKey::Auto((*k).clone()); + Section { + tabs: members(&key), + key: Some(key), + name: Some(name), + } + })); + if !rest.is_empty() { sections.push(Section { key: None, - name: Some(t(L10nKey::SidebarScratchGroup).to_string()), - tabs: scratch, + name: (!auto_order.is_empty()).then(|| t(L10nKey::SidebarUngroupedGroup).to_string()), + tabs: rest, }); } sections @@ -2078,6 +2881,7 @@ fn sidebar_sections(keys: &[Option]) -> Vec
{ fn reordered_rows( keys: &[Option], + groups: &WorkspaceGroups, group: &Option, visible: &[usize], from: usize, @@ -2093,7 +2897,7 @@ fn reordered_rows( members.insert(at, moved); let mut out: Vec = Vec::with_capacity(keys.len()); - for g in sidebar_sections(keys).iter().map(|s| &s.key) { + for g in sidebar_sections(keys, groups).iter().map(|s| &s.key) { if g == group { out.extend_from_slice(&members); } else { @@ -2103,16 +2907,17 @@ fn reordered_rows( Some(out) } -fn regrouped_order( - keys: &[Option], - from: &GroupKey, - to: &GroupKey, -) -> Option> { +/// The tab order that moves auto group `from` to where auto group `to` is. +/// +/// Auto groups are ordered by where their first tab sits, so moving one is +/// moving its tabs. Pinned groups keep an order of their own and take no part: +/// their tabs follow the auto groups' here, where they draw no differently. +fn regrouped_order(keys: &[Option], from: &AutoKey, to: &AutoKey) -> Option> { if from == to { return None; } - let mut order: Vec<&GroupKey> = Vec::new(); - for k in keys.iter().flatten() { + let mut order: Vec<&AutoKey> = Vec::new(); + for k in keys.iter().flatten().filter_map(GroupKey::auto) { if !order.contains(&k) { order.push(k); } @@ -2122,38 +2927,80 @@ fn regrouped_order( let moved = order.remove(fi); order.insert(ti, moved); + let auto_of = |i: usize| keys[i].as_ref().and_then(GroupKey::auto); let mut out: Vec = Vec::with_capacity(keys.len()); for g in &order { - out.extend((0..keys.len()).filter(|&i| keys[i].as_ref() == Some(*g))); + out.extend((0..keys.len()).filter(|&i| auto_of(i) == Some(*g))); } - out.extend((0..keys.len()).filter(|&i| keys[i].is_none())); + out.extend((0..keys.len()).filter(|&i| auto_of(i).is_none())); Some(out) } -/// What each section's header reads, in `keys` order. +/// The order of all pinned groups after the header in visible slot `from` is +/// dropped on slot `to`, as indices into `all`. `shown` is the pinned groups +/// on screen, in order — a search can hide some, and those keep their places. +fn reordered_pinned( + all: &[GroupId], + shown: &[GroupId], + from: usize, + to: usize, +) -> Option> { + let (&moved, &anchor) = (shown.get(from)?, shown.get(to)?); + if moved == anchor { + return None; + } + let mut order: Vec = all.to_vec(); + order.retain(|g| *g != moved); + let at = order.iter().position(|g| *g == anchor)? + usize::from(to > from); + order.insert(at, moved); + order + .iter() + .map(|g| all.iter().position(|a| a == g)) + .collect() +} + +/// What each pinned group's header reads, in list order: the name the user +/// gave it, or its folder's last component — lengthened, like a repo's, when +/// two folders end the same way. A label group always has a given name. +fn pinned_names(pinned: &[PinnedGroup]) -> Vec { + let unnamed: Vec = pinned + .iter() + .filter(|g| g.given_name().is_none()) + .filter_map(|g| g.folder_path().map(Path::to_path_buf)) + .collect(); + let mut from_folders = group_names(&unnamed.iter().collect::>()).into_iter(); + pinned + .iter() + .map(|g| match (g.given_name(), g.folder_path()) { + (Some(name), _) => name.to_string(), + (None, Some(_)) => from_folders + .next() + .expect("group_names answers one name per folder"), + (None, None) => String::new(), + }) + .collect() +} + +/// What each auto group's header reads, in `keys` order. /// /// Only repo roots go through [`group_names`]. They are paths, so two of them /// can perfectly well end in the same component and need lengthening until -/// they differ. A custom group's name is the name the user typed — there is -/// nothing to shorten and nothing to disambiguate against, and running it -/// through the path splitter would chop a name containing a `/` into -/// components and then "disambiguate" it by growing a prefix that was never -/// there. -fn section_names(keys: &[&GroupKey]) -> Vec { +/// they differ. A host group reads its `user@host` target as it is. +fn auto_names(keys: &[&AutoKey]) -> Vec { let roots: Vec<&PathBuf> = keys .iter() .filter_map(|k| match k { - GroupKey::Repo(p) => Some(p), - GroupKey::Custom(_) => None, + AutoKey::Repo(p) => Some(p), + AutoKey::SshHost(_) => None, }) .collect(); let mut disambiguated = group_names(&roots).into_iter(); keys.iter() .map(|k| match k { - GroupKey::Repo(_) => disambiguated + AutoKey::Repo(_) => disambiguated .next() .expect("group_names answers one name per root"), - GroupKey::Custom(name) => name.clone(), + AutoKey::SshHost(host) => host.clone(), }) .collect() } @@ -2240,13 +3087,13 @@ mod fold_tests { app.sidebar_slots.borrow()[i].size.height > px(0.) } - /// Put tab `i` in a directory and tell the cache that directory is the - /// repo `root`, so the sidebar's own probe has a real answer to act on. - /// Both halves are needed: the probe reads the tab's cwd and looks it up - /// in the cache, and either one missing makes it return "no decision", - /// which would leave every group below untouched and every assertion - /// about overwriting vacuous. - fn plant_repo(app: &Tty7App, i: usize, cwd: &str, root: &str, cx: &mut gpui::App) { + /// Put tab `i` in a directory and tell the cache that directory is in the + /// repo `root`, whose home is `home` — the same as `root` unless the + /// checkout is a linked worktree. Both halves are needed: the sidebar + /// reads the tab's cwd and looks it up in the cache, and either one + /// missing makes it return "no decision", which would leave every group + /// below untouched and every assertion about moving vacuous. + fn plant(app: &Tty7App, i: usize, cwd: &str, root: &str, home: &str, cx: &mut gpui::App) { use crate::terminal::git_status::{GitStatusCache, RepoSnapshot}; use crate::ui::host_ops::HostId; @@ -2263,7 +3110,7 @@ mod fold_tests { &cwd, Some(RepoSnapshot { root: PathBuf::from(root), - home: PathBuf::from(root), + home: PathBuf::from(home), branch: "main".into(), counts: Some((0, 0)), }), @@ -2271,24 +3118,70 @@ mod fold_tests { }); } - /// The rule the whole feature rests on. A group the user stated by hand - /// is not the probe's to change: without this the cwd probe would drag a - /// hand-placed tab back into its repo on the very next frame, and no - /// amount of clicking would keep it where it was put. + fn plant_repo(app: &Tty7App, i: usize, cwd: &str, root: &str, cx: &mut gpui::App) { + plant(app, i, cwd, root, root, cx); + } + + /// Put tab `i` somewhere the cache knows is in no repo at all. + fn plant_plain(app: &Tty7App, i: usize, cwd: &str, cx: &mut gpui::App) { + use crate::terminal::git_status::GitStatusCache; + use crate::ui::host_ops::HostId; + + let cwd = PathBuf::from(cwd); + let leaf = app.tabs[i].pane.first_leaf().expect("test tab has a pane"); + leaf.terminal() + .expect("test pane is a terminal") + .update(cx, |view, _| { + view.set_git_status_cwd_for_test(Some(cwd.clone())) + }); + cx.update_global::(|cache, _| { + cache.finish_probe(HostId::LOCAL, &cwd, None); + }); + } + + fn repo(s: &str) -> Option { + Some(GroupKey::Auto(AutoKey::Repo(PathBuf::from(s)))) + } + + /// Pin a label group called `name`, answering its id. + fn label(app: &mut Tty7App, name: &str, cx: &mut Context) -> GroupId { + let group = PinnedGroup::label(name); + let id = group.id; + app.edit_groups(cx, |groups| groups.pinned.push(group)); + id + } + + /// Pin `folder`, answering the group's id. + fn folder(app: &mut Tty7App, folder: &str, cx: &mut Context) -> GroupId { + app.pin_folder(PathBuf::from(folder), cx); + app.sidebar_groups + .pinned + .iter() + .find(|g| g.folder.as_deref() == Some(folder)) + .expect("just pinned") + .id + } + + /// Rule 1. A tab kept in a pinned group is not the probe's to move: + /// without this the cwd would drag a hand-placed tab back into its repo + /// on the very next frame, and no amount of clicking would keep it where + /// it was put. /// - /// Tab 1 is the control. It carries no stated group, so the same probe - /// that must leave tab 0 alone has to move tab 1 — otherwise this test - /// would pass just as well with the probe switched off entirely. + /// Tab 1 is the control. It is kept nowhere, so the same probe that must + /// leave tab 0 alone has to file tab 1 — otherwise this test would pass + /// just as well with the probe switched off entirely. #[gpui::test] - fn a_probe_moves_a_derived_group_and_never_a_stated_one(cx: &mut TestAppContext) { + fn a_probe_files_an_auto_tab_and_never_a_pinned_one(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); - app.update(&mut vcx, |app, cx| { - *app.tabs[0].sidebar_group.borrow_mut() = GroupKey::custom("work"); + let work = app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); for i in 0..2 { plant_repo(app, i, "/w/probed/sub", "/w/probed", cx); } cx.notify(); + work }); vcx.run_until_parked(); @@ -2296,51 +3189,44 @@ mod fold_tests { let keys = app.sidebar_group_keys(cx); assert_eq!( keys[0], - GroupKey::custom("work"), - "the stated group survived a probe that had a real answer" + Some(GroupKey::Pinned(work)), + "the pinned group survived a probe that had a real answer" ); assert_eq!( keys[1], - Some(GroupKey::Repo(PathBuf::from("/w/probed"))), - "and that same probe did move the tab that only had a derived one" - ); - assert_eq!( - *app.tabs[0].sidebar_group.borrow(), - GroupKey::custom("work"), - "the tab itself was not written over either" + repo("/w/probed"), + "and that same probe did file the tab that was kept nowhere" ); }); } - /// The way out. A stated group locks the probe out of that tab, so if - /// clearing it did not hand the tab back, a tab moved into a custom - /// group could never follow its cwd again. + /// Dropping a tab below the divider is the way out of a pinned group: the + /// cwd decides again. #[gpui::test] - fn clearing_a_stated_group_hands_the_tab_back_to_the_probe(cx: &mut TestAppContext) { + fn a_tab_handed_back_to_auto_follows_its_cwd_again(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - app.update(&mut vcx, |app, cx| { + let (work, dragged) = app.update(&mut vcx, |app, cx| { plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); - app.set_tab_group(0, GroupKey::custom("work"), cx); + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); + (work, app.tabs[0].tree_id.get()) + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + assert_eq!(app.sidebar_group_keys(cx)[0], Some(GroupKey::Pinned(work))); + }); + + app.update(&mut vcx, |app, cx| { + app.regroup_tab(dragged, reorder::Regroup::ToAuto, cx) }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { assert_eq!( app.sidebar_group_keys(cx)[0], - GroupKey::custom("work"), - "stated, so the probe is locked out" - ); - }); - - app.update(&mut vcx, |app, cx| app.set_tab_group(0, None, cx)); - vcx.run_until_parked(); - - app.update(&mut vcx, |app, cx| { - assert_eq!( - app.sidebar_group_keys(cx)[0], - Some(GroupKey::Repo(PathBuf::from("/w/probed"))), - "cleared, so the probe takes the tab back over" + repo("/w/probed"), + "dropped below the divider, so the probe takes the tab back" ); }); } @@ -2354,9 +3240,11 @@ mod fold_tests { let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); let dragged = app.update(&mut vcx, |app, _| app.tabs[2].tree_id.get()); - app.update_in(&mut vcx, |app, window, cx| { + let work = app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); app.close_tab(0, window, cx); - app.regroup_tab(dragged, GroupKey::custom("work").expect("non-blank"), cx); + app.regroup_tab(dragged, reorder::Regroup::Into(work), cx); + work }); vcx.run_until_parked(); @@ -2367,42 +3255,45 @@ mod fold_tests { .find(|t| t.tree_id.get() == dragged) .expect("the dragged tab is still open"); assert_eq!( - *moved.sidebar_group.borrow(), - GroupKey::custom("work"), + moved.group.get(), + Some(work), "the tab that was picked up is the tab that moved" ); assert!( app.tabs .iter() .filter(|t| t.tree_id.get() != dragged) - .all(|t| t.sidebar_group.borrow().is_none()), + .all(|t| t.group.get().is_none()), "and no bystander was regrouped in its place" ); }); } - /// ⌘T inside a custom group has to land in it. Otherwise the new tab + /// ⌘T inside a pinned group has to land in it. Otherwise the new tab /// goes wherever its cwd says, and if the group it was opened from is /// folded, the only thing that happens on screen is the header's count /// going up by one — the symptom #804 fixed for repo groups. #[gpui::test] - fn a_tab_spawned_inside_a_custom_group_joins_it(cx: &mut TestAppContext) { + fn a_tab_spawned_inside_a_pinned_group_joins_it(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - app.update(&mut vcx, |app, cx| { + let work = app.update(&mut vcx, |app, cx| { // A probe that would send the tab somewhere else if it were // consulted, so this cannot pass by there being no answer. plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); - app.set_tab_group(0, GroupKey::custom("work"), cx); + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); app.active = 0; + work }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let place = app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx); assert_eq!( - app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx), - Some(GroupKey::custom("work")), - "the stated group is inherited ahead of anything the cwd says" + place.group, + Some(work), + "the pinned group is inherited ahead of anything the cwd says" ); }); @@ -2410,159 +3301,387 @@ mod fold_tests { vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let place = app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx); + assert_eq!(place.group, None, "with nothing kept, nothing inherited"); assert_eq!( - app.spawn_group(Some(&PathBuf::from("/w/probed/sub")), cx), - Some(Some(GroupKey::Repo(PathBuf::from("/w/probed")))), - "and with nothing stated the cwd decides again" + place.auto, + Some(Some(AutoKey::Repo(PathBuf::from("/w/probed")))), + "and the cwd seeds the auto group from the warm cache" ); }); } - /// A group is only the set of tabs that name it, so a rename reaching - /// half of them would not leave a half-renamed group — it would leave - /// two groups. + /// A tab opened in a pinned folder is filed there before its shell has + /// said a word — from the cwd it was opened in. #[gpui::test] - fn renaming_a_group_moves_every_tab_in_it_at_once(cx: &mut TestAppContext) { - let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); - let work = GroupKey::custom("work").expect("non-blank"); + fn a_tab_spawned_in_a_pinned_folder_joins_it(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - app.update_in(&mut vcx, |app, window, cx| { - app.set_tab_group(0, Some(work.clone()), cx); - app.set_tab_group(1, Some(work.clone()), cx); - app.set_tab_group(2, GroupKey::custom("other"), cx); - // Shut it, so the fold state has somewhere to be carried from. - app.toggle_sidebar_group(Some(&work), cx); - app.start_group_rename(work.clone(), window, cx); + app.update(&mut vcx, |app, cx| { + let tty7 = folder(app, "/w/tty7", cx); + let place = app.spawn_group(Some(&PathBuf::from("/w/tty7/src")), cx); + assert_eq!(place.group, Some(tty7)); + let place = app.spawn_group(Some(&PathBuf::from("/w/else")), cx); + assert_eq!(place.group, None); + }); + } + + /// A rename lands on the group itself: its tabs point at it by id, so + /// nothing about them has to change. + #[gpui::test] + fn renaming_a_group_keeps_its_tabs_in_it(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let work = app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); + app.set_tab_group(1, Some(work), cx); + app.start_group_rename(work, window, cx); app.group_rename .as_ref() .expect("the box is up") .input .update(cx, |state, cx| state.set_value("urgent", window, cx)); app.commit_group_rename(window, cx); - }); - vcx.run_until_parked(); - - app.update(&mut vcx, |app, cx| { - let renamed = GroupKey::custom("urgent"); - assert_eq!(*app.tabs[0].sidebar_group.borrow(), renamed); - assert_eq!(*app.tabs[1].sidebar_group.borrow(), renamed, "both, in one"); - assert_eq!( - *app.tabs[2].sidebar_group.borrow(), - GroupKey::custom("other"), - "and nothing outside the group moved" - ); - assert_eq!( - cx.global::().sidebar_collapsed_groups, - vec!["custom:urgent".to_string()], - "a shut group that is renamed stays shut, under its new key" - ); - }); - } - - /// Clearing the box and dismissing it must not drop a whole group's - /// worth of tabs into Scratch. - #[gpui::test] - fn a_blank_rename_leaves_the_group_alone(cx: &mut TestAppContext) { - let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); - let work = GroupKey::custom("work").expect("non-blank"); - - app.update_in(&mut vcx, |app, window, cx| { - app.set_tab_group(0, Some(work.clone()), cx); - app.start_group_rename(work.clone(), window, cx); - app.group_rename - .as_ref() - .expect("the box is up") - .input - .update(cx, |state, cx| state.set_value(" ", window, cx)); - app.commit_group_rename(window, cx); + work }); vcx.run_until_parked(); app.update(&mut vcx, |app, _| { + assert_eq!(app.pinned_group_names(), vec![(work, "urgent".to_string())]); + assert!(app.tabs.iter().all(|t| t.group.get() == Some(work))); + }); + } + + /// Clearing the box and dismissing it must not leave a label group with + /// no name to draw; a folder group, which has its folder's name to fall + /// back on, goes back to reading it. + #[gpui::test] + fn a_blank_rename_keeps_a_label_and_resets_a_folder(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); + let tty7 = folder(app, "/w/tty7", cx); + app.edit_groups(cx, |groups| { + groups.get_mut(tty7).expect("pinned").name = Some("mine".into()) + }); + for id in [work, tty7] { + app.start_group_rename(id, window, cx); + app.group_rename + .as_ref() + .expect("the box is up") + .input + .update(cx, |state, cx| state.set_value(" ", window, cx)); + app.commit_group_rename(window, cx); + } assert_eq!( - *app.tabs[0].sidebar_group.borrow(), - Some(work), - "an empty name means 'keep the one you had'" + app.pinned_group_names(), + vec![(work, "work".to_string()), (tty7, "tty7".to_string())], + "the label kept its name; the folder went back to its own" ); }); } /// The placeholder only has to be unique — the box opens selected, so - /// the first keystroke replaces it. But two groups with one name are one - /// group, so a second new group must not land on top of the first. + /// the first keystroke replaces it. But two headers with one name are two + /// groups nobody can tell apart. #[gpui::test] fn a_second_new_group_does_not_land_on_the_first(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); app.update_in(&mut vcx, |app, window, cx| { app.new_tab_group(0, window, cx); + app.commit_group_rename(window, cx); app.new_tab_group(1, window, cx); + app.commit_group_rename(window, cx); }); vcx.run_until_parked(); app.update(&mut vcx, |app, _| { - let names = app.custom_group_names(); + let names = app.pinned_group_names(); assert_eq!(names.len(), 2, "two groups, not one shared by both tabs"); - assert_ne!(names[0], names[1]); + assert_ne!(names[0].1, names[1].1); + assert_ne!(app.tabs[0].group.get(), app.tabs[1].group.get()); }); } - /// "No grouping" means no headers, full stop. Letting a custom group - /// through would draw its box beside a "Scratch" holding everything else - /// — two headers more than the setting asked for. The key stays on the - /// tab, so turning grouping back on brings it straight back. + /// "Auto grouping off" hides the groups the sidebar works out, and + /// Ungrouped's header with them — but a pinned group is the user's, and + /// still stands. #[gpui::test] - fn no_grouping_hides_a_custom_group_without_forgetting_it(cx: &mut TestAppContext) { - let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + fn auto_grouping_off_hides_auto_groups_and_keeps_pinned_ones(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); - app.update(&mut vcx, |app, cx| { - *app.tabs[0].sidebar_group.borrow_mut() = GroupKey::custom("work"); + let work = app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + app.set_tab_group(0, Some(work), cx); + plant_repo(app, 1, "/w/alpha", "/w/alpha", cx); + plant_plain(app, 2, "/tmp", cx); let mut cfg = cx.global::().clone(); - cfg.sidebar_grouping = SidebarGrouping::None; + cfg.sidebar_auto_grouping = false; cx.set_global(cfg); cx.notify(); + work }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let keys = app.sidebar_group_keys(cx); + assert_eq!(keys, vec![Some(GroupKey::Pinned(work)), None, None]); + let sections = sidebar_sections(&keys, &app.sidebar_groups); assert_eq!( - app.sidebar_group_keys(cx), - vec![None, None], - "nothing is grouped, so nothing draws a header" - ); - assert_eq!( - *app.tabs[0].sidebar_group.borrow(), - GroupKey::custom("work"), - "but the tab still remembers where it was put" + sections.iter().map(|s| s.name.clone()).collect::>(), + vec![Some("work".into()), None], + "the pinned header, then a flat list with no header over it" ); }); app.update(&mut vcx, |_, cx| { let mut cfg = cx.global::().clone(); - cfg.sidebar_grouping = SidebarGrouping::Repo; + cfg.sidebar_auto_grouping = true; cx.set_global(cfg); cx.notify(); }); vcx.run_until_parked(); app.update(&mut vcx, |app, cx| { + let keys = app.sidebar_group_keys(cx); + assert_eq!( + keys, + vec![Some(GroupKey::Pinned(work)), repo("/w/alpha"), None] + ); + let sections = sidebar_sections(&keys, &app.sidebar_groups); + assert_eq!( + sections.last().and_then(|s| s.name.clone()), + Some("Ungrouped".into()), + "beside an auto group, the rest is Ungrouped again" + ); + }); + } + + /// Rule 2: a tab walking into a pinned folder joins it. + #[gpui::test] + fn a_tab_walking_into_a_pinned_folder_joins_it(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let probed = app.update(&mut vcx, |app, cx| { + plant_plain(app, 0, "/w/elsewhere", cx); + plant_plain(app, 1, "/w/elsewhere", cx); + let probed = folder(app, "/w/probed", cx); + cx.notify(); + probed + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs[0].group.get(), Some(probed), "walked in: joined"); + assert_eq!(app.tabs[1].group.get(), None, "stayed out: did not"); assert_eq!( app.sidebar_group_keys(cx)[0], - GroupKey::custom("work"), - "turning grouping back on brings the group straight back" + Some(GroupKey::Pinned(probed)), + "and the pinned folder beats the repo root it sits in" ); }); } + /// Nested folders: the deepest one a tab is inside wins, so pinning a + /// monorepo and a package in it files the package's tabs under the + /// package. + #[gpui::test] + fn nested_pinned_folders_file_a_tab_under_the_deepest(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let (mono, pkg) = app.update(&mut vcx, |app, cx| { + let mono = folder(app, "/w/mono", cx); + let pkg = folder(app, "/w/mono/pkg", cx); + plant_repo(app, 0, "/w/mono/pkg/src", "/w/mono", cx); + plant_repo(app, 1, "/w/mono/docs", "/w/mono", cx); + cx.notify(); + (mono, pkg) + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), Some(pkg)); + assert_eq!(app.tabs[1].group.get(), Some(mono)); + }); + } + + /// A linked worktree lives outside its main checkout, but its repo home + /// is that checkout — so pinning the repo keeps its worktrees too. + #[gpui::test] + fn a_worktree_joins_the_folder_its_repo_home_is_pinned_as(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let tty7 = app.update(&mut vcx, |app, cx| { + let tty7 = folder(app, "/w/tty7", cx); + plant( + app, + 0, + "/tmp/wt/feature/src", + "/tmp/wt/feature", + "/w/tty7", + cx, + ); + cx.notify(); + tty7 + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), Some(tty7)); + }); + } + + /// The edge the whole rule turns on. A tab dragged out of a folder group + /// while it is still in the folder has been told where to go; it must not + /// be pulled straight back, and only walking out and in again rejoins it. + #[gpui::test] + fn a_tab_dragged_out_is_not_pulled_back_until_it_re_enters(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let probed = app.update(&mut vcx, |app, cx| { + let probed = folder(app, "/w/probed", cx); + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + probed + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + assert_eq!( + app.tabs[0].group.get(), + Some(probed), + "joined on the way in" + ); + app.set_tab_group(0, None, cx); + }); + for _ in 0..3 { + app.update(&mut vcx, |_, cx| cx.notify()); + vcx.run_until_parked(); + } + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs[0].group.get(), None, "still inside: stays out"); + plant_plain(app, 0, "/w/elsewhere", cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs[0].group.get(), None, "left: nothing to join"); + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), Some(probed), "re-entered: joined"); + }); + } + + /// A tab restored from the machine tree was filed by whoever had it + /// last. Sitting in a folder at launch is not walking into it. + #[gpui::test] + fn a_restored_tab_is_not_pulled_in_by_where_it_already_is(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + app.tabs[0] + .folder_watch + .set(crate::core::group_key::EntryWatch::baseline()); + folder(app, "/w/probed", cx); + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), None); + }); + } + + /// Deleting a group closes nothing: its tabs go back to auto grouping. + #[gpui::test] + fn deleting_a_group_returns_its_tabs_to_auto_grouping(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + let work = app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + plant_repo(app, 0, "/w/alpha", "/w/alpha", cx); + app.set_tab_group(0, Some(work), cx); + app.set_tab_group(1, Some(work), cx); + work + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| app.delete_group(work, cx)); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| { + assert_eq!(app.tabs.len(), 2, "no tab was closed"); + assert!(app.sidebar_groups.pinned.is_empty()); + assert!(app.tabs.iter().all(|t| t.group.get().is_none())); + assert_eq!(app.sidebar_group_keys(cx)[0], repo("/w/alpha")); + }); + } + + /// Pinning an auto group keeps the tabs in it now, keeps its fold, and — + /// for a repo — keeps its folder, so tabs walking in later join too. + #[gpui::test] + fn pinning_an_auto_group_keeps_its_tabs_and_its_folder(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + + app.update(&mut vcx, |app, cx| { + plant_repo(app, 0, "/w/alpha", "/w/alpha", cx); + plant_repo(app, 1, "/w/alpha/src", "/w/alpha", cx); + plant_repo(app, 2, "/w/beta", "/w/beta", cx); + app.toggle_sidebar_group(repo("/w/alpha").as_ref(), cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + app.pin_auto_group(AutoKey::Repo(PathBuf::from("/w/alpha")), cx) + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + let group = app.sidebar_groups.pinned.first().expect("pinned"); + assert_eq!(group.folder.as_deref(), Some("/w/alpha")); + assert!(group.collapsed, "the fold came along"); + assert!(app.sidebar_groups.auto_collapsed.is_empty()); + assert_eq!(app.tabs[0].group.get(), Some(group.id)); + assert_eq!(app.tabs[1].group.get(), Some(group.id)); + assert_eq!(app.tabs[2].group.get(), None, "beta stays auto"); + }); + } + + /// A pinned group with no tabs is still drawn — it is kept until it is + /// deleted — while an auto group with none simply is not there. + #[gpui::test] + fn an_empty_pinned_group_still_has_a_section(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + let work = label(app, "work", cx); + let keys = app.sidebar_group_keys(cx); + let sections = sidebar_sections(&keys, &app.sidebar_groups); + assert_eq!(sections[0].key, Some(GroupKey::Pinned(work))); + assert!(sections[0].tabs.is_empty()); + }); + } + #[gpui::test] fn folding_a_group_takes_its_rows_off_the_sidebar(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); - let alpha = GroupKey::Repo(PathBuf::from("/w/alpha")); - let beta = GroupKey::Repo(PathBuf::from("/w/beta")); + let alpha = AutoKey::Repo(PathBuf::from("/w/alpha")); + let beta = AutoKey::Repo(PathBuf::from("/w/beta")); app.update(&mut vcx, |app, cx| { for (i, root) in [(0, &alpha), (1, &alpha), (2, &beta)] { - *app.tabs[i].sidebar_group.borrow_mut() = Some(root.clone()); + *app.tabs[i].auto_group.borrow_mut() = Some(root.clone()); } app.active = 2; cx.notify(); @@ -2576,33 +3695,34 @@ mod fold_tests { ); }); + let alpha_key = GroupKey::Auto(alpha.clone()); app.update(&mut vcx, |app, cx| { - app.toggle_sidebar_group(Some(&alpha), cx) + app.toggle_sidebar_group(Some(&alpha_key), cx) }); vcx.run_until_parked(); - app.update(&mut vcx, |app, cx| { + app.update(&mut vcx, |app, _| { assert!( !drawn(app, 0) && !drawn(app, 1), "the folded group's rows left no rectangle behind" ); assert!(drawn(app, 2), "the group next to it is untouched"); assert_eq!( - cx.global::().sidebar_collapsed_groups, - vec!["/w/alpha".to_string()], - "the fold is written where the next launch will read it" + app.sidebar_groups.auto_collapsed, + vec![alpha.clone()], + "the fold is kept with the workspace, where the next launch reads it" ); }); app.update(&mut vcx, |app, cx| { - app.toggle_sidebar_group(Some(&alpha), cx) + app.toggle_sidebar_group(Some(&alpha_key), cx) }); vcx.run_until_parked(); - app.update(&mut vcx, |app, cx| { + app.update(&mut vcx, |app, _| { assert!((0..3).all(|i| drawn(app, i)), "unfolding brings them back"); assert!( - cx.global::().sidebar_collapsed_groups.is_empty(), + app.sidebar_groups.auto_collapsed.is_empty(), "and takes the entry back out rather than piling up" ); }); @@ -2611,13 +3731,13 @@ mod fold_tests { #[gpui::test] fn a_search_outranks_a_fold(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); - let alpha = GroupKey::Repo(PathBuf::from("/w/alpha")); + let alpha = AutoKey::Repo(PathBuf::from("/w/alpha")); app.update(&mut vcx, |app, cx| { for i in 0..2 { - *app.tabs[i].sidebar_group.borrow_mut() = Some(alpha.clone()); + *app.tabs[i].auto_group.borrow_mut() = Some(alpha.clone()); } - app.toggle_sidebar_group(Some(&alpha), cx); + app.toggle_sidebar_group(Some(&GroupKey::Auto(alpha.clone())), cx); }); vcx.run_until_parked(); app.update(&mut vcx, |app, _| { @@ -2650,11 +3770,11 @@ mod fold_tests { #[gpui::test] fn a_fold_hides_the_active_row_too(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); - let alpha = GroupKey::Repo(PathBuf::from("/w/alpha")); + let alpha = GroupKey::Auto(AutoKey::Repo(PathBuf::from("/w/alpha"))); app.update(&mut vcx, |app, cx| { for i in 0..2 { - *app.tabs[i].sidebar_group.borrow_mut() = Some(alpha.clone()); + *app.tabs[i].auto_group.borrow_mut() = alpha.auto().cloned(); } app.active = 0; app.toggle_sidebar_group(Some(&alpha), cx); @@ -2675,6 +3795,252 @@ mod fold_tests { assert!((0..2).all(|i| drawn(app, i)), "unfolding brings both back"); }); } + /// The divider stands between kept and derived groups, so it is there + /// exactly when something is kept — and it is recorded, because a tab + /// dropped below it leaves its pinned group. + #[gpui::test] + fn the_divider_is_drawn_once_something_is_pinned(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + app.update(&mut vcx, |_, cx| cx.notify()); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert!(app.sidebar_divider.get().is_none(), "nothing kept, no line"); + }); + + app.update(&mut vcx, |app, cx| { + label(app, "work", cx); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, _| { + assert!(app.sidebar_divider.get().is_some(), "kept, so a line"); + }); + } + + /// An empty pinned group stays on screen, and takes drops like any other: + /// its block is recorded where a dragged tab looks for somewhere to land. + #[gpui::test] + fn an_empty_pinned_group_is_drawn_and_takes_drops(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let work = app.update(&mut vcx, |app, cx| label(app, "work", cx)); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + let landing = app.sidebar_group_slots.borrow(); + let block = landing + .iter() + .find(|(id, _)| *id == work) + .expect("the empty group was drawn"); + assert!(block.1.size.height > px(0.)); + }); + } + + /// Pinned headers are ordered by hand; a drag lands as a new order for + /// the list, not for the tabs. + #[gpui::test] + fn a_pinned_drag_reorders_the_groups_not_the_tabs(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update(&mut vcx, |app, cx| { + let (a, b, c) = ( + label(app, "a", cx), + label(app, "b", cx), + label(app, "c", cx), + ); + app.set_tab_group(0, Some(a), cx); + app.set_tab_group(1, Some(c), cx); + let tabs: Vec<_> = app.tabs.iter().map(|t| t.tree_id.get()).collect(); + app.apply_pinned_order(&[2, 0, 1], cx); + assert_eq!( + app.pinned_group_names() + .into_iter() + .map(|(id, _)| id) + .collect::>(), + vec![c, a, b] + ); + assert_eq!( + app.tabs.iter().map(|t| t.tree_id.get()).collect::>(), + tabs, + "the tabs stayed where they were" + ); + // An order that is not a permutation of the list changes nothing. + app.apply_pinned_order(&[0, 1], cx); + assert_eq!(app.sidebar_groups.pinned.len(), 3); + }); + } + + /// "New Tab" on a pinned group opens a tab in it — joining outright, + /// without waiting for a cwd to walk in, since a label group has nothing + /// to walk into. + #[gpui::test] + fn a_label_group_can_be_filled_from_its_header(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + let before = app.update(&mut vcx, |app, _| app.tabs.len()); + let work = app.update_in(&mut vcx, |app, window, cx| { + let work = label(app, "work", cx); + app.new_tab_in_group(GroupKey::Pinned(work), window, cx); + work + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + // A harness without a daemon may refuse to spawn; when it does + // open one, it has to be in the group. + if app.tabs.len() > before { + assert_eq!(app.tabs[app.active].group.get(), Some(work)); + } + }); + } + + /// Clearing a folder keeps the name the header was showing: a folder + /// group nobody renamed reads its folder's name, and would read nothing + /// once the folder is gone. + #[gpui::test] + fn clearing_a_folder_keeps_the_name_it_was_showing(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + let tty7 = folder(app, "/w/tty7", cx); + app.set_group_folder(tty7, None, cx); + let group = app.sidebar_groups.get(tty7).expect("still pinned"); + assert_eq!(group.folder, None); + assert_eq!(group.given_name(), Some("tty7")); + app.set_group_folder(tty7, Some(PathBuf::from("/w/else")), cx); + let group = app.sidebar_groups.get(tty7).expect("still pinned"); + assert_eq!(group.folder.as_deref(), Some("/w/else")); + assert_eq!(group.given_name(), Some("tty7"), "a set name stays"); + }); + } + /// A folder dropped from Finder is pinned; a file is let fall, and a + /// folder already pinned is not pinned twice. + #[gpui::test] + fn a_dropped_folder_is_pinned_and_a_dropped_file_is_not(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("notes.txt"); + std::fs::write(&file, "x").expect("write"); + + app.update(&mut vcx, |app, cx| { + let paths = [dir.path().to_path_buf(), file.clone()]; + app.pin_dropped_folders(&paths, cx); + app.pin_dropped_folders(&paths, cx); + let folders: Vec<_> = app + .sidebar_groups + .pinned + .iter() + .map(|g| g.folder.clone()) + .collect(); + assert_eq!( + folders, + vec![Some(dir.path().to_string_lossy().into_owned())] + ); + }); + } + + /// The palette's "New Group" makes an empty pinned group and opens its + /// name for typing. + #[gpui::test] + fn the_palette_makes_an_empty_group_ready_to_name(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update_in(&mut vcx, |app, window, cx| { + app.new_empty_group(window, cx); + let id = app.sidebar_groups.pinned[0].id; + assert_eq!(app.group_rename.as_ref().map(|r| r.group), Some(id)); + assert!(app.tabs.iter().all(|t| t.group.get().is_none())); + let kinds: Vec<_> = app + .palette_commands(window, cx) + .into_iter() + .map(|c| c.kind) + .collect(); + assert!(kinds.contains(&crate::ui::palette::CommandKind::NewGroup)); + assert!( + kinds.contains(&crate::ui::palette::CommandKind::OpenFolderAsGroup), + "a workspace on this computer can pick a folder" + ); + }); + } + + /// Groups that arrive from another window, or from the machine at launch, + /// are not folders this window's tabs walked into. A tab sitting in one — + /// here, one that was dragged out of it — stays where it is. + #[gpui::test] + fn groups_arriving_from_elsewhere_pull_no_tab_in(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + + app.update(&mut vcx, |app, cx| { + plant_repo(app, 0, "/w/probed/sub", "/w/probed", cx); + cx.notify(); + }); + vcx.run_until_parked(); + app.update(&mut vcx, |app, cx| { + let mut groups = WorkspaceGroups::default(); + groups + .pinned + .push(PinnedGroup::folder(Path::new("/w/probed"))); + app.adopt_sidebar_groups(groups, cx); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, _| { + assert_eq!(app.tabs[0].group.get(), None); + }); + } + + /// A tab restored from the tree is drawn in the auto group it last had + /// before any probe has answered — the launch no longer parks every tab + /// in Ungrouped — and the live answer, once it lands, wins and becomes + /// the new hint. A hint never outranks a pinned group. + #[gpui::test] + fn a_restored_tab_is_drawn_from_its_hint_until_a_probe_answers(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 2); + + app.update(&mut vcx, |app, cx| { + for i in 0..2 { + let pane = std::mem::replace(&mut app.tabs[i].pane, crate::ui::pane::Pane::Empty); + let tree = tty7_core::core::machine::Tab { + id: app.tabs[i].tree_id.get(), + name: None, + group: None, + last_auto: Some(AutoKey::Repo(PathBuf::from("/w/alpha"))), + root: tty7_core::core::machine::PaneNode::Leaf { pane: 1 }, + hibernated: false, + }; + app.tabs[i] = crate::ui::app::Tab::from_tree(&tree, pane); + } + let work = label(app, "work", cx); + app.tabs[1].group.set(Some(work)); + let keys = app.sidebar_group_keys(cx); + assert_eq!( + keys[0], + repo("/w/alpha"), + "drawn from the hint, no probe yet" + ); + assert_eq!( + keys[1], + Some(GroupKey::Pinned(work)), + "a pinned group outranks any hint" + ); + plant_repo(app, 0, "/w/beta/src", "/w/beta", cx); + cx.notify(); + }); + vcx.run_until_parked(); + + app.update(&mut vcx, |app, cx| { + assert_eq!( + app.sidebar_group_keys(cx)[0], + repo("/w/beta"), + "the probe wins" + ); + let (desired, _, _) = crate::ui::tree_sync::desired_tabs(app, cx); + assert_eq!( + desired[0].last_auto, + Some(AutoKey::Repo(PathBuf::from("/w/beta"))), + "and is what goes up as the next launch's hint" + ); + }); + } } #[cfg(test)] @@ -2685,24 +4051,17 @@ mod tests { PathBuf::from(s) } - /// The derived group keyed on repo root `s`. + /// The auto group keyed on repo root `s`. fn g(s: &str) -> GroupKey { - GroupKey::Repo(p(s)) + GroupKey::Auto(AutoKey::Repo(p(s))) } - /// The group the user named `s` by hand. - fn c(s: &str) -> GroupKey { - GroupKey::custom(s).expect("test names are not blank") + fn host(s: &str) -> GroupKey { + GroupKey::Auto(AutoKey::SshHost(s.into())) } - #[test] - fn the_scratch_group_folds_under_a_key_no_repo_can_take() { - assert_eq!(collapse_key(Some(&g("/w/repo"))), "/w/repo"); - assert_eq!( - collapse_key(None), - "", - "scratch has no root, so it is stored as the name that is not one" - ); + fn none() -> WorkspaceGroups { + WorkspaceGroups::default() } #[test] @@ -2731,64 +4090,56 @@ mod tests { assert_eq!(diff_click_cwd::(&cfg, None), None); } + /// Two machines' `/home/ubuntu` are two directories: each host gets a + /// group of its own, named for the host. #[test] - fn a_probed_non_repo_groups_by_folder_only_in_the_fallback_mode() { - let cwd = p("/w/plain"); + fn ssh_tabs_on_two_hosts_land_in_two_groups() { + let keys = [ + Some(host("ubuntu@alpha")), + Some(host("ubuntu@beta")), + Some(host("ubuntu@alpha")), + ]; + let sections = sidebar_sections(&keys, &none()); + let shape: Vec<(Option, Vec)> = + sections.into_iter().map(|s| (s.name, s.tabs)).collect(); assert_eq!( - resolved_group(SidebarGrouping::RepoOrDirectory, Some(None), &cwd), - Some(Some(g("/w/plain"))) - ); - assert_eq!( - resolved_group(SidebarGrouping::Repo, Some(None), &cwd), - Some(None), - "under Repo a probed non-repo still falls to Scratch" - ); - // Never probed: no decision in either mode, so the tab keeps the - // group it already has instead of bouncing through Scratch. - assert_eq!(resolved_group(SidebarGrouping::Repo, None, &cwd), None); - assert_eq!( - resolved_group(SidebarGrouping::RepoOrDirectory, None, &cwd), - None + shape, + vec![ + (Some("ubuntu@alpha".into()), vec![0, 2]), + (Some("ubuntu@beta".into()), vec![1]), + ] ); } #[test] - fn a_native_ssh_cwd_groups_by_its_remote_folder() { - let home = p("/home/ubuntu"); + fn a_shell_that_sshd_onward_groups_by_host_and_wsl_does_not() { + use crate::daemon::protocol::{RemoteContext, RemoteKind}; + let ctx = |kind| RemoteContext { + kind, + argv: vec![], + target: "u@h".into(), + }; assert_eq!( - unprobed_group(SidebarGrouping::RepoOrDirectory, &home), - Some(Some(g("/home/ubuntu"))) + ssh_host(Some(&ctx(RemoteKind::Ssh))).as_deref(), + Some("u@h") ); assert_eq!( - unprobed_group(SidebarGrouping::Repo, &home), - Some(None), - "under Repo there is no repo to find, so Scratch" + ssh_host(Some(&ctx(RemoteKind::NativeSsh))).as_deref(), + Some("u@h") ); - // Named by the last segment, like a local folder group. - let keys = [Some(g("/home/ubuntu")), None]; - let sections = sidebar_sections(&keys); - assert_eq!(sections[0].name.as_deref(), Some("ubuntu")); + assert_eq!(ssh_host(Some(&ctx(RemoteKind::Wsl))), None); + assert_eq!(ssh_host(None), None); } #[test] - fn a_known_repo_home_wins_over_the_folder_in_both_modes() { - for mode in [SidebarGrouping::Repo, SidebarGrouping::RepoOrDirectory] { - assert_eq!( - resolved_group(mode, Some(Some(p("/w/repo"))), &p("/w/repo/sub")), - Some(Some(g("/w/repo"))) - ); - } - } - - #[test] - fn sections_order_groups_by_first_appearance_scratch_last() { + fn sections_order_groups_by_first_appearance_ungrouped_last() { let keys = vec![ Some(g("/w/beta")), None, Some(g("/w/alpha")), Some(g("/w/beta")), ]; - let sections = sidebar_sections(&keys); + let sections = sidebar_sections(&keys, &none()); let shape: Vec<(Option, Option, Vec)> = sections .into_iter() .map(|s| (s.key, s.name, s.tabs)) @@ -2798,16 +4149,63 @@ mod tests { vec![ (Some(g("/w/beta")), Some("beta".into()), vec![0, 3]), (Some(g("/w/alpha")), Some("alpha".into()), vec![2]), - (None, Some("Scratch".into()), vec![1]), + (None, Some("Ungrouped".into()), vec![1]), ] ); - let flat = sidebar_sections(&[None, None]); + let flat = sidebar_sections(&[None, None], &none()); assert_eq!(flat.len(), 1); assert_eq!(flat[0].name, None); assert_eq!(flat[0].tabs, vec![0, 1]); } + /// Pinned groups come first, in the order the user gave them, whatever + /// order their tabs are in — and an empty one keeps its place. + #[test] + fn pinned_groups_lead_in_their_own_order_empty_or_not() { + let mut groups = none(); + let (a, b, c) = ( + PinnedGroup::label("a"), + PinnedGroup::folder(Path::new("/w/b")), + PinnedGroup::label("c"), + ); + groups.pinned = vec![a.clone(), b.clone(), c.clone()]; + let keys = vec![ + Some(g("/w/r")), + Some(GroupKey::Pinned(c.id)), + Some(GroupKey::Pinned(a.id)), + None, + ]; + let sections = sidebar_sections(&keys, &groups); + let shape: Vec<(Option, Vec)> = + sections.into_iter().map(|s| (s.name, s.tabs)).collect(); + assert_eq!( + shape, + vec![ + (Some("a".into()), vec![2]), + (Some("b".into()), vec![]), + (Some("c".into()), vec![1]), + (Some("r".into()), vec![0]), + (Some("Ungrouped".into()), vec![3]), + ] + ); + } + + /// With pinned groups and nothing auto-grouped below them, the rest is + /// just the list — a header reading "Ungrouped" over all of it would be a + /// label on nothing. + #[test] + fn below_pinned_groups_the_rest_needs_no_header_on_its_own() { + let mut groups = none(); + let work = PinnedGroup::label("work"); + groups.pinned = vec![work.clone()]; + let keys = vec![Some(GroupKey::Pinned(work.id)), None]; + let sections = sidebar_sections(&keys, &groups); + assert_eq!(sections.len(), 2); + assert_eq!(sections[1].name, None); + assert_eq!(sections[1].tabs, vec![1]); + } + /// The badge on a row and the tab ⌘N opens are two readings of one order, /// taken in two places. Grouping makes them diverge from `self.tabs` /// order — tab 3 sits in the second row here — so if they are ever read @@ -2821,7 +4219,7 @@ mod tests { Some(g("/w/beta")), ]; // What `visual_tab_order` returns for a left tab bar. - let order: Vec = sidebar_sections(&keys) + let order: Vec = sidebar_sections(&keys, &none()) .into_iter() .flat_map(|s| s.tabs) .collect(); @@ -2852,14 +4250,14 @@ mod tests { ]; let alpha = Some(g("/w/alpha")); assert_eq!( - reordered_rows(&keys, &alpha, &[0, 2], 0, 1), + reordered_rows(&keys, &none(), &alpha, &[0, 2], 0, 1), Some(vec![2, 0, 1, 3]) ); assert_eq!( - reordered_rows(&keys, &alpha, &[0, 2], 1, 0), + reordered_rows(&keys, &none(), &alpha, &[0, 2], 1, 0), Some(vec![2, 0, 1, 3]) ); - assert_eq!(reordered_rows(&keys, &alpha, &[0, 2], 1, 1), None); + assert_eq!(reordered_rows(&keys, &none(), &alpha, &[0, 2], 1, 1), None); } #[test] @@ -2867,11 +4265,26 @@ mod tests { let keys = vec![Some(g("/w/a")), Some(g("/w/a")), Some(g("/w/a"))]; let a = Some(g("/w/a")); assert_eq!( - reordered_rows(&keys, &a, &[0, 2], 0, 1), + reordered_rows(&keys, &none(), &a, &[0, 2], 0, 1), Some(vec![1, 2, 0]) ); } + /// A pinned group is where a tab was put, and rows move within it the + /// same way they move within a repo group. + #[test] + fn rows_reorder_inside_a_pinned_group_too() { + let mut groups = none(); + let work = PinnedGroup::label("work"); + groups.pinned = vec![work.clone()]; + let w = Some(GroupKey::Pinned(work.id)); + let keys = vec![w.clone(), Some(g("/w/beta")), w.clone()]; + assert_eq!( + reordered_rows(&keys, &groups, &w, &[0, 2], 0, 1), + Some(vec![2, 0, 1]) + ); + } + #[test] fn regrouped_order_moves_the_group_into_the_target_slot() { let keys = vec![ @@ -2881,12 +4294,13 @@ mod tests { Some(g("/w/alpha")), Some(g("/w/gamma")), ]; + let r = |s: &str| AutoKey::Repo(p(s)); assert_eq!( - regrouped_order(&keys, &g("/w/gamma"), &g("/w/alpha")), + regrouped_order(&keys, &r("/w/gamma"), &r("/w/alpha")), Some(vec![4, 0, 3, 2, 1]) ); assert_eq!( - regrouped_order(&keys, &g("/w/alpha"), &g("/w/gamma")), + regrouped_order(&keys, &r("/w/alpha"), &r("/w/gamma")), Some(vec![2, 4, 0, 3, 1]) ); } @@ -2894,74 +4308,48 @@ mod tests { #[test] fn regrouped_order_ignores_self_and_unknown_roots() { let keys = vec![Some(g("/w/alpha")), Some(g("/w/beta"))]; - assert_eq!(regrouped_order(&keys, &g("/w/alpha"), &g("/w/alpha")), None); - assert_eq!(regrouped_order(&keys, &g("/w/gone"), &g("/w/beta")), None); - assert_eq!(regrouped_order(&keys, &g("/w/alpha"), &g("/w/gone")), None); + let r = |s: &str| AutoKey::Repo(p(s)); + assert_eq!(regrouped_order(&keys, &r("/w/alpha"), &r("/w/alpha")), None); + assert_eq!(regrouped_order(&keys, &r("/w/gone"), &r("/w/beta")), None); + assert_eq!(regrouped_order(&keys, &r("/w/alpha"), &r("/w/gone")), None); } - /// A custom name is the name the user typed. Running it through the path + #[test] + fn reordered_pinned_moves_a_header_among_the_ones_shown() { + let ids: Vec = (0..4).map(|_| GroupId::new()).collect(); + // All four shown: dragging the first onto the third. + assert_eq!(reordered_pinned(&ids, &ids, 0, 2), Some(vec![1, 2, 0, 3])); + assert_eq!(reordered_pinned(&ids, &ids, 3, 0), Some(vec![3, 0, 1, 2])); + assert_eq!(reordered_pinned(&ids, &ids, 1, 1), None); + // A search hides the second; it keeps its place. + let shown = [ids[0], ids[2], ids[3]]; + assert_eq!(reordered_pinned(&ids, &shown, 2, 0), Some(vec![3, 0, 1, 2])); + } + + /// A label is the name the user typed. Running it through the path /// splitter would chop one containing a `/` into components and then /// "shorten" it to the tail, so `work/urgent` would print as `urgent`. #[test] - fn a_custom_name_is_never_shortened_the_way_a_path_is() { - let keys = vec![Some(c("work/urgent")), Some(g("/home/u/tty7"))]; - let sections = sidebar_sections(&keys); - assert_eq!( - sections.iter().map(|s| s.name.clone()).collect::>(), - vec![Some("work/urgent".into()), Some("tty7".into())] - ); - } - - /// Two repo roots ending in the same component grow a prefix until they - /// differ. A custom group sitting between them takes no part in that: it - /// is not a path, so there is no prefix to grow and nothing to collide - /// with as far as the path splitter is concerned. - #[test] - fn a_custom_group_sits_out_the_path_disambiguation() { - let keys = vec![ - Some(g("/home/u/work/app")), - Some(c("scratch")), - Some(g("/home/u/fork/app")), + fn a_label_is_never_shortened_the_way_a_path_is() { + let pinned = vec![ + PinnedGroup::label("work/urgent"), + PinnedGroup::folder(Path::new("/home/u/tty7")), ]; - let sections = sidebar_sections(&keys); - assert_eq!( - sections.iter().map(|s| s.name.clone()).collect::>(), - vec![ - Some("work/app".into()), - Some("scratch".into()), - Some("fork/app".into()) - ], - "the two roots still disambiguate against each other" - ); + assert_eq!(pinned_names(&pinned), vec!["work/urgent", "tty7"]); } - /// A custom group named after a real repo prints the same header as that - /// repo, but the two are different groups and must stay apart — in the - /// section list and, through `collapse_key`, in the fold state. + /// Two folders ending in the same component grow a prefix until they + /// differ, the way two repo roots do; a named one sits that out. #[test] - fn a_custom_group_never_merges_with_the_repo_it_is_named_after() { - let keys = vec![Some(g("/w/tty7")), Some(c("tty7"))]; - let sections = sidebar_sections(&keys); - assert_eq!(sections.len(), 2, "two groups, not one"); - assert_eq!(sections[0].tabs, vec![0]); - assert_eq!(sections[1].tabs, vec![1]); - assert_ne!( - collapse_key(sections[0].key.as_ref()), - collapse_key(sections[1].key.as_ref()), - "folding one must not fold the other" - ); - } - - /// A custom group is where a tab was put, and rows move within it the - /// same way they move within a repo group. - #[test] - fn rows_reorder_inside_a_custom_group_too() { - let keys = vec![Some(c("work")), Some(g("/w/beta")), Some(c("work"))]; - let work = Some(c("work")); - assert_eq!( - reordered_rows(&keys, &work, &[0, 2], 0, 1), - Some(vec![2, 0, 1]) - ); + fn pinned_folders_disambiguate_like_repo_roots() { + let mut named = PinnedGroup::folder(Path::new("/home/u/other/app")); + named.name = Some("mine".into()); + let pinned = vec![ + PinnedGroup::folder(Path::new("/home/u/work/app")), + named, + PinnedGroup::folder(Path::new("/home/u/fork/app")), + ]; + assert_eq!(pinned_names(&pinned), vec!["work/app", "mine", "fork/app"]); } #[test] diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index ddb712f7..bc8f4805 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -18,8 +18,7 @@ use crate::core::actions::{ SelectWorkspace6, SelectWorkspace7, SelectWorkspace8, SelectWorkspace9, SplitDown, SplitRight, TogglePalette, }; -use crate::core::config::{Config, RightPanelTab, SidebarGrouping}; -use crate::core::group_key::GroupKey; +use crate::core::config::{Config, RightPanelTab}; use crate::core::shells::DetectedShell; use crate::daemon::protocol::ShellSpec; use crate::ui::app::{SpawnWhere, TILE_GLYPH, TILE_SIZE, Tab, Tty7App, tile_trailing_inset}; @@ -1822,36 +1821,34 @@ impl Tty7App { // Where this tab sits, and where it could be put instead. // // Laid out flat rather than behind a "Move to Group ▸" submenu: there - // are never many custom groups — they are maintained by hand — so a - // submenu would cost a second click to show two or three items, and + // are never many pinned groups — they are kept by hand — so a submenu + // would cost a second click to show two or three items, and // `PopupMenu::submenu` wants a `&mut Context` this function does not // have. The label above them says what the block is. // - // Hidden entirely when grouping is off. The sidebar draws no headers - // then, so "move to group" would name something the user cannot see. - if cx.global::().sidebar_grouping != SidebarGrouping::None { - let stated = this + // No way back to auto grouping here: that is a drag below the divider, + // the one place in the sidebar where "not kept by hand" is drawn. + // Offered only with the tabs in the sidebar for the same reason — a + // group is something the sidebar draws, and "move to group" from the + // top tab bar would name something the user cannot see. + if cx.global::().tab_bar_position == crate::core::config::TabBarPosition::Left { + let here = this .tabs .get(index) - .and_then(|t| t.sidebar_group.borrow().clone()); - let here = match &stated { - Some(GroupKey::Custom(name)) => Some(name.clone()), - _ => None, - }; + .and_then(|t| t.group.get()) + .filter(|g| this.sidebar_groups.contains(*g)); menu = menu .separator() .item(PopupMenuItem::label(t(L10nKey::SidebarMoveToGroup))); - for name in this.custom_group_names() { + for (id, name) in this.pinned_group_names() { menu = menu.item( - PopupMenuItem::new(name.clone()) - .checked(here.as_deref() == Some(name.as_str())) + PopupMenuItem::new(name) + .checked(here == Some(id)) .on_click({ let app = app.clone(); - let name = name.clone(); move |_, _window, cx| { - let key = GroupKey::custom(&name); - let _ = - app.update(cx, |this, cx| this.set_tab_group(index, key, cx)); + let _ = app + .update(cx, |this, cx| this.set_tab_group(index, Some(id), cx)); } }), ); @@ -1862,16 +1859,6 @@ impl Tty7App { let _ = app.update(cx, |this, cx| this.new_tab_group(index, window, cx)); } })); - // Only worth offering once there is something to undo. A tab that - // never left its derived group is already grouped automatically. - if here.is_some() { - menu = menu.item(PopupMenuItem::new(t(L10nKey::SidebarAutoGroup)).on_click({ - let app = app.clone(); - move |_, _window, cx| { - let _ = app.update(cx, |this, cx| this.set_tab_group(index, None, cx)); - } - })); - } } let in_repo = this.tab_is_in_repo(index, window, cx); diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index b98e70b7..5fc8931c 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -11,7 +11,7 @@ use tty7_core::core::machine::{ use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk}; use tty7_core::host::HostId; -use crate::core::group_key::GroupKey; +use crate::core::group_key::{AutoKey, GroupId, WorkspaceGroups}; use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, WorkspaceStore}; use crate::ui::app::Tty7App; use crate::ui::i18n::{L10nKey, t}; @@ -75,7 +75,8 @@ fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { pub(crate) struct DesiredTab { pub id: TabId, pub name: Option, - pub group: Option, + pub group: Option, + pub last_auto: Option, pub root: DesiredNode, /// The tab is asleep, and `root` is the layout it will wake into — the /// same pane ids the machine already holds, none of them running. @@ -156,7 +157,12 @@ pub(crate) fn desired_tabs( out.push(DesiredTab { id, name: tab.name.clone(), - group: tab.sidebar_group.borrow().as_ref().map(GroupKey::encode), + // As the tab has it, even when this window does not know the + // group: a window whose copy of the groups has not landed yet + // would otherwise send every tab it holds back to auto grouping. + // A group that really is gone was already cleared by the machine. + group: tab.group.get(), + last_auto: tab.auto_group.borrow().clone(), root, hibernated: tab.asleep_layout().is_some(), }); @@ -333,6 +339,7 @@ fn seeded_records(desired: &[DesiredTab], live: impl Fn(u64) -> bool) -> Vec, pub active: Option, + pub groups: WorkspaceGroups, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -575,11 +582,12 @@ fn create_tab( name: want.name.clone(), }); } - if want.group.is_some() { + if want.group.is_some() || want.last_auto.is_some() { ops.push(ControlRequest::TabSetGroup { workspace, tab: want.id, - group: want.group.clone(), + group: want.group, + last_auto: want.last_auto.clone(), }); } if want.hibernated { @@ -594,7 +602,8 @@ fn create_tab( TreeTab { id: want.id, name: want.name.clone(), - sidebar_group: want.group.clone(), + group: want.group, + last_auto: want.last_auto.clone(), root, hibernated: want.hibernated, }, @@ -643,12 +652,17 @@ fn reconcile_tab( name: want.name.clone(), }); } - if tab.sidebar_group != want.group { - tab.sidebar_group = want.group.clone(); + // One op for both: the hint rides with the membership, so a tab + // whose repo changed costs one message, and the machine never holds + // a hint and a group that came from two different moments. + if tab.group != want.group || tab.last_auto != want.last_auto { + tab.group = want.group; + tab.last_auto = want.last_auto.clone(); ops.push(ControlRequest::TabSetGroup { workspace, tab: want.id, - group: want.group.clone(), + group: want.group, + last_auto: want.last_auto.clone(), }); } // Ahead of anything structural. Going to sleep changes nothing else @@ -1048,6 +1062,9 @@ struct WsState { /// `start_prime`, which spends it instead of the generated name, and /// cleared by `finish_prime` once the machine has confirmed a name. chosen_name: Option, + /// An edit to the workspace's sidebar groups made before this window's + /// pull landed, sent on when it does — see [`push_groups`]. + unsent_groups: Option, /// Whether this window has already been told why it opened empty. /// /// The retry is as quiet as the failure was, so a window whose machine @@ -1075,6 +1092,7 @@ impl Default for WsState { rehydrate_attempts: 0, then_open: Vec::new(), chosen_name: None, + unsent_groups: None, said_why_empty: false, } } @@ -1552,6 +1570,7 @@ fn primed(ws: Workspace, arrival: Arrival) -> (WsMirror, Option, Arrival WsMirror { tabs: ws.tabs, active: ws.active_tab, + groups: ws.groups, }, ws.name, arrival, @@ -1591,6 +1610,7 @@ fn finish_prime( return; } }; + adopt_groups(cx, client_ws); let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( @@ -1611,6 +1631,87 @@ fn finish_prime( app.update(cx, |app, cx| sync_window(app, cx)); } +/// Sends a window's edit to its workspace's sidebar groups up to the machine. +/// +/// Not part of the diff [`sync_window`] runs, on purpose. Groups have nothing +/// a window can know better than the machine: a fresh window holds none at +/// all, and diffing that emptiness against the tree would unpin every group +/// the workspace had. So they only ever go up as what they are — an edit +/// someone just made — and come down from every pull and every +/// `GroupsChanged`. +/// +/// Queued with the tab ops rather than fired beside them, and ahead of the +/// ones the same edit raises (the caller pushes this before it saves): a tab +/// filed into a group that was just made must not reach the machine before +/// the group does, or `workspace_set_groups`, which hands tabs naming unknown +/// groups back to auto grouping, would see a tab pointing at nothing. +/// +/// A window whose pull has not landed parks the edit, and the pull sends it +/// on instead of overwriting it — otherwise a pin made in the first moments +/// of a window's life would be undone by the tree that arrives after it. +pub(crate) fn push_groups(cx: &mut App, client_ws: WorkspaceId, groups: WorkspaceGroups) { + if !cx.has_global::() { + return; + } + let machine_ws = tree_workspace_id(cx, client_ws); + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + match &mut state.sync { + SyncPhase::Primed(mirror) => { + if mirror.groups == groups { + return; + } + mirror.groups = groups.clone(); + state.queue.push_back(ControlRequest::WorkspaceSetGroups { + workspace: machine_ws, + groups, + }); + pump(cx, client_ws); + } + SyncPhase::Unprimed { .. } => state.unsent_groups = Some(groups), + } +} + +/// Hands the window the sidebar groups a pull just brought in — or, when the +/// window edited them before the pull landed, sends that edit up instead. +/// +/// Deferred: a pull can land while the window is itself mid-update (its own +/// `sync_window` started it), and the window is only updated once that is +/// over. Nothing in between can push the window's stale copy, because groups +/// only ever go up from an edit — see [`push_groups`]. +fn adopt_groups(cx: &mut App, client_ws: WorkspaceId) { + let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { + return; + }; + if let Some(unsent) = state.unsent_groups.take() { + push_groups(cx, client_ws, unsent); + return; + } + cx.defer(move |cx| { + let groups = match cx + .default_global::() + .windows + .get(&client_ws) + .map(|s| &s.sync) + { + Some(SyncPhase::Primed(mirror)) => mirror.groups.clone(), + _ => return, + }; + if !cx.has_global::() { + return; + } + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade()) + else { + return; + }; + app.update(cx, |app, cx| app.adopt_sidebar_groups(groups, cx)); + }); +} + fn pump(cx: &mut App, client_ws: WorkspaceId) { let host = WorkspaceStore::host_of(cx, client_ws); let client = tree_control_for(cx, host); @@ -1690,7 +1791,8 @@ pub(crate) fn session_from_tree( .map(|(tab, view)| SessionTab { name: tab.name.clone(), tree_id: Some(tab.id), - sidebar_group: tab.sidebar_group.as_deref().and_then(GroupKey::decode), + group: tab.group, + last_auto: tab.last_auto.clone(), pane: session_pane_from_node(&tab.root, panes), hibernated: tab.hibernated, asleep_view: tab.hibernated.then_some(view), @@ -2276,6 +2378,7 @@ fn layout_of( let mirror = WsMirror { tabs: ws.tabs.clone(), active: ws.active_tab, + groups: ws.groups.clone(), }; let session = session_from_tree(ws, &machine.panes); Ok((machine, mirror, session)) @@ -2356,6 +2459,7 @@ fn settle_hydration( state.said_why_empty = false; dirty }; + adopt_groups(cx, client_ws); let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) else { @@ -2621,11 +2725,20 @@ fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { t.name = name.clone(); true } - LayoutDelta::TabRegrouped { tab, group } => { + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + } => { let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { return false; }; - t.sidebar_group = group.clone(); + t.group = *group; + t.last_auto = last_auto.clone(); + true + } + LayoutDelta::GroupsChanged { groups } => { + mirror.groups = groups.clone(); true } LayoutDelta::TabMoved { tab, to } => { @@ -2779,13 +2892,29 @@ impl Tty7App { } true } - LayoutDelta::TabRegrouped { tab, group } => { + LayoutDelta::TabRegrouped { + tab, + group, + last_auto, + } => { if let Some(index) = index_of(&self.tabs, *tab) { - *self.tabs[index].sidebar_group.borrow_mut() = - group.as_deref().and_then(GroupKey::decode); + let gui = &self.tabs[index]; + gui.group.set(*group); + // Another window's hint only fills a gap. Where this + // window has an answer of its own it keeps it: the two + // probe the same cwd and agree, and letting each + // overwrite the other would bounce a disagreement between + // them for as long as it lasted. + if gui.auto_group.borrow().is_none() { + *gui.auto_group.borrow_mut() = last_auto.clone(); + } } true } + LayoutDelta::GroupsChanged { groups } => { + self.adopt_sidebar_groups(groups.clone(), cx); + true + } LayoutDelta::TabMoved { tab, to } => { if let Some(from) = index_of(&self.tabs, *tab) { let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get()); @@ -2887,7 +3016,10 @@ impl Tty7App { let gui = &mut self.tabs[index]; gui.pane = pane; gui.name = tab.name.clone(); - *gui.sidebar_group.borrow_mut() = tab.sidebar_group.as_deref().and_then(GroupKey::decode); + gui.group.set(tab.group); + if gui.auto_group.borrow().is_none() { + *gui.auto_group.borrow_mut() = tab.last_auto.clone(); + } self.maximized = None; true } @@ -3546,12 +3678,17 @@ mod tests { dirty: false, priming: false, }; - let primed_with = - |tabs: Vec| SyncPhase::Primed(WsMirror { tabs, active: None }); + let primed_with = |tabs: Vec| { + SyncPhase::Primed(WsMirror { + tabs, + ..Default::default() + }) + }; let a_tab = || TreeTab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }; @@ -3621,10 +3758,7 @@ mod tests { // The mirror the emptied window would have been diffed against, // already drained the way `save_session` drains it on the way // out of a window that has no tabs left. - state.sync = SyncPhase::Primed(WsMirror { - tabs: vec![], - active: None, - }); + state.sync = SyncPhase::Primed(WsMirror::default()); state .queue .push_back(ControlRequest::TabClose { workspace: ws, tab }); @@ -4061,7 +4195,7 @@ mod tests { state.rehydrate = None; state.sync = SyncPhase::Primed(WsMirror { tabs: vec![TreeTab::leaf(1), TreeTab::leaf(2)], - active: None, + ..Default::default() }); } assert!( @@ -4190,19 +4324,22 @@ mod tests { TreeTab { id: put_up, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }, TreeTab { id: failed, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 2 }, hibernated: false, }, ], active: Some(put_up), + ..Default::default() }); // Keeps whatever the sync queues where the test can read it: // with no link, `pump` would otherwise clear the queue and drop @@ -4282,19 +4419,22 @@ mod tests { TreeTab { id: theirs.0, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 11 }, hibernated: false, }, TreeTab { id: theirs.1, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 12 }, hibernated: false, }, ], active: Some(theirs.0), + ..Default::default() }); state.informed = true; // Keeps whatever the switch queues where the test can read it. @@ -4365,7 +4505,7 @@ mod tests { }; let advanced = WsMirror { tabs: vec![TreeTab::leaf(7)], - active: None, + ..Default::default() }; { let state = cx @@ -4425,6 +4565,7 @@ mod tests { id, name: None, group: None, + last_auto: None, root, hibernated: false, } @@ -4455,7 +4596,7 @@ mod tests { for (m, d) in mirror.tabs.iter().zip(desired) { assert_eq!(m.id, d.id); assert_eq!(m.name, d.name); - assert_eq!(m.sidebar_group, d.group); + assert_eq!(m.group, d.group); assert_eq!(m.root, d.root.to_pane_node()); } } @@ -4919,7 +5060,8 @@ mod tests { let mut named = tab(id, leaf(1)); named.name = Some("build".into()); - named.group = Some("/repo".into()); + let group = GroupId::new(); + named.group = Some(group); let want = vec![named]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( @@ -4933,7 +5075,8 @@ mod tests { ControlRequest::TabSetGroup { workspace: ws, tab: id, - group: Some("/repo".into()), + group: Some(group), + last_auto: None, }, ] ); @@ -5089,7 +5232,8 @@ mod tests { let tree_tab = TreeTab { id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }; @@ -5110,7 +5254,8 @@ mod tests { tab: TreeTab { id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Split { axis: TreeAxis::Vertical, ratio: 0.5, @@ -5180,7 +5325,8 @@ mod tests { tabs: vec![TreeTab { id: tab_id, name: Some("build".into()), - sidebar_group: Some("/repo".into()), + group: Some(GroupId::new()), + last_auto: Some(AutoKey::Repo("/work".into())), root: PaneNode::Split { axis: TreeAxis::Vertical, ratio: 0.3, @@ -5223,6 +5369,11 @@ mod tests { "the daemon tab's identity rides along" ); assert_eq!(tab.name.as_deref(), Some("build")); + assert_eq!( + tab.last_auto, + Some(AutoKey::Repo("/work".into())), + "the auto-group hint rides along, so the tab is drawn in its group at once" + ); let SessionPane::Split { ratio, a, b, .. } = &tab.pane else { panic!("the split survives the lowering"); }; @@ -5276,7 +5427,8 @@ mod tests { tabs: vec![TreeTab { id: tab_id, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 7 }, hibernated: false, }], @@ -5306,7 +5458,8 @@ mod tests { tabs: vec![TreeTab { id: TabId::new(), name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }], @@ -5536,14 +5689,16 @@ mod tests { TreeTab { id: awake, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 1 }, hibernated: false, }, TreeTab { id: sleeping, name: None, - sidebar_group: None, + group: None, + last_auto: None, root: PaneNode::Leaf { pane: 2 }, hibernated: true, }, From e6009636b58e95965f96f67b44443473f78fcfd7 Mon Sep 17 00:00:00 2001 From: migege Date: Sun, 27 Sep 2026 09:29:30 +0800 Subject: [PATCH 13/20] feat(tabs): reorder the active tab from the keyboard (MoveTabLeft / MoveTabRight) (#974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tabs): MoveTabLeft / MoveTabRight — keyboard tab reordering The drag that reorders a tab now has a keyboard form: the active tab trades places with its neighbour in visual order, wrapping past either end. Ships unbound like the pane-swap pair; bindable from config.json and the Keybindings page, and listed in the palette and the docs. * fix(tabs): keep a keyboard tab move inside its sidebar group On a left tab bar the move stepped along the flat visual order, so a step out of a group reordered self.tabs without moving anything on screen (still saved, still synced as TabMove, and visible later on a top bar), and a wrap through another group landed the tab at its own group's far end by accident. The move now reassigns only the slots of the tab's own sidebar section, and wraps at that section's ends. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- CHANGELOG.md | 9 +++ docs/reference/keyboard-shortcuts.mdx | 2 +- src/core/actions.rs | 2 + src/ui/app.rs | 93 ++++++++++++++++++++++++++- src/ui/i18n/en.rs | 2 + src/ui/i18n/ja.rs | 2 + src/ui/i18n/mod.rs | 2 + src/ui/i18n/zh.rs | 2 + src/ui/keymap.rs | 15 +++++ src/ui/palette.rs | 8 +++ src/ui/tab_sidebar.rs | 15 +++++ 11 files changed, 150 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 426b3eed..b3a619e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Reorder the active tab from the keyboard** (`MoveTabLeft` / `MoveTabRight`). + The tab moves one slot past its neighbour — the keyboard form of dragging it + in the tab strip or the sidebar — and wraps past either end, so one held key + walks a tab the whole way down the list. On a left tab bar the same pair + reads as up and down, and the tab stays inside its sidebar group, wrapping + at the group's ends. Shipped unbound like the pane-swap pair: bind it under + Settings → Keyboard shortcuts ("Move Tab Left" / "Move Tab Right"), in + `config.json`, or run it from the palette. + - **Quick launch for the coding agents on your PATH** (#955). Every agent tty7 recognises whose binary is on `PATH` is a palette command — "Agent: Claude Code", "Agent: Codex", … — ordered by how often and how recently it was diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index c0509050..a1613e26 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -108,7 +108,7 @@ Keybindings**: | Group | Actions | |---|---| -| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | +| Tabs | `RenameTab` · `NewWorktreeTab` · `MoveTabLeft` · `MoveTabRight` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `HibernateTab` · `ToggleTabSidebar` | | Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | | Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | | Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` · `LaunchAgent:` (one per agent, e.g. `LaunchAgent:claude`) | diff --git a/src/core/actions.rs b/src/core/actions.rs index bf9fff64..201edf58 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -57,6 +57,8 @@ actions!( PrevTab, SelectNextTab, SelectPrevTab, + MoveTabLeft, + MoveTabRight, ActivateTab1, ActivateTab2, ActivateTab3, diff --git a/src/ui/app.rs b/src/ui/app.rs index 5d6907a1..1cd7ad3e 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4888,6 +4888,20 @@ impl Tty7App { } } + /// Reorders the active tab one slot — the keyboard form of the drag that + /// the strip and the sidebar both do. Left is back along the order and + /// right is forward (on a left tab bar that reads as up and down), and + /// both wrap: past the last tab the first one follows, so a tab can walk + /// the whole list without letting go of the key. On a left tab bar the + /// list is the tab's own sidebar group: the grouping keys off the tab, + /// not its position, so a step out of the group would move nothing. + fn move_tab(&mut self, right: bool, cx: &mut Context) { + let run = self.tab_run(self.active, cx); + if let Some(permutation) = one_slot_move(&run, self.tabs.len(), self.active, right) { + self.apply_tab_order(&permutation, cx); + } + } + pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { // A tab is woken by being looked at, the way a browser reloads a // discarded tab when it is selected: a tab on screen is always a @@ -5973,6 +5987,8 @@ impl Tty7App { SwapPanePrev => self.swap_pane(false, window, cx), SelectNextTab => self.cycle_tab(true, window, cx), SelectPrevTab => self.cycle_tab(false, window, cx), + MoveTabLeft => self.move_tab(false, cx), + MoveTabRight => self.move_tab(true, cx), ToggleMaximizePane => self.toggle_maximize(window, cx), ToggleFullscreen => self.toggle_fullscreen(window, cx), ToggleTabSidebar => self.toggle_tab_sidebar(cx), @@ -9191,6 +9207,12 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &SelectPrevTab, window, cx| { this.cycle_tab(false, window, cx) })) + .on_action( + cx.listener(|this, _: &MoveTabLeft, _window, cx| this.move_tab(false, cx)), + ) + .on_action( + cx.listener(|this, _: &MoveTabRight, _window, cx| this.move_tab(true, cx)), + ) .on_action(cx.listener(|this, _: &ActivateTab1, window, cx| { this.activate_visual(0, window, cx) })) @@ -9477,6 +9499,35 @@ fn step_in_order(order: &[usize], active: usize, forward: bool) -> Option Some(order[next]).filter(|&i| i != active) } +/// The `apply_tab_order` permutation that moves the active tab one slot +/// within `run` — a move, not a trade: the tab is lifted out and put back at +/// the neighbour's place, so through a wrap the run it crossed shifts one slot +/// (`[a, b, c]` with `c` moved right becomes `[c, a, b]`, not `[c, b, a]`). +/// `run` is the tabs drawn together (a sidebar section, or every tab on a top +/// bar) as ascending `self.tabs` indices out of `n`; only their slots are +/// reassigned, so no tab in another run moves, not even out of sight. `None` +/// when there is nothing to move: a run of fewer than two tabs, or one that +/// does not hold the active tab. +fn one_slot_move(run: &[usize], n: usize, active: usize, right: bool) -> Option> { + let len = run.len(); + if len < 2 { + return None; + } + let pos = run.iter().position(|&i| i == active)?; + let mut moved = run.to_vec(); + match (right, pos) { + (true, p) if p + 1 == len => moved.rotate_right(1), + (false, 0) => moved.rotate_left(1), + (true, p) => moved.swap(p, p + 1), + (false, p) => moved.swap(p, p - 1), + } + let mut permutation: Vec = (0..n).collect(); + for (&slot, &tab) in run.iter().zip(&moved) { + permutation[slot] = tab; + } + Some(permutation) +} + fn mru_order(stamps: &[u64], active: usize) -> Vec { let mut order: Vec = (0..stamps.len()).collect(); order.sort_by_key(|&i| (stamps[i] == 0, std::cmp::Reverse(stamps[i]), i)); @@ -10665,7 +10716,7 @@ mod tests { use super::{ CloseReason, DOCUMENT_MIN_W, Dir, Pane, Rename, TERMINAL_MIN_W, TITLE_BAR_HEIGHT, Tab, TabAgentSession, clear_window_override_values, close_prompt, document_column_px, - join_shell_args, leaf_shares_the_window_daemon, mru_order, pane_free_for, + join_shell_args, leaf_shares_the_window_daemon, mru_order, one_slot_move, pane_free_for, parse_ssh_connect_input, parse_ssh_option_words, rename_outcome, side_panel_max, split_shell_args, step_in_order, strip_band, wd_path_saveable, }; @@ -11045,6 +11096,46 @@ mod tests { assert_eq!(step_in_order(&[0], 0, false), None); } + #[test] + fn moving_a_tab_one_slot_puts_it_where_the_neighbour_was() { + // A move, not a trade: the neighbour it crossed shifts one slot. + assert_eq!(one_slot_move(&[0, 1, 2], 3, 1, true), Some(vec![0, 2, 1])); + assert_eq!(one_slot_move(&[0, 1, 2], 3, 1, false), Some(vec![1, 0, 2])); + } + + #[test] + fn a_tab_moved_past_an_end_carries_the_whole_run_with_it() { + // Through the wrap the tab lands at the far end and the run it + // crossed shifts one slot — `[c, a, b]`, not `[c, b, a]`. + assert_eq!(one_slot_move(&[0, 1, 2], 3, 2, true), Some(vec![2, 0, 1])); + assert_eq!(one_slot_move(&[0, 1, 2], 3, 0, false), Some(vec![1, 2, 0])); + } + + #[test] + fn a_move_inside_a_sidebar_group_leaves_the_other_groups_alone() { + // Tabs 0, 2 and 4 are one group, 1 and 3 another: only the group's + // own slots are reassigned, and the wrap stays inside it. + assert_eq!( + one_slot_move(&[0, 2, 4], 5, 2, true), + Some(vec![0, 1, 4, 3, 2]) + ); + assert_eq!( + one_slot_move(&[0, 2, 4], 5, 0, false), + Some(vec![2, 1, 4, 3, 0]) + ); + assert_eq!( + one_slot_move(&[1, 3], 5, 3, true), + Some(vec![0, 3, 2, 1, 4]) + ); + } + + #[test] + fn a_move_with_nothing_to_move_is_refused() { + assert_eq!(one_slot_move(&[], 0, 0, true), None); + assert_eq!(one_slot_move(&[0], 3, 0, true), None); + assert_eq!(one_slot_move(&[0, 1, 2], 3, 7, true), None); + } + #[test] fn restore_only_attaches_panes_the_workspace_owns_or_nobody_claims() { let ours = crate::core::session::WorkspaceId::new(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index cd1991b9..68b1e8c4 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1522,6 +1522,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CmdSwapPanePrevious => "Swap Pane Previous", L10nKey::CmdNextTab => "Next Tab", L10nKey::CmdPreviousTab => "Previous Tab", + L10nKey::CmdMoveTabLeft => "Move Tab Left", + L10nKey::CmdMoveTabRight => "Move Tab Right", L10nKey::CmdRecentTabSwitcher => "Recent Tab Switcher", L10nKey::CmdRecentTabSwitcherReverse => "Recent Tab Switcher (Reverse)", L10nKey::CmdCopyWorkingDirectory => "Copy Working Directory", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a2502336..0b0c822b 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1586,6 +1586,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CmdSwapPanePrevious => "前のペインと入れ替え", L10nKey::CmdNextTab => "次のタブ", L10nKey::CmdPreviousTab => "前のタブ", + L10nKey::CmdMoveTabLeft => "タブを左へ移動", + L10nKey::CmdMoveTabRight => "タブを右へ移動", L10nKey::CmdRecentTabSwitcher => "最近のタブを切り替える", L10nKey::CmdRecentTabSwitcherReverse => "最近のタブを切り替える(逆順)", L10nKey::CmdCopyWorkingDirectory => "作業ディレクトリをコピー", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 4e8eaf92..e0fc74c7 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1238,6 +1238,8 @@ l10n_keys! { CmdSwapPanePrevious, CmdNextTab, CmdPreviousTab, + CmdMoveTabLeft, + CmdMoveTabRight, CmdRecentTabSwitcher, CmdRecentTabSwitcherReverse, CmdCopyWorkingDirectory, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 092a77dd..61074840 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1429,6 +1429,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CmdSwapPanePrevious => "与上一窗格交换", L10nKey::CmdNextTab => "下一标签页", L10nKey::CmdPreviousTab => "上一标签页", + L10nKey::CmdMoveTabLeft => "标签页左移", + L10nKey::CmdMoveTabRight => "标签页右移", L10nKey::CmdRecentTabSwitcher => "最近标签页切换器", L10nKey::CmdRecentTabSwitcherReverse => "最近标签页切换器(反向)", L10nKey::CmdCopyWorkingDirectory => "复制工作目录", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 08296cd1..fcf86522 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -410,6 +410,11 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { "SelectPrevTab", per_platform("secondary-shift-[", "ctrl-pageup"), ), + // Reordering, not switching: bound by the user who wants it (the + // palette and the Keybindings page both carry these), like the pane + // swap pair above. No chord ships free enough to spend on both. + ("MoveTabLeft", ""), + ("MoveTabRight", ""), ("ActivateTab1", per_platform("secondary-1", "alt-1")), ("ActivateTab2", per_platform("secondary-2", "alt-2")), ("ActivateTab3", per_platform("secondary-3", "alt-3")), @@ -726,6 +731,14 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { CommandGroup::TabsPanes, t(L10nKey::CmdPreviousTab).to_string(), ), + "MoveTabLeft" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdMoveTabLeft).to_string(), + ), + "MoveTabRight" => ( + CommandGroup::TabsPanes, + t(L10nKey::CmdMoveTabRight).to_string(), + ), "NewWorkspace" => ( CommandGroup::Workspaces, t(L10nKey::CmdNewWorkspace).to_string(), @@ -1426,6 +1439,8 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "PrevTab" => KeyBinding::new(keystroke, PrevTab, None), "SelectNextTab" => KeyBinding::new(keystroke, SelectNextTab, None), "SelectPrevTab" => KeyBinding::new(keystroke, SelectPrevTab, None), + "MoveTabLeft" => KeyBinding::new(keystroke, MoveTabLeft, None), + "MoveTabRight" => KeyBinding::new(keystroke, MoveTabRight, None), "ActivateTab1" => KeyBinding::new(keystroke, ActivateTab1, None), "ActivateTab2" => KeyBinding::new(keystroke, ActivateTab2, None), "ActivateTab3" => KeyBinding::new(keystroke, ActivateTab3, None), diff --git a/src/ui/palette.rs b/src/ui/palette.rs index bc7d16ff..8cbc4b77 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -55,6 +55,8 @@ pub enum CommandKind { SwapPanePrev, SelectNextTab, SelectPrevTab, + MoveTabLeft, + MoveTabRight, ToggleMaximizePane, ToggleFullscreen, ToggleTabSidebar, @@ -170,6 +172,8 @@ impl CommandKind { SwapPanePrev => "swap-pane-prev", SelectNextTab => "next-tab", SelectPrevTab => "prev-tab", + MoveTabLeft => "move-tab-left", + MoveTabRight => "move-tab-right", ToggleMaximizePane => "zoom-pane", ToggleFullscreen => "full-screen", ToggleTabSidebar => "tab-bar-position", @@ -294,6 +298,8 @@ impl CommandKind { // MRU switcher `NextTab` opens, so its chord hint is that one's. SelectNextTab => "SelectNextTab", SelectPrevTab => "SelectPrevTab", + MoveTabLeft => "MoveTabLeft", + MoveTabRight => "MoveTabRight", ToggleMaximizePane => "ToggleMaximizePane", ToggleFullscreen => "ToggleFullscreen", ToggleTabSidebar => "ToggleTabSidebar", @@ -494,6 +500,8 @@ impl Command { Command::localized(L10nKey::CmdSwapPanePrevious, SwapPanePrev), Command::localized(L10nKey::CmdNextTab, SelectNextTab), Command::localized(L10nKey::CmdPreviousTab, SelectPrevTab), + Command::localized(L10nKey::CmdMoveTabLeft, MoveTabLeft), + Command::localized(L10nKey::CmdMoveTabRight, MoveTabRight), Command::localized(L10nKey::CmdCopyWorkingDirectory, CopyWorkingDirectory), Command::localized(L10nKey::CmdCopySessionId, CopyAgentSessionId) .with_subtitle(t(L10nKey::CmdCopySessionIdSubtitle)), diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 55d5db75..e5274f79 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -2597,6 +2597,21 @@ impl Tty7App { .collect() } + /// The tabs drawn in the same run as `index`, in `self.tabs` order: its + /// sidebar section on a left tab bar, every tab on a top one. Empty when + /// no run holds `index`. + pub(crate) fn tab_run(&self, index: usize, cx: &gpui::App) -> Vec { + if cx.global::().tab_bar_position != crate::core::config::TabBarPosition::Left { + return (0..self.tabs.len()).collect(); + } + let keys = self.sidebar_group_keys(cx); + sidebar_sections(&keys, &self.sidebar_groups) + .into_iter() + .map(|s| s.tabs) + .find(|tabs| tabs.contains(&index)) + .unwrap_or_default() + } + pub(crate) fn activate_visual( &mut self, n: usize, From 7a32e7dbbcc274d0692548cde1fc9cceec9a346b Mon Sep 17 00:00:00 2001 From: kalpakprod Date: Sun, 27 Sep 2026 06:30:11 +0500 Subject: [PATCH 14/20] feat(agents): recognise Empryo and Prime Agent, add Antigravity status hooks (#975) * feat(agents): recognise Empryo and Prime Agent Prime Agent (PrimeIntellect-ai/prime-agent) is a Pi fork: detected as `prime-agent`, resumes with `--resume `, forks with `--fork `, `--no-session` opts out, and reports status through the shared Pi extension bridge at ~/.prime/agent/extensions/tty7/index.ts. Empryo is detected as `empryo` and resumes with `empryo --session `. No hook installer yet: Empryo filters TTY7* variables out of hook processes, so `tty7-app agent-hook` would stop at the missing marker. * feat(agents): Antigravity status hooks Antigravity CLI (`agy`) now installs a `tty7` hook set in ~/.gemini/config/hooks.json, next to the user's own sets: PreInvocation -> prompt-submit, PostToolUse -> tool-complete, Stop -> stop. Only the first PreInvocation of a turn (invocationNum 0) counts as a new prompt, later ones keep the turn working; a Stop with fullyIdle false is ignored. conversationId and workspacePaths feed the session id and cwd. PreToolUse is left alone because it must answer with a decision. * fix(agents): index Prime Agent and Antigravity in settings, scope conversationId alias - Add settings titles, search keywords (en/ja/zh) and Agents index entries for the two new HookAgents; agent_rows_are_in_the_search_index requires one per HookAgent::ALL. - Read Antigravity's conversationId as the session id only for antigravity: the alias table is last-write-wins, so a global alias could replace another agent's session id. - Drop the stray .empryo/ job records. --------- Co-authored-by: kalpak Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- README.md | 8 +- README.zh-CN.md | 4 +- crates/tty7-core/src/core/agent_hooks.rs | 295 ++++++++++++++++++++++- crates/tty7-core/src/core/cli_agent.rs | 89 ++++++- docs/agents/overview.mdx | 6 +- docs/agents/sessions.mdx | 1 + docs/agents/status.mdx | 11 +- docs/getting-started/first-launch.mdx | 2 +- docs/index.mdx | 2 +- src/ui/i18n/en.rs | 8 + src/ui/i18n/ja.rs | 8 + src/ui/i18n/mod.rs | 6 + src/ui/i18n/zh.rs | 8 + src/ui/settings.rs | 10 + 14 files changed, 441 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 74d6f4ac..218771d3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (23 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups | +| **Agent-aware** | per-pane detection (25 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | @@ -69,7 +69,7 @@ after a reboot. **Fork** needs both — the agent's own fork command, and the ho that tells tty7 which session to fork.
-The full support matrix, all twenty-three +The full support matrix, all twenty-five | Agent | Detected | Status · resume | Fork | |---|:-:|:-:|:-:| @@ -79,6 +79,7 @@ that tells tty7 which session to fork. | **Grok** | ✓ | ✓ | ✓ | | **OpenCode** | ✓ | ✓ | ✓ | | **Oh My Pi** | ✓ | ✓ | ✓ | +| **Prime Agent** | ✓ | ✓ | ✓ | | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | @@ -89,13 +90,14 @@ that tells tty7 which session to fork. | **Kimi Code** | ✓ | ✓ | | | **Pi** | ✓ | ✓ | | | **Crush** | ✓ | ✓ | | +| **Antigravity** | ✓ | ✓ | | | **Cursor** | ✓ | ✓ | | | Aider | ✓ | | | | Amp | ✓ | | | | Auggie | ✓ | | | | Hermes | ✓ | | | | Vibe | ✓ | | | -| Antigravity | ✓ | | | +| Empryo | ✓ | | |
diff --git a/README.zh-CN.md b/README.zh-CN.md index 571937ec..ccb7aa1d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -77,6 +77,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | **Grok** | ✓ | ✓ | ✓ | | **OpenCode** | ✓ | ✓ | ✓ | | **Oh My Pi** | ✓ | ✓ | ✓ | +| **Prime Agent** | ✓ | ✓ | ✓ | | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | @@ -84,13 +85,14 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | **Copilot** | ✓ | ✓ | | | **Kimi Code** | ✓ | ✓ | | | **Pi** | ✓ | ✓ | | +| **Antigravity** | ✓ | ✓ | | | Aider | ✓ | | | | Amp | ✓ | | | | Cursor | ✓ | | | | Auggie | ✓ | | | | Hermes | ✓ | | | | Vibe | ✓ | | | -| Antigravity | ✓ | | | +| Empryo | ✓ | | | diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 38c846c8..a4867638 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,6 +43,26 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { } fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { + // Antigravity has no turn-start event, only `PreInvocation` before every + // model call. The first call of a turn starts it; later ones are the same + // turn still working, and must not count as new prompts. `Stop` with + // `fullyIdle: false` ends one execution loop while more are queued. + if agent == "antigravity" + && let Ok(payload) = serde_json::from_str::(stdin_json) + { + if event == "prompt-submit" + && payload + .get("invocationNum") + .and_then(|n| n.as_u64()) + .unwrap_or(0) + > 0 + { + return Some("tool-complete"); + } + if event == "stop" && payload.get("fullyIdle").and_then(|v| v.as_bool()) == Some(false) { + return None; + } + } // Qoder and CodeBuddy also emit SessionStart after compacting the active // turn. Preserve its status until a real turn or session boundary arrives. if matches!(agent, "qodercli" | "codebuddy") @@ -90,11 +110,25 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { body[key] = serde_json::Value::String(v.to_string()); } } + // Antigravity names the session after the conversation, in camelCase. + // Scoped to it: the alias table is last-write-wins, and another agent's + // unrelated `conversationId` must not replace its real session id. + if agent == "antigravity" + && body.get("session_id").is_none() + && let Some(id) = payload + .get("conversationId") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + { + body["session_id"] = serde_json::Value::String(id.to_string()); + } // Cursor puts `cwd` only on its tool events; the rest carry the workspace // roots instead, and the first of those is where the session runs. + // Antigravity's `workspacePaths` is the same list under its own name. if body.get("cwd").is_none() && let Some(root) = payload .get("workspace_roots") + .or_else(|| payload.get("workspacePaths")) .and_then(|r| r.as_array()) .and_then(|r| r.first()) .and_then(|r| r.as_str()) @@ -285,10 +319,12 @@ pub enum HookAgent { Crush, CodeBuddy, Cursor, + PrimeAgent, + Antigravity, } impl HookAgent { - pub const ALL: [HookAgent; 17] = [ + pub const ALL: [HookAgent; 19] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -306,6 +342,8 @@ impl HookAgent { HookAgent::Crush, HookAgent::CodeBuddy, HookAgent::Cursor, + HookAgent::PrimeAgent, + HookAgent::Antigravity, ]; /// The hooks behind a detected agent process, if it has any. @@ -323,6 +361,8 @@ impl HookAgent { CLIAgent::Pi => Some(HookAgent::Pi), CLIAgent::Grok => Some(HookAgent::Grok), CLIAgent::OhMyPi => Some(HookAgent::OhMyPi), + CLIAgent::PrimeAgent => Some(HookAgent::PrimeAgent), + CLIAgent::Antigravity => Some(HookAgent::Antigravity), CLIAgent::Gemini => Some(HookAgent::Gemini), CLIAgent::Droid => Some(HookAgent::Droid), CLIAgent::Qwen => Some(HookAgent::Qwen), @@ -337,7 +377,7 @@ impl HookAgent { | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity => None, + | CLIAgent::Empryo => None, } } @@ -361,6 +401,8 @@ impl HookAgent { | HookAgent::Pi | HookAgent::Grok | HookAgent::OhMyPi + | HookAgent::PrimeAgent + | HookAgent::Antigravity | HookAgent::Goose | HookAgent::Kimi => None, } @@ -406,6 +448,8 @@ impl HookAgent { HookAgent::Pi => "pi", HookAgent::Grok => "grok", HookAgent::OhMyPi => "omp", + HookAgent::PrimeAgent => "prime-agent", + HookAgent::Antigravity => "antigravity", HookAgent::Gemini => "gemini", HookAgent::Droid => "droid", HookAgent::Qwen => "qwen", @@ -428,6 +472,8 @@ impl HookAgent { HookAgent::Pi => "Pi", HookAgent::Grok => "Grok Build", HookAgent::OhMyPi => "Oh My Pi", + HookAgent::PrimeAgent => "Prime Agent", + HookAgent::Antigravity => "Antigravity", HookAgent::Gemini => "Gemini", HookAgent::Droid => "Droid", HookAgent::Qwen => "Qwen Code", @@ -455,6 +501,10 @@ impl HookAgent { &["opencode", "plugins", OWNED_FILE_STEM_JS], ), HookAgent::Pi => target.under_home(&[".pi", "agent", "extensions", "tty7", "index.ts"]), + HookAgent::PrimeAgent => { + target.under_home(&[".prime", "agent", "extensions", "tty7", "index.ts"]) + } + HookAgent::Antigravity => target.under_home(&[".gemini", "config", "hooks.json"]), HookAgent::Grok => target.under_home(&[".grok", "hooks", OWNED_FILE_STEM_JSON]), HookAgent::OhMyPi => { target.under_home(&[".omp", "agent", "extensions", "tty7", "index.ts"]) @@ -683,6 +733,9 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + return named_hook_set_state(target, &path, &antigravity_hook_set(target)); + } if let Some(events) = agent.toml_hook_events() { return toml_hooks_state(target, &path, agent, events); } @@ -719,6 +772,10 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + named_hook_set_install(target, &path, antigravity_hook_set(target))?; + return Ok(HookOutcome::Installed); + } if let Some(events) = agent.toml_hook_events() { toml_hooks_install(target, &path, agent, events)?; return Ok(HookOutcome::Installed); @@ -744,6 +801,9 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Antigravity { + return named_hook_set_uninstall(target, &path); + } if agent.toml_hook_events().is_some() { return toml_hooks_uninstall(target, &path, agent); } @@ -1324,11 +1384,108 @@ fn enable_codex_hooks_feature() -> Result<(), String> { } } +/// The key tty7's entry lives under in Antigravity's `hooks.json`. +/// +/// Antigravity names every hook set at the root of the file — +/// `{"": {"": [...]}}` — rather than grouping handlers under a +/// shared `hooks` object the way Claude does, so tty7 owns one named set and +/// never has to pick its commands out of the user's lists. +const ANTIGRAVITY_HOOK_SET: &str = "tty7"; + +/// Antigravity CLI's lifecycle events, mapped onto tty7's. +/// +/// It has no session-start or session-end event. `PreInvocation` fires before +/// every model call of a turn, so `effective_event` keeps only the first one +/// as the turn start; `Stop` fires when the execution loop ends. The +/// `PreToolUse` gate is left alone: it must answer with a `decision`, and an +/// empty reply from a status hook has no business deciding anything. +fn antigravity_hook_set(target: &HookTarget) -> serde_json::Value { + let handler = |event: &str| { + serde_json::json!({ + "type": "command", + "command": target.hook_command(HookAgent::Antigravity, event), + "timeout": 5, + }) + }; + serde_json::json!({ + "PreInvocation": [handler("prompt-submit")], + "PostToolUse": [{ "matcher": "*", "hooks": [handler("tool-complete")] }], + "Stop": [handler("stop")], + }) +} + +fn read_json_object(target: &HookTarget, path: &Path) -> anyhow::Result> { + let text = match target.read(path) { + Ok(text) => text, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + if text.trim().is_empty() { + return Ok(Some(serde_json::json!({}))); + } + let root: serde_json::Value = serde_json::from_str(&text).map_err(|e| { + anyhow::anyhow!( + "{} is not valid JSON ({e}); not touching it", + path.display() + ) + })?; + if !root.is_object() { + return Err(anyhow::anyhow!( + "{} is not a JSON object; not touching it", + path.display() + )); + } + Ok(Some(root)) +} + +fn named_hook_set_state( + target: &HookTarget, + path: &Path, + expected: &serde_json::Value, +) -> HooksState { + match read_json_object(target, path) { + Ok(Some(root)) => match root.get(ANTIGRAVITY_HOOK_SET) { + None => HooksState::NotInstalled, + Some(set) if set == expected => HooksState::Installed, + Some(_) => HooksState::Outdated, + }, + _ => HooksState::NotInstalled, + } +} + +fn named_hook_set_install( + target: &HookTarget, + path: &Path, + set: serde_json::Value, +) -> anyhow::Result<()> { + let mut root = read_json_object(target, path)?.unwrap_or_else(|| serde_json::json!({})); + root.as_object_mut() + .unwrap() + .insert(ANTIGRAVITY_HOOK_SET.to_string(), set); + target.write(path, serde_json::to_string_pretty(&root)?.as_bytes()) +} + +fn named_hook_set_uninstall(target: &HookTarget, path: &Path) -> anyhow::Result { + let Some(mut root) = read_json_object(target, path)? else { + return Ok(HookOutcome::NothingInstalled); + }; + if root + .as_object_mut() + .unwrap() + .remove(ANTIGRAVITY_HOOK_SET) + .is_none() + { + return Ok(HookOutcome::NoTty7Hooks); + } + target.write(path, serde_json::to_string_pretty(&root)?.as_bytes())?; + Ok(HookOutcome::Removed) +} + fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { match agent { HookAgent::Copilot => copilot_hooks_json(target), HookAgent::OpenCode => opencode_plugin_js(target), - HookAgent::Pi | HookAgent::OhMyPi => pi_extension_ts(target, agent), + HookAgent::Pi | HookAgent::OhMyPi | HookAgent::PrimeAgent => pi_extension_ts(target, agent), HookAgent::Grok => grok_hooks_json(target), HookAgent::Goose => goose_hooks_json(target), HookAgent::Claude @@ -1341,6 +1498,7 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Crush | HookAgent::CodeBuddy | HookAgent::Cursor + | HookAgent::Antigravity | HookAgent::Kimi => None, } } @@ -1568,6 +1726,7 @@ fn pi_extension_ts(target: &HookTarget, agent: HookAgent) -> Option { let (slug, package) = match agent { HookAgent::Pi => ("pi", "@mariozechner/pi-coding-agent"), HookAgent::OhMyPi => ("omp", "@oh-my-pi/pi-coding-agent"), + HookAgent::PrimeAgent => ("prime-agent", "@earendil-works/pi-coding-agent"), _ => return None, }; let exe = serde_json::to_string(&target.exe.display().to_string()).ok()?; @@ -1838,6 +1997,10 @@ mod tests { (HookAgent::Crush, "/home/me/.config/crush/crush.json"), (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), (HookAgent::Cursor, "/home/me/.cursor/hooks.json"), + ( + HookAgent::PrimeAgent, + "/home/me/.prime/agent/extensions/tty7/index.ts", + ), ] { assert_eq!( agent.target_path(&t), @@ -2582,6 +2745,127 @@ mod tests { ); } + #[test] + fn antigravity_owns_a_named_hook_set_beside_the_users() { + let dir = std::env::temp_dir().join(format!("tty7-agy-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let host = FakeRemote::shared(); + let target = HookTarget::remote(&*host, dir.clone()); + let path = HookAgent::Antigravity.target_path(&target); + assert_eq!(path, dir.join(".gemini/config/hooks.json")); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let theirs = serde_json::json!({ + "my-linter-hook": { + "PostToolUse": [{ "matcher": "run_command", "hooks": [{ "command": "./lint.sh" }] }] + } + }); + std::fs::write(&path, theirs.to_string()).unwrap(); + + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::NotInstalled + ); + install_hooks(&target, HookAgent::Antigravity).expect("install succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Installed + ); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + root["my-linter-hook"], theirs["my-linter-hook"], + "the user's set survives" + ); + let set = &root[ANTIGRAVITY_HOOK_SET]; + assert!( + set["PreInvocation"][0]["command"] + .as_str() + .unwrap() + .ends_with("agent-hook antigravity prompt-submit") + ); + assert_eq!(set["PostToolUse"][0]["matcher"], "*"); + assert!( + set["Stop"][0]["command"] + .as_str() + .unwrap() + .ends_with("agent-hook antigravity stop") + ); + assert!( + set.get("PreToolUse").is_none(), + "a status hook must not gate tools" + ); + + root_set_to(&path, serde_json::json!({ "Stop": [] }), &root); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Outdated + ); + install_hooks(&target, HookAgent::Antigravity).expect("reinstall succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Antigravity), + HooksState::Installed + ); + + assert_eq!( + uninstall_hooks(&target, HookAgent::Antigravity).expect("uninstall succeeds"), + HookOutcome::Removed + ); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!( + root, theirs, + "uninstall leaves exactly what was there before" + ); + assert_eq!( + uninstall_hooks(&target, HookAgent::Antigravity).expect("second uninstall"), + HookOutcome::NoTty7Hooks + ); + let _ = std::fs::remove_dir_all(&dir); + } + + fn root_set_to(path: &Path, set: serde_json::Value, root: &serde_json::Value) { + let mut root = root.clone(); + root[ANTIGRAVITY_HOOK_SET] = set; + std::fs::write(path, root.to_string()).unwrap(); + } + + #[test] + fn antigravity_payloads_map_onto_turns() { + let first = r#"{"conversationId":"c-1","invocationNum":0,"workspacePaths":["/repo"]}"#; + let later = r#"{"conversationId":"c-1","invocationNum":2,"workspacePaths":["/repo"]}"#; + assert_eq!( + effective_event("antigravity", "prompt-submit", first), + Some("prompt-submit") + ); + assert_eq!( + effective_event("antigravity", "prompt-submit", later), + Some("tool-complete") + ); + assert_eq!( + effective_event( + "antigravity", + "stop", + r#"{"conversationId":"c-1","fullyIdle":true}"# + ), + Some("stop") + ); + assert_eq!( + effective_event( + "antigravity", + "stop", + r#"{"conversationId":"c-1","fullyIdle":false}"# + ), + None + ); + let ev = round_trip("antigravity", "prompt-submit", first); + assert_eq!(ev.session_id.as_deref(), Some("c-1")); + assert_eq!(ev.cwd.as_deref(), Some(Path::new("/repo"))); + assert_eq!( + ev.agent, + Some(crate::core::cli_agent::CLIAgent::Antigravity) + ); + } + #[test] fn a_remote_install_round_trips_through_the_host() { let dir = std::env::temp_dir().join(format!("tty7-remote-hooks-{}", std::process::id())); @@ -2679,6 +2963,11 @@ mod tests { for (agent, slug, package) in [ (HookAgent::Pi, "pi", "@mariozechner/pi-coding-agent"), (HookAgent::OhMyPi, "omp", "@oh-my-pi/pi-coding-agent"), + ( + HookAgent::PrimeAgent, + "prime-agent", + "@earendil-works/pi-coding-agent", + ), ] { let bridge = pi_extension_ts(&target, agent).unwrap_or_else(|| panic!("{slug} content builds")); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 42b44526..0620a50b 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -29,10 +29,12 @@ pub enum CLIAgent { QoderCLI, Crush, CodeBuddy, + Empryo, + PrimeAgent, } impl CLIAgent { - pub const ALL: [CLIAgent; 23] = [ + pub const ALL: [CLIAgent; 25] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -56,6 +58,8 @@ impl CLIAgent { CLIAgent::QoderCLI, CLIAgent::Crush, CLIAgent::CodeBuddy, + CLIAgent::Empryo, + CLIAgent::PrimeAgent, ]; fn aliases(self) -> &'static [&'static str] { @@ -106,6 +110,8 @@ impl CLIAgent { // `cbc` also names the COIN-OR solver; a solver run wearing the // avatar until it exits is the cost of catching the short name. CLIAgent::CodeBuddy => &["codebuddy", "codebuddy-code", "cbc"], + CLIAgent::Empryo => &["empryo"], + CLIAgent::PrimeAgent => &["prime-agent"], } } @@ -134,6 +140,8 @@ impl CLIAgent { CLIAgent::QoderCLI => "qodercli", CLIAgent::Crush => "crush", CLIAgent::CodeBuddy => "codebuddy", + CLIAgent::Empryo => "empryo", + CLIAgent::PrimeAgent => "prime-agent", } } @@ -167,6 +175,8 @@ impl CLIAgent { CLIAgent::QoderCLI => "Qoder CLI", CLIAgent::Crush => "Crush", CLIAgent::CodeBuddy => "CodeBuddy", + CLIAgent::Empryo => "Empryo", + CLIAgent::PrimeAgent => "Prime Agent", } } @@ -204,13 +214,15 @@ impl CLIAgent { CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")), CLIAgent::CodeBuddy => Some(format!("codebuddy{flags} --resume {session_id}")), + CLIAgent::Empryo => Some(format!("empryo{flags} --session {session_id}")), + CLIAgent::PrimeAgent => Some(format!("prime-agent{flags} --resume {session_id}")), _ => None, } } fn opts_out_of_sessions(self, argv: &[String]) -> bool { let ephemeral: &[&str] = match self { - CLIAgent::Pi | CLIAgent::OhMyPi => &["--no-session"], + CLIAgent::Pi | CLIAgent::OhMyPi | CLIAgent::PrimeAgent => &["--no-session"], // "Do not save conversation history" — nothing is persisted, so // there is no session left to resume from. CLIAgent::Auggie => &["--dont-save-session"], @@ -245,6 +257,7 @@ impl CLIAgent { )), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")), + CLIAgent::PrimeAgent => Some(format!("prime-agent{flags} --fork {session_id}")), // Droid forks with a standalone flag rather than resume-plus-a-switch. CLIAgent::Droid => Some(format!("droid{flags} --fork {session_id}")), // `fork` is missing from `amp threads --help`, but the subcommand is @@ -267,6 +280,7 @@ impl CLIAgent { | CLIAgent::Grok | CLIAgent::OpenCode | CLIAgent::OhMyPi + | CLIAgent::PrimeAgent | CLIAgent::Droid | CLIAgent::Amp | CLIAgent::Qwen @@ -433,6 +447,12 @@ impl CLIAgent { // `--resume`, `-r` and `--session` are three spellings of one flag // in Oh My Pi; `--session-dir` is a different one and survives. CLIAgent::OhMyPi => &["--resume", "-r", "--session", "--fork", "--continue", "-c"], + // Prime Agent is a Pi fork with the same session switches; + // `--session-dir` is a different option and survives. + CLIAgent::PrimeAgent => &["--resume", "-r", "--fork", "--continue", "-c"], + // empryo resumes with `--session `; `--save-session` only + // applies to headless runs and is not a session selector. + CLIAgent::Empryo => &["--session"], // `--resume`/`-r` is Kimi's hidden alias for `--session`/`-S`. // `--agent`/`--agent-file` bind the main agent at session creation // and Kimi rejects either next to `--session` outright; resuming @@ -548,6 +568,8 @@ impl CLIAgent { CLIAgent::QoderCLI => 0xFFFFFF, // The blue-violet field Charm ships the Crush heart on. CLIAgent::Crush => 0x6B50FF, + CLIAgent::Empryo => 0xE8663D, + CLIAgent::PrimeAgent => 0x111111, // The near-black field CodeBuddy's own app icon sits on. CLIAgent::CodeBuddy => 0x1F1F1F, } @@ -597,7 +619,9 @@ impl CLIAgent { | CLIAgent::Auggie | CLIAgent::Hermes | CLIAgent::Vibe - | CLIAgent::Antigravity => "icons/bot.svg", + | CLIAgent::Antigravity + | CLIAgent::Empryo + | CLIAgent::PrimeAgent => "icons/bot.svg", } } @@ -1239,7 +1263,15 @@ mod tests { .collect(); assert_eq!( fallback, - ["aider", "auggie", "hermes", "vibe", "antigravity"] + [ + "aider", + "auggie", + "hermes", + "vibe", + "antigravity", + "empryo", + "prime-agent" + ] ); assert!( !fallback.contains(&"omp"), @@ -1255,6 +1287,55 @@ mod tests { } } + #[test] + fn empryo_and_prime_agent_are_detected_and_resume() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[ + "/home/me/.empryo/bin/empryo", + "--session", + "750adff9" + ])), + Some(CLIAgent::Empryo) + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["prime-agent", "--model", "x"])), + Some(CLIAgent::PrimeAgent) + ); + assert_eq!(CLIAgent::from_slug("empryo"), Some(CLIAgent::Empryo)); + assert_eq!( + CLIAgent::from_slug("prime-agent"), + Some(CLIAgent::PrimeAgent) + ); + assert_eq!( + CLIAgent::Empryo + .resume_command( + "750adff9-f366", + Some(&argv(&["empryo", "--session", "old"])) + ) + .as_deref(), + Some("empryo --session 750adff9-f366") + ); + assert_eq!( + CLIAgent::PrimeAgent + .resume_command( + "s-1", + Some(&argv(&["prime-agent", "-r", "old", "--model", "m"])) + ) + .as_deref(), + Some("prime-agent --model m --resume s-1") + ); + assert_eq!( + CLIAgent::PrimeAgent.fork_command("s-1", None).as_deref(), + Some("prime-agent --fork s-1") + ); + assert_eq!( + CLIAgent::PrimeAgent + .resume_command("s-1", Some(&argv(&["prime-agent", "--no-session"]))), + None + ); + assert_eq!(CLIAgent::Empryo.fork_command("s-1", None), None); + } + #[test] fn detects_newer_agents_by_command() { for (cmd, agent) in [ diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 649e7648..4b0ad97b 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty-three CLIs are recognised on sight, by the command running in the pane: +Twenty-five CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -42,6 +42,8 @@ Twenty-three CLIs are recognised on sight, by the command running in the pane: | Antigravity | `agy`, `antigravity` | | Pi | `pi` | | Oh My Pi | `omp` | +| Prime Agent | `prime-agent` | +| Empryo | `empryo` | Detection sees through the usual disguises: a full path, a `.cmd` or `.exe` on Windows, leading environment assignments, and an interpreter in front @@ -64,7 +66,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`). +`omp`, `kimi`, `qodercli`, `crush`, `codebuddy`, `empryo`, `prime-agent`). ## Quick launch diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index 90703839..e47dc680 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -43,6 +43,7 @@ or right-click the **tab or sidebar row** to open the fork in a new tab. | Grok | `grok --resume --fork-session` | | OpenCode | `opencode --session --fork` | | Oh My Pi | `omp --fork ` | +| Prime Agent | `prime-agent --fork ` | It is the agent's own fork command, run in a new pane — nothing is copied by tty7 itself. diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 34dcebf8..27251cfc 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,8 +14,8 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy · Cursor CLI | Hooks available | -| Aider · Amp · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush · CodeBuddy · Cursor CLI · Prime Agent · Antigravity | Hooks available | +| Aider · Amp · Auggie · Hermes · Vibe · Empryo | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed the row grows a second **Uninstall** button beside the first, which itself @@ -28,6 +28,13 @@ ships a newer hook. carries no status dot and `tty7 wait` times out rather than returning. + + Antigravity has no session-start or permission event, so its pane goes + straight from no dot to working on the first model call, and a pending + permission prompt shows as working rather than amber. Its hooks live in a + `tty7` set inside `~/.gemini/config/hooks.json`, beside any sets of your own. + + `cursor-agent -p` (print mode) sends neither of Cursor's turn hooks, so a headless run records its session id — Copy Session ID and resume still diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index ae7893dc..3ab597c6 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -54,7 +54,7 @@ leave it off if you type accented characters. ## 4. If you use coding agents, install the hooks -**Settings → Integrations.** tty7 detects 23 coding CLIs by process name on its own — +**Settings → Integrations.** tty7 detects 25 coding CLIs by process name on its own — you get brand avatars and tab labels for free. The *status dots*, the "needs your permission" notifications, and `tty7 wait` all need one more thing: a small hook the agent calls to report what it is doing. diff --git a/docs/index.mdx b/docs/index.mdx index f14d99aa..88a3b375 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,7 +31,7 @@ something floods the screen. syntax highlighting, click-to-place-caret, real multi-line editing. - 23 coding CLIs are recognised on sight. Per-pane status dots, notifications + 25 coding CLIs are recognised on sight. Per-pane status dots, notifications when one needs you, git context, and session resume after a reboot. diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 68b1e8c4..b2e339d7 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -805,6 +805,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsAgentCursorCli => "Cursor CLI", + L10nKey::SettingsAgentPrimeAgent => "Prime Agent", + L10nKey::SettingsAgentAntigravity => "Antigravity", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -911,6 +913,12 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchCursorCliKeywords => { "agent integration hooks install cursor cursor-agent" } + L10nKey::SettingsSearchPrimeAgentKeywords => { + "agent integration extension install prime prime-agent primeintellect" + } + L10nKey::SettingsSearchAntigravityKeywords => { + "agent integration hooks install antigravity agy google" + } L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0b0c822b..0e0ebc46 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -815,6 +815,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsAgentCursorCli => "Cursor CLI", + L10nKey::SettingsAgentPrimeAgent => "Prime Agent", + L10nKey::SettingsAgentAntigravity => "Antigravity", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -967,6 +969,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorCliKeywords => { "エージェント 統合 フック インストール cursor cursor-agent agent integration hooks install" } + L10nKey::SettingsSearchPrimeAgentKeywords => { + "エージェント 統合 拡張 インストール prime prime-agent primeintellect agent integration extension install" + } + L10nKey::SettingsSearchAntigravityKeywords => { + "エージェント 統合 フック インストール antigravity agy google agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index e0fc74c7..ce929130 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -620,6 +620,8 @@ l10n_keys! { SettingsAgentCrush, SettingsAgentCodeBuddy, SettingsAgentCursorCli, + SettingsAgentPrimeAgent, + SettingsAgentAntigravity, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -679,6 +681,8 @@ l10n_keys! { SettingsSearchCrushKeywords, SettingsSearchCodeBuddyKeywords, SettingsSearchCursorCliKeywords, + SettingsSearchPrimeAgentKeywords, + SettingsSearchAntigravityKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -1666,6 +1670,8 @@ mod tests { L10nKey::SettingsAgentCrush, L10nKey::SettingsAgentCodeBuddy, L10nKey::SettingsAgentCursorCli, + L10nKey::SettingsAgentPrimeAgent, + L10nKey::SettingsAgentAntigravity, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 61074840..7bfbb78f 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -712,6 +712,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsAgentCodeBuddy => "CodeBuddy", L10nKey::SettingsAgentCursorCli => "Cursor CLI", + L10nKey::SettingsAgentPrimeAgent => "Prime Agent", + L10nKey::SettingsAgentAntigravity => "Antigravity", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -858,6 +860,12 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorCliKeywords => { "Cursor CLI agent 集成 钩子 安装 cursor cursor-agent" } + L10nKey::SettingsSearchPrimeAgentKeywords => { + "Prime Agent agent 集成 扩展 安装 prime prime-agent primeintellect" + } + L10nKey::SettingsSearchAntigravityKeywords => { + "Antigravity agent 集成 钩子 安装 antigravity agy google" + } L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index bd2ebc97..dac43544 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -799,6 +799,16 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentCursorCli, keywords: SettingsSearchCursorCliKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentPrimeAgent, + keywords: SettingsSearchPrimeAgentKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentAntigravity, + keywords: SettingsSearchAntigravityKeywords, + }, SearchEntry { section: General, title: SettingsStartupWindow, From a6e4235d27132f80b119843b41e98a0e6be5be2b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:35 +0800 Subject: [PATCH 15/20] fix(render): stop a blank IME preedit hiding the cell under the cursor (#970) paint_marked blanks the cursor's cell to the theme background before laying an IME composition down. A composition with nothing visible in it (Windows IMEs can leave one behind) therefore painted a background-coloured hole with an underline over the character and the block cursor on every cell the cursor visited. Skip painting a preedit that has no ink. Fixes #966 --- CHANGELOG.md | 7 ++++ src/terminal/element.rs | 73 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a619e6..89a95b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The character under the cursor no longer disappears in vim and Neovim** + (#966). An input-method composition with nothing visible in it — Windows + IMEs can leave one behind — was still painted at the cursor, as a cell of + the theme's background with an underline under it, covering both the + character and the block cursor on every cell the cursor moved to. A + composition that has nothing to draw is no longer painted, so the cell keeps + its character and the block cursor draws it in reverse video as usual. - **Nerd Font icons from a fallback font come out at the text's size** (#866). With a Nerd Font icon face such as Symbols Nerd Font Mono behind a primary that lacks the icons, an icon followed by a space on the same background — diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 7424b570..68f810a1 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1723,6 +1723,22 @@ fn cursor_style_from_shape(shape: CursorShape) -> crate::core::config::CursorSty } } +/// Whether an IME preedit has anything to draw. `paint_marked` blanks the cell +/// under the cursor to the theme's background before it lays the preedit +/// down, which also covers the block cursor's reverse-video cell. A preedit of +/// nothing but blanks (a composition an IME opened and left empty-looking — +/// Windows IMEs can leave one behind, and gpui there never hears the +/// composition end) would therefore paint a theme-coloured hole with an +/// underline where the character and the caret should be, and hold it on +/// every cell the cursor visits (#966). Nothing to show means nothing to +/// paint: the grid, and the caret on it, stay as they are. +fn preedit_has_ink(marked: &str) -> bool { + use unicode_width::UnicodeWidthChar as _; + marked + .chars() + .any(|c| !c.is_whitespace() && !c.is_control() && c.width().unwrap_or(0) > 0) +} + fn paint_marked( window: &mut Window, cx: &mut App, @@ -1734,7 +1750,7 @@ fn paint_marked( default_fg: Hsla, default_bg: Hsla, ) { - if marked.is_empty() { + if !preedit_has_ink(marked) { return; } let Some((row, col)) = cursor else { @@ -2714,6 +2730,61 @@ mod tests { assert_eq!(ink, colors.default_bg); } + #[test] + fn a_caret_the_colour_of_the_text_still_leaves_its_glyph_readable() { + // Gruvbox Dark, the theme in #966: the caret is the foreground colour + // exactly, so drawing the glyph in its own ink on the caret would be + // cream on cream. Reverse video has to reach for the background. + let fg = Rgb { + r: 0xeb, + g: 0xdb, + b: 0xb2, + }; + let bg = Rgb { + r: 0x28, + g: 0x28, + b: 0x28, + }; + let colors = PaintColors { + default_fg: to_hsla(fg), + default_bg: to_hsla(bg), + caret: to_hsla(fg), + fg_rgb: fg, + bg_rgb: bg, + ..caret_colors() + }; + let mut buf = vec![RenderCell::default(); 3]; + buf[1].c = 'v'; + buf[1].fg = to_hsla(fg); + invert_cursor_cell(&mut buf, 3, 0, 1, &colors); + assert_eq!(buf[1].bg, colors.caret); + assert_eq!(buf[1].fg, colors.default_bg); + assert_eq!(buf[1].c, 'v'); + } + + #[test] + fn a_preedit_with_nothing_to_show_leaves_the_cursor_cell_alone() { + // Painting any of these would blank the cell under the cursor — + // character and block caret both — to the theme background (#966). + for blank in [ + "", + " ", + " ", + "\u{3000}", + "\u{200b}", + "\t", + "\u{0}", + " \u{200d} ", + ] { + assert!(!preedit_has_ink(blank), "{blank:?} has nothing to draw"); + } + // Real compositions still paint, including a leading or trailing + // blank the IME put between syllables. + for text in ["n", "ni hao", "你好", " a", "に", "ㅎ", "é"] { + assert!(preedit_has_ink(text), "{text:?} is a real preedit"); + } + } + #[test] fn to_hsla_normalizes_channels_and_alpha() { let black = to_hsla(Rgb { r: 0, g: 0, b: 0 }); From 03c24514b215310247f5c27b6598b9ae58ee549f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:43 +0800 Subject: [PATCH 16/20] fix(editor): ghost suggests the newest match, not the most frecent (#968) The inline suggestion now takes the newest history entry that extends the line, preferring one run in the current directory and falling back to the newest anywhere, and skips commands whose last run failed. It used to be the top frecency match, where run count and the directory bonus outweighed recency. Ctrl+R keeps ranking by frecency. Re-submitting a command now drops its older copy, matching the global dedup applied when history loads, so up-arrow steps onto each command once. --- CHANGELOG.md | 12 ++ src/terminal/history.rs | 40 ++----- src/terminal/view.rs | 240 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 239 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a95b2b..0b8501ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The command-line ghost suggests what you ran last, not what you ran + most.** It was the top prefix match by frecency, where run count and the + current-directory bonus outweighed recency, so after `git commit -m x` + typing `git c` still offered `git checkout main` because that had run 20 + times here. The ghost is now the newest entry that extends the line, + preferring one run in the current directory and falling back to the newest + anywhere, and it skips commands whose last run exited non-zero, so it names + what ↑ recalls. Ctrl+R still ranks by frecency. Re-running a command now + moves it to the newest history entry instead of adding a second copy + (before, only an immediate repeat was collapsed), so ↑ steps onto each + command once, the same as after a restart. + - **The sidebar groups tabs by repo automatically; pin what you want to keep** (#955). Groups now come in two halves, split by a divider. Below it, every tab you have not pinned is filed under its git repository, and an SSH tab diff --git a/src/terminal/history.rs b/src/terminal/history.rs index 8167eb0a..914f0146 100644 --- a/src/terminal/history.rs +++ b/src/terminal/history.rs @@ -190,23 +190,6 @@ pub fn frecency_scores( .collect() } -pub fn rank_by_frecency( - entries: &[String], - counts: &HashMap, - cwds: &HashMap>, - cwd: Option<&str>, -) -> Vec { - let scores = frecency_scores(entries, counts, cwds, cwd); - let mut idx: Vec = (0..entries.len()).collect(); - idx.sort_by(|&a, &b| { - scores[b] - .partial_cmp(&scores[a]) - .unwrap_or(std::cmp::Ordering::Equal) - .then(b.cmp(&a)) - }); - idx.into_iter().map(|i| entries[i].clone()).collect() -} - pub fn format_ago(now: u64, ts: u64) -> String { let s = now.saturating_sub(ts); let (n, unit) = if s < 60 { @@ -904,12 +887,9 @@ mod tests { counts.insert("git status".to_string(), 40); counts.insert("ls".to_string(), 5); counts.insert("oops typo".to_string(), 1); - let ranked = rank_by_frecency(&entries, &counts, &HashMap::new(), None); - assert_eq!(ranked[0], "git status"); - assert!( - ranked.iter().position(|e| e == "git status").unwrap() - < ranked.iter().position(|e| e == "oops typo").unwrap() - ); + let scores = frecency_scores(&entries, &counts, &HashMap::new(), None); + assert!(scores[0] > scores[1], "frequent beats newer: {scores:?}"); + assert!(scores[0] > scores[2], "frequent beats newest: {scores:?}"); } #[test] @@ -920,15 +900,17 @@ mod tests { cwds.entry("cargo build".to_string()) .or_default() .insert("/work/proj".to_string()); - let ranked = rank_by_frecency(&entries, &counts, &cwds, Some("/work/proj")); - assert_eq!(ranked[0], "cargo build"); - let neutral = rank_by_frecency(&entries, &counts, &cwds, None); - assert_eq!(neutral[0], "cargo build"); - assert_eq!(neutral[1], "npm test"); + let here = frecency_scores(&entries, &counts, &cwds, Some("/work/proj")); + let neutral = frecency_scores(&entries, &counts, &cwds, None); + assert!(here[1] > here[0]); + assert!( + here[1] - here[0] > neutral[1] - neutral[0], + "running here widens the lead: {here:?} vs {neutral:?}" + ); } #[test] - fn frecency_scores_align_with_the_ranking() { + fn frecency_scores_favour_the_newer_of_two_equals() { let entries = vec!["a".to_string(), "b".to_string()]; let scores = frecency_scores(&entries, &HashMap::new(), &HashMap::new(), None); assert_eq!(scores.len(), 2); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 6ccac9f9..d4924e8b 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -458,7 +458,6 @@ pub struct TerminalView { history_counts: std::collections::HashMap, history_cwds: std::collections::HashMap>, history_meta: std::collections::HashMap, - history_ranked: Vec, history_frecency: Vec, history_scope: super::history::Scope, /// What each scope this pane has already loaded held when it was left, so @@ -1579,12 +1578,6 @@ impl TerminalView { window.focus(&focus_handle, cx); let history = super::history::load(&super::history::Scope::Local); - let history_ranked = super::history::rank_by_frecency( - &history.entries, - &history.counts, - &history.cwds, - None, - ); let history_frecency = super::history::frecency_scores(&history.entries, &history.counts, &history.cwds, None); @@ -1676,7 +1669,6 @@ impl TerminalView { history_counts: history.counts, history_cwds: history.cwds, history_meta: history.meta, - history_ranked, history_frecency, history_scope: super::history::Scope::Local, history_cache: Vec::new(), @@ -3908,7 +3900,6 @@ impl TerminalView { self.history_ready = false; } } - self.history_ranked.clear(); self.history_frecency.clear(); self.history_nav = None; self.reverse_search = None; @@ -4684,9 +4675,11 @@ impl TerminalView { exit: None, }, ); - if self.history.last().map(String::as_str) != Some(line.as_str()) { - self.history.push(line.clone()); - } + // One entry per command, at its latest run: the same global + // dedup `normalize` applies on load, so ↑ does not step onto a + // command twice and a restart does not reorder anything. + self.history.retain(|h| *h != line); + self.history.push(line.clone()); self.flush_pending_history(); self.pending_history = Some(PendingHistory { line: line.clone(), @@ -4696,7 +4689,10 @@ impl TerminalView { }); self.rerank_history(cwd.as_deref()); } + // The dedup above can shift entries down, so nothing may keep an + // index into `history` across a submit. self.history_nav = None; + self.last_word_nav = None; self.history_stash.clear(); self.history_prefix.clear(); self.close_completion(); @@ -4806,12 +4802,6 @@ impl TerminalView { fn rerank_history(&mut self, cwd: Option<&std::path::Path>) { let cwd_str = cwd.and_then(|p| p.to_str()); - self.history_ranked = super::history::rank_by_frecency( - &self.history, - &self.history_counts, - &self.history_cwds, - cwd_str, - ); self.history_frecency = super::history::frecency_scores( &self.history, &self.history_counts, @@ -4836,15 +4826,42 @@ impl TerminalView { super::history::append(&self.history_scope, &p.line, p.cwd.as_deref(), p.ts, exit); } + /// The inline suggestion: the most recent entry that extends the line, + /// preferring one run in the current directory and falling back to the + /// most recent anywhere. Commands that last exited non-zero are never + /// offered. Pure recency, not frecency, so the ghost names what ↑ would + /// recall rather than an older command that merely ran more often; Ctrl+R + /// is where frecency ranks. fn ghost_suggestion(&self) -> Option { if self.cmd.is_empty() || self.cmd.cursor() != self.cmd.len() { return None; } let line = self.cmd.text(); - self.history_ranked - .iter() - .find(|h| h.len() > line.len() && h.starts_with(&line)) - .cloned() + let cwd = self.ranked_cwd.as_deref().and_then(|p| p.to_str()); + let mut elsewhere = None; + for h in self.history.iter().rev() { + if h.len() <= line.len() || !h.starts_with(&line) { + continue; + } + if self + .history_meta + .get(h) + .and_then(|m| m.exit) + .is_some_and(|code| code != 0) + { + continue; + } + let here = cwd.is_some_and(|dir| { + self.history_cwds + .get(h) + .is_some_and(|dirs| dirs.contains(dir)) + }); + if here || cwd.is_none() { + return Some(h.clone()); + } + elsewhere.get_or_insert(h); + } + elsewhere.cloned() } fn note_integration_gap(&mut self, cx: &mut Context) { @@ -14038,7 +14055,7 @@ mod gpui_tests { window .update(cx, |view, _, cx| { assert!(view.input_active(), "the local editor owns a fresh prompt"); - view.history_ranked = vec!["git log --oneline".to_string()]; + view.history = vec!["git log --oneline".to_string()]; view.cmd.set("git l"); view.handle_editor_key(&key("ctrl-e"), cx); @@ -14062,7 +14079,7 @@ mod gpui_tests { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { - view.history_ranked = vec!["git log --oneline".to_string()]; + view.history = vec!["git log --oneline".to_string()]; view.cmd.set_with_cursor("git l", 2); view.handle_editor_key(&key("ctrl-e"), cx); @@ -16368,6 +16385,181 @@ mod gpui_tests { "a spawned pane counts its first agent, once" ); } + /// Seeds a pane's history for the ghost tests: `(command, dirs, exit)`, + /// oldest first, with `cwd` as the directory the pane is in. + fn seed_ghost_history( + view: &mut TerminalView, + entries: &[(&str, &[&str], Option)], + cwd: Option<&str>, + ) { + view.history = entries.iter().map(|(cmd, _, _)| cmd.to_string()).collect(); + view.history_cwds.clear(); + view.history_meta.clear(); + for (cmd, dirs, exit) in entries { + view.history_cwds.insert( + cmd.to_string(), + dirs.iter().map(|d| d.to_string()).collect(), + ); + view.history_meta.insert( + cmd.to_string(), + crate::terminal::history::EntryMeta { + ts: None, + exit: *exit, + }, + ); + } + view.rerank_history(cwd.map(std::path::Path::new)); + } + + #[gpui::test] + fn ghost_suggests_the_command_just_run_over_a_frequent_one(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, _| { + seed_ghost_history( + view, + &[ + ("git checkout main", &["/work/proj"], Some(0)), + ("git commit -m x", &["/work/proj"], Some(0)), + ], + Some("/work/proj"), + ); + view.history_counts + .insert("git checkout main".to_string(), 20); + view.rerank_history(Some(std::path::Path::new("/work/proj"))); + view.cmd.set("git c"); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("git commit -m x"), + "recency decides the ghost, however often the older one ran" + ); + }) + .unwrap(); + } + + #[gpui::test] + fn ghost_prefers_this_directory_then_falls_back_to_anywhere(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, _| { + seed_ghost_history( + view, + &[ + ("cargo build", &["/work/proj"], None), + ("cargo test --all", &["/elsewhere"], None), + ("npm run dev", &["/elsewhere"], None), + ], + Some("/work/proj"), + ); + view.cmd.set("cargo "); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("cargo build"), + "an older match run here beats a newer one run elsewhere" + ); + view.cmd.set("npm "); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("npm run dev"), + "with nothing run here, the newest match anywhere is offered" + ); + view.cmd.set("python "); + assert_eq!(view.ghost_suggestion(), None); + }) + .unwrap(); + } + + #[gpui::test] + fn ghost_skips_commands_that_failed(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, _| { + seed_ghost_history( + view, + &[ + ("make test", &["/work/proj"], Some(0)), + ("make tset", &["/work/proj"], Some(2)), + ("make lint", &["/work/proj"], Some(1)), + ], + Some("/work/proj"), + ); + view.cmd.set("make t"); + assert_eq!( + view.ghost_suggestion().as_deref(), + Some("make test"), + "the newer typo exited 2, so the older success is offered" + ); + view.cmd.set("make l"); + assert_eq!( + view.ghost_suggestion(), + None, + "a command whose only run failed is never offered" + ); + }) + .unwrap(); + } + + #[gpui::test] + fn ghost_names_what_up_arrow_recalls(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, _, cx| { + seed_ghost_history( + view, + &[ + ("git status", &[], None), + ("git log --oneline", &[], None), + ("ls", &[], None), + ("git push", &[], None), + ("echo hi", &[], None), + ], + None, + ); + view.history_counts.insert("git status".to_string(), 50); + view.rerank_history(None); + for typed in ["git", "git l", "e", "l"] { + view.history_nav = None; + view.cmd.set(typed); + let ghost = view.ghost_suggestion(); + view.handle_editor_key(&key("up"), cx); + assert_eq!( + ghost.as_deref(), + Some(view.cmd.text().as_str()), + "ghost and ↑ disagree on {typed:?}" + ); + } + }) + .unwrap(); + } + + #[gpui::test] + fn submitting_a_command_again_moves_it_to_the_newest_entry(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + prompt_ready(&window, cx, &mut daemon); + + window + .update(cx, |view, _, cx| { + view.history = ["git status", "ls", "make"] + .into_iter() + .map(String::from) + .collect(); + view.cmd.set("git status"); + view.submit_command(cx); + assert_eq!( + view.history, + ["ls", "make", "git status"], + "one entry per command, at its latest run, as a reload leaves it" + ); + + view.cmd.set(""); + view.handle_editor_key(&key("up"), cx); + assert_eq!(view.cmd.text(), "git status"); + view.handle_editor_key(&key("up"), cx); + assert_eq!(view.cmd.text(), "make", "↑ never lands on a command twice"); + }) + .unwrap(); + } } /// The window between the shell reporting a prompt and its line editor From b5cb6efe7a23b7678f6dfe193e2f3389a08490b0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:49 +0800 Subject: [PATCH 17/20] =?UTF-8?q?style(sidebar):=20mark=20pinned=20groups?= =?UTF-8?q?=20with=20=E2=97=86=20instead=20of=20a=20pin=20icon,=20drop=20t?= =?UTF-8?q?he=20divider=20(#972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin icon read as a smudge at header size. Kept groups now carry a small ◆ beside their name — every kept group, label groups too, since with the divider gone it is the one thing that tells kept from derived. On a folder group the mark is still the click that unpins it, and the hover button that pins an auto group shows the same character. The divider is no longer drawn at rest. It keeps its (smaller) place in the layout and its recorded bounds, so pinning a header by dragging it up and handing a tab back by dropping it below behave exactly as before, and it still shows as a line while it is the drop target. --- CHANGELOG.md | 36 ++++++------ assets/icons/pin.svg | 1 - docs/window/sidebar.mdx | 22 ++++---- src/ui/assets.rs | 1 - src/ui/tab_sidebar.rs | 119 ++++++++++++++++++++++++++-------------- 5 files changed, 108 insertions(+), 71 deletions(-) delete mode 100644 assets/icons/pin.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8501ee..256b157c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,24 +88,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 command once, the same as after a restart. - **The sidebar groups tabs by repo automatically; pin what you want to keep** - (#955). Groups now come in two halves, split by a divider. Below it, every - tab you have not pinned is filed under its git repository, and an SSH tab - under the host it is on rather than its remote path — `/home/ubuntu` on two - machines used to share a header. Above it sit the groups you keep, in the - order you drag them into, until you delete them. A pinned group can keep a - folder: a tab whose working directory enters it joins it (the deepest folder - wins when they nest, and a worktree of a pinned repo counts), while a tab you - drag out stays out until it leaves the folder and comes back. Pin an auto - group with the pin on its header or by dragging the header above the - divider; pin a folder by dropping it from Finder, with **Pin as Group** in the - Files panel, or with **Open Folder as Group…** in the palette. **New Group** - in the palette or on a tab's right-click makes a label group. Deleting a - group closes nothing — its tabs go back to auto grouping — and dragging a tab - below the divider does the same for one tab. Groups, their order and which - are folded are stored with the workspace, so every window onto it agrees. - **Settings → Window & Tabs → Auto grouping** replaces the three-way *Sidebar - grouping* choice; off, unpinned tabs sit in one flat list under the pinned - groups. Scratch is now **Ungrouped**. + (#955). Groups now come in two halves. Below, every tab you have not pinned is + filed under its git repository, and an SSH tab under the host it is on rather + than its remote path — `/home/ubuntu` on two machines used to share a header. + Above sit the groups you keep, each marked ◆ beside its name, in the order you + drag them into, until you delete them. A pinned group can keep a folder: a tab + whose working directory enters it joins it (the deepest folder wins when they + nest, and a worktree of a pinned repo counts), while a tab you drag out stays + out until it leaves the folder and comes back. Pin an auto group with the ◆ on + its header or by dragging the header up among the pinned ones; pin a folder by + dropping it from Finder, with **Pin as Group** in the Files panel, or with + **Open Folder as Group…** in the palette. **New Group** in the palette or on a + tab's right-click makes a label group. Deleting a group closes nothing — its + tabs go back to auto grouping — and dragging a tab below the pinned groups + does the same for one tab. Groups, their order and which are folded are stored + with the workspace, so every window onto it agrees. **Settings → Window & Tabs + → Auto grouping** replaces the three-way *Sidebar grouping* choice; off, + unpinned tabs sit in one flat list under the pinned groups. Scratch is now + **Ungrouped**. - **The New Tab menu names three shells, not every one the machine has.** A stock macOS box reports nine, so the `+` menu opened on a column of `csh`, diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg deleted file mode 100644 index 05568e0d..00000000 --- a/assets/icons/pin.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index a5c32cd5..e53b214d 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -18,17 +18,19 @@ is doing. The sidebar groups tabs by repository automatically. Pin what you want to keep. -**Auto groups** sit below a divider. Every tab you have not pinned is filed -under the git repository its working directory is in — a linked worktree under -the repository it belongs to, a submodule under itself — and an SSH tab under -the host it is connected to, so `/home/ubuntu` on two machines is two groups. +**Auto groups** sit below the pinned ones. Every tab you have not pinned is +filed under the git repository its working directory is in — a linked worktree +under the repository it belongs to, a submodule under itself — and an SSH tab +under the host it is connected to, so `/home/ubuntu` on two machines is two +groups. Everything else collects in a trailing **Ungrouped** section. An auto group follows the tab's working directory, not its history: `cd` into another repository and the row moves; switching branches never does. When its last tab leaves, the group is gone. -**Pinned groups** sit above the divider, in the order you put them, and stay -until you delete them — an empty one keeps its place with a **+ New Tab** row. +**Pinned groups** sit at the top, each marked ◆ beside its name, in the order +you put them, and stay until you delete them — an empty one keeps its place +with a **+ New Tab** row. A tab in a pinned group never leaves it on its own. There are two kinds: - A **folder group** keeps a directory. A tab whose working directory *enters* @@ -36,7 +38,7 @@ A tab in a pinned group never leaves it on its own. There are two kinds: repository the folder is. When folders nest, the deepest one wins, so pinning a monorepo and one package in it files the package's tabs under the package. A tab you drag out while it is still inside stays out until it leaves the - folder and comes back. Hover the header for the folder's path; click its pin + folder and comes back. Hover the header for the folder's path; click its ◆ to unpin it, and its tabs go back to auto groups. - A **label group** is just a name, for tabs that belong together for a reason no directory shows. Tabs go in and out of it by hand. @@ -45,7 +47,7 @@ Ways to pin: | To get | Do this | |---|---| -| A folder group from an auto group | Click the pin on its header, or drag the header above the divider | +| A folder group from an auto group | Click the ◆ on its header, or drag the header up among the pinned groups | | A folder group from a folder | Drop it from Finder onto the sidebar, choose **Pin as Group** on it in the Files panel, or run **Open Folder as Group…** from the command palette | | A label group | Right-click a tab → **Move to Group → New Group…**, or run **New Group** from the command palette | @@ -92,8 +94,8 @@ they simply stop opening the overlay. Drag a row to reorder it within its group, or drag a group header to move the group — pinned groups among pinned groups, auto groups among auto groups. Drag -a row onto a pinned group to put it there, or anywhere below the divider to -hand it back to auto grouping. A row cannot be dragged into an auto group: its +a row onto a pinned group to put it there, or anywhere below the pinned groups +to hand it back to auto grouping. A row cannot be dragged into an auto group: its membership comes from the working directory, so `cd` is what moves it. Drag a row out over the panes instead and it stops being a session of its own: diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 73ffca40..1731232b 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -34,7 +34,6 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), - "icons/pin.svg" => include_bytes!("../../assets/icons/pin.svg"), "icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"), "icons/folder-closed.svg" => include_bytes!("../../assets/icons/folder-closed.svg"), "icons/folder-open.svg" => include_bytes!("../../assets/icons/folder-open.svg"), diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index e5274f79..4bf498fc 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -34,6 +34,17 @@ const GRAB_HANDLE_W: f32 = 48.; const ROW_GAP: f32 = 2.; +/// What marks a kept group, and what the header button that keeps one reads. +/// +/// A character rather than an icon: at a header's size a drawn pin is a +/// smudge, and the mark only has to say "kept" beside the name, the way a +/// bullet does. With no line between kept and derived groups it is also the +/// only thing that tells the two apart, so every kept group carries it. +const PIN_MARK: &str = "\u{25C6}"; + +/// The mark's size: small enough to sit under the header text's x-height. +const PIN_MARK_SIZE: f32 = 8.; + /// A single-line tab row: one line of `text_sm` and a little air, the same /// 28px the search field and the workspace chip above it stand at. const ROW_HEIGHT: f32 = 28.; @@ -1283,37 +1294,39 @@ impl Tty7App { .child(Icon::new(IconName::ChevronRight).xsmall()), ) }) - // The pin marks a folder group: kept, and keeping a folder. - // It is also the way to stop keeping it — a click unpins, - // and the folder's tabs fall back to the groups their cwds - // resolve to. A label group has no such mark: it sits above - // the divider, and there is no folder to fall back on. - .when_some( - pinned_id.filter(|_| pinned_folder.is_some()), - |header, id| { - header.child( - div() - .id(("sidebar-group-unpin", group_ix)) - .flex_shrink_0() - .cursor_pointer() - .hover(|s| s.text_color(cx.theme().foreground)) - .child(Icon::empty().path("icons/pin.svg").xsmall()) - .tooltip(|window, cx| { - gpui_component::tooltip::Tooltip::new(t( - L10nKey::SidebarUnpinGroup, - )) - .build(window, cx) - }) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation() - }) - .on_click(cx.listener(move |this, _, _window, cx| { - cx.stop_propagation(); - this.delete_group(id, cx); - })), - ) - }, - ) + // Every kept group carries the mark; it is what sets them + // apart from the derived groups below. On a folder group it + // is also the way to stop keeping it — a click unpins, and + // the folder's tabs fall back to the groups their cwds + // resolve to. A label group's mark is only a mark: without a + // folder there is nothing to fall back on, so letting go of + // it is Delete Group, in its menu, not a stray click. + .when_some(pinned_id, |header, id| { + let mark = div() + .flex_shrink_0() + .text_size(px(PIN_MARK_SIZE)) + .line_height(px(PIN_MARK_SIZE)) + .child(PIN_MARK); + header.child(match pinned_folder.is_some() { + false => mark.into_any_element(), + true => mark + .id(("sidebar-group-unpin", group_ix)) + .cursor_pointer() + .hover(|s| s.text_color(cx.theme().foreground)) + .tooltip(|window, cx| { + gpui_component::tooltip::Tooltip::new(t( + L10nKey::SidebarUnpinGroup, + )) + .build(window, cx) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, _, _window, cx| { + cx.stop_propagation(); + this.delete_group(id, cx); + })) + .into_any_element(), + }) + }) .child(match renaming_group { Some(input) => div() .id(("sidebar-group-rename", group_ix)) @@ -1823,8 +1836,14 @@ impl Tty7App { .child(handle) } - /// The line between the kept groups and the derived ones, recording where - /// it was drawn so a drag next frame can tell which side it is on. + /// The boundary between the kept groups and the derived ones, recording + /// where it lies so a drag next frame can tell which side it is on. + /// + /// Not drawn at rest — the mark on each kept header already says which + /// side a group is on, and a hairline across the list was one more rule + /// for the eye to cross. It keeps its place in the layout all the same, so + /// nothing jumps when a drag starts, and shows as a line only while it is + /// the target: when letting go would pin a header or hand a tab back. /// /// With nothing pinned yet it only appears while an auto header is being /// carried, and then as a drop zone rather than a hairline: a one-pixel @@ -1833,9 +1852,10 @@ impl Tty7App { /// back to auto grouping. fn sidebar_divider(&self, lit: bool, zone: bool, cx: &Context) -> AnyElement { let bounds = self.sidebar_divider.clone(); - let ink = match lit { - true => cx.theme().drag_border, - false => cx.theme().sidebar_border, + let ink = match (lit, zone) { + (true, _) => cx.theme().drag_border, + (false, true) => cx.theme().sidebar_border, + (false, false) => gpui::transparent_black(), }; let body = match zone { true => h_flex() @@ -1862,7 +1882,9 @@ impl Tty7App { .relative() .w_full() .px_2() - .pt(px(10.)) + // Less than when it was a drawn rule: the next header's own top + // margin already opens the gap between the two kinds of group. + .pt(px(4.)) .pb(px(2.)) .child( canvas(move |b, _window, _cx| bounds.set(Some(b)), |_, _, _, _| {}) @@ -1895,7 +1917,7 @@ impl Tty7App { fade.a = 0.; let muted = cx.theme().muted_foreground; let ink = cx.theme().foreground; - let button = |id: &'static str, path: &'static str, tip: L10nKey| { + let button = |id: &'static str, glyph: AnyElement, tip: L10nKey| { div() .id((id, group_ix)) .flex_shrink_0() @@ -1904,7 +1926,7 @@ impl Tty7App { .cursor_pointer() .text_color(muted) .hover(move |s| s.text_color(ink)) - .child(Icon::empty().path(path).xsmall()) + .child(glyph) .tooltip(move |window, cx| { gpui_component::tooltip::Tooltip::new(t(tip)).build(window, cx) }) @@ -1914,7 +1936,19 @@ impl Tty7App { buttons = buttons.child( button( "sidebar-group-pin", - "icons/pin.svg", + // The mark the group will carry once kept, the size of + // the icon beside it so the two buttons line up. + div() + .size(px(12.)) + .flex() + .items_center() + .justify_center() + .text_size(px(PIN_MARK_SIZE)) + // Its own line box: the header's is several times the + // mark's size and would drop it below the "+" beside it. + .line_height(px(PIN_MARK_SIZE)) + .child(PIN_MARK) + .into_any_element(), L10nKey::SidebarPinGroup, ) .on_mouse_down( @@ -1930,7 +1964,10 @@ impl Tty7App { buttons = buttons.child( button( "sidebar-group-add", - "icons/plus.svg", + Icon::empty() + .path("icons/plus.svg") + .xsmall() + .into_any_element(), L10nKey::SidebarGroupNewTab, ) .on_mouse_down( From 334734b0b62f5fd4087313d4babce132ef37c69c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:31:58 +0800 Subject: [PATCH 18/20] feat(links): make Windows file paths clickable (#971) * feat(links): make Windows file paths clickable Drive-letter paths with either separator and UNC shares resolve as file links, including when CJK prose or a Markdown link is glued onto them. Windows paths are spelled with backslashes and an upper-case drive so Explorer can open and reveal them, the Windows join is textual so a Mac asking a Windows host sends one spelling, and a POSIX pane looks a drive path up under /mnt/ for WSL. Drive-relative tokens (a:b, C:, C:notes.txt) are never probed. Closes #965 * feat(links): quoted paths with spaces and Open with Default App A path enclosed in matching double quotes, single quotes or backticks is read as one candidate even when it contains spaces, with a line:column location inside the quotes or right after the closing one. The quoted span must look like a path (a separator, no space at either end, at most 260 chars) and yields a single reading, so quoted prose costs one lookup. Unquoted spaces still end a path. The file-link context menu gains Open with Default App, which always uses the OS association. It is shown only for local files and hidden when link_file_open is already system. The OS opener and Reveal now spell Windows paths with backslashes before handing them to Explorer. --- CHANGELOG.md | 23 ++ src/core/actions.rs | 1 + src/terminal/search.rs | 731 +++++++++++++++++++++++++++++++++++++++-- src/terminal/view.rs | 70 +++- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + 8 files changed, 805 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 256b157c..8d8775bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 pane in front as before, and an ended session still says so. The key is `ssh_tab_title` (`dynamic`, `profile-name`, `hostname`). +- **Windows file paths are links** (#965). `C:\Users\me\a.png`, + `c:/Users/me/a.png` and `\\server\share\a.png` underline and open like any + other file path, with a `:10:2`, `(10,2)` or `#L10` location kept. The path + is found when Chinese prose is glued straight onto it + (`图片已保存到:c:/Users/me/a.png`) and inside a Markdown link + (`![chart](c:/out/chart.png)`), and is handed on with backslashes so + Explorer opens and reveals it — a forward-slashed path used to open + Documents instead. In a WSL pane a drive path is looked up under + `/mnt/`. A letter and a colon alone (`a:b`, `C:`, `C:notes.txt`) is + never read as a drive. + +- **A quoted path may contain spaces** (#965). `"C:\Program Files\app\app.exe"`, + `'/Users/me/My Docs/a.txt'` and the same in backticks are one link, with a + `:10:2` or `(10,2)` location read inside the quotes or just after the + closing one. Only quotes do this: an unquoted space still ends a path, and + quoted prose that is not written like a path — no separator, a space at + either end, over 260 characters — is left alone. + +- **"Open with Default App" in a file link's right-click menu** (#965). It + hands the file to whatever the OS has it associated with, whatever + `link_file_open` makes "Open" do. Shown for files on this machine only, and + not when "Open" already uses the system opener. + ### Changed - **The command-line ghost suggests what you ran last, not what you ran diff --git a/src/core/actions.rs b/src/core/actions.rs index 201edf58..b01cf6d0 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -27,6 +27,7 @@ actions!( CloseTabsToTheRight, CopyWorkingDirectory, OpenLinkUnderPointer, + OpenLinkWithDefaultApp, RevealLinkUnderPointer, CopyLinkPathUnderPointer, MarkTabUnread, diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 26e1f1ee..0be17715 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -22,6 +22,10 @@ const MAX_MATCHES: usize = 10_000; /// from turning a hover into a burst of filesystem calls. const MAX_FILE_CANDIDATES: usize = 8; +/// The longest quoted span read as one path with spaces in it. Windows' +/// classic `MAX_PATH`: past this a quote is holding a sentence, not a path. +const MAX_QUOTED_PATH: usize = 260; + /// How long a printing pane has to stay quiet before an open search bar /// rescans it. Short enough that a command's output is re-counted by the time /// the eye gets back to the bar, long enough that a flood costs one scan per @@ -794,35 +798,139 @@ impl PathStyle { // A UNC share, or a drive letter with a separator behind it. // `C:foo` is drive-*relative* and deliberately not included, which // is what `Path::is_absolute` says on Windows too. - PathStyle::Windows => { - if path.starts_with("\\\\") { - return true; - } - let mut chars = path.chars(); - chars.next().is_some_and(|c| c.is_ascii_alphabetic()) - && chars.next() == Some(':') - && matches!(chars.next(), Some('\\' | '/')) - } + PathStyle::Windows => path.starts_with("\\\\") || drive_root(path).is_some(), } } + /// The one path a token that names its own root stands for, spelled the + /// way the pane's host can look it up — `None` for a token that has to be + /// measured from somewhere. + /// + /// A Windows pane gets its path back with every separator a `\` and the + /// drive letter upper-cased. `c:/Users/me/a.png` is what Node prints on + /// Windows, and while the filesystem takes either slash, Explorer does + /// not: handed a forward-slashed path to open or `/select,` it shows + /// Documents and reports success. + /// + /// A POSIX pane has no drives, but it may well be a WSL distro looking at + /// the Windows machine it runs on — a Windows tool run through interop, a + /// `wslpath -w`, a build log from the other side. There `C:\Users` is + /// `/mnt/c/Users`, and that is what the host is asked about. Anywhere else + /// the mount is not there and the probe simply misses, so nothing + /// underlines; that beats measuring `C:\Users` from the pane's directory, + /// which could only ever be nonsense. + pub fn rooted(self, path: &str) -> Option { + match self { + PathStyle::Posix if path.starts_with('/') => Some(path.to_string()), + PathStyle::Posix => { + let (drive, rest) = drive_root(path)?; + let rest = rest.replace('\\', "/"); + Some(format!( + "/mnt/{}/{}", + drive.to_ascii_lowercase(), + rest.trim_start_matches('/') + )) + } + PathStyle::Windows => self.is_absolute(path).then(|| windows_spelling(path)), + } + } + + /// Whether a token only means something against the current directory of + /// one particular drive: `C:notes.txt`, or `C:` on its own. + /// + /// There is no such thing as "the current directory of drive C" for a + /// pane — only for a process — so the one answer that would exist is tty7's + /// own, which has nothing to do with what the pane printed. And a bare + /// letter and a colon is far more often prose than a path: `a:b`, `x:y`. + fn is_drive_relative(self, path: &str) -> bool { + let mut chars = path.chars(); + self == PathStyle::Windows + && chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.next() == Some(':') + && !matches!(chars.next(), Some('\\' | '/')) + } + /// `rel` measured from `root`, spelled the way the pane's host spells it. /// - /// The POSIX arm joins textually because `Path::join` would reach for this + /// Both arms join textually because `Path::join` would reach for this /// machine's separator: on Windows it turns `/home/u` and `src/lib.rs` /// into `/home/u\src/lib.rs`, which the Linux box on the other end of the - /// probe cannot stat. + /// probe cannot stat, and on a Mac it turns `C:\proj` and `src\lib.rs` + /// into `C:\proj/src\lib.rs` for a Windows host. + /// + /// A Windows `rel` that starts with a separator is rooted on the root's + /// own drive (or share), the way `cd \etc` is in a `cmd.exe` sitting on C:. pub fn join(self, root: &Path, rel: &str) -> PathBuf { + let root = root.to_string_lossy(); match self { - PathStyle::Posix => PathBuf::from(format!( - "{}/{rel}", - root.to_string_lossy().trim_end_matches('/') - )), - PathStyle::Windows => root.join(rel), + PathStyle::Posix => PathBuf::from(format!("{}/{rel}", root.trim_end_matches('/'))), + PathStyle::Windows => { + let joined = match rel.starts_with(['\\', '/']) { + true => format!("{}{rel}", windows_prefix(&root)), + false => format!("{}\\{rel}", root.trim_end_matches(['\\', '/'])), + }; + PathBuf::from(windows_spelling(&joined)) + } } } } +/// The drive letter a path starts with, and everything after the colon — only +/// when a separator follows it, which is what makes it absolute. `C:\x` and +/// `c:/x` are; `C:x`, `C:` and `12:30` are not. +fn drive_root(path: &str) -> Option<(char, &str)> { + let mut chars = path.chars(); + let drive = chars.next().filter(char::is_ascii_alphabetic)?; + let rest = path.strip_prefix(drive)?.strip_prefix(':')?; + rest.starts_with(['\\', '/']).then_some((drive, rest)) +} + +/// The part of a Windows path that names its drive or share: `C:` out of +/// `C:\proj`, `\\server\share` out of `\\server\share\proj`. Empty when there +/// is none to take. +fn windows_prefix(path: &str) -> &str { + if drive_root(path).is_some() || path.len() == 2 && path.ends_with(':') { + return &path[..2]; + } + if path.starts_with("\\\\") || path.starts_with("//") { + // Past the two leading separators, the server, and the share. + let mut seps = path + .char_indices() + .skip(2) + .filter(|&(_, c)| matches!(c, '\\' | '/')); + seps.next(); + return match seps.next() { + Some((end, _)) => &path[..end], + None => path, + }; + } + "" +} + +/// `path` spelled the way `style`'s own tools want it — for handing a path to +/// something outside tty7, like the OS opener. Only a Windows spelling +/// changes, and only a path that is valid UTF-8: rewriting anything else +/// through a lossy string would name a different file. +pub(super) fn spelled_for(style: PathStyle, path: &Path) -> PathBuf { + match (style, path.to_str()) { + (PathStyle::Windows, Some(text)) => PathBuf::from(windows_spelling(text)), + _ => path.to_path_buf(), + } +} + +/// A Windows path the way Windows itself writes one: `\` throughout and an +/// upper-case drive letter. Every Windows API takes `/` too, but Explorer — +/// the system opener and the "Reveal" menu item — does not. +fn windows_spelling(path: &str) -> String { + let mut out = path.replace('/', "\\"); + if let Some((drive, _)) = drive_root(&out) + && drive.is_ascii_lowercase() + { + out.replace_range(..1, &drive.to_ascii_uppercase().to_string()); + } + out +} + /// Where a relative path printed by a pane is measured from. /// /// `local_home` is the part that is easy to miss. `~` has to become a real @@ -955,11 +1063,11 @@ impl FileCandidate { let Some(expanded) = expand_home(&self.path, roots) else { return Vec::new(); }; - if expanded.is_empty() { + if expanded.is_empty() || roots.style.is_drive_relative(&expanded) { return Vec::new(); } - if roots.style.is_absolute(&expanded) { - return vec![PathBuf::from(expanded)]; + if let Some(rooted) = roots.style.rooted(&expanded) { + return vec![PathBuf::from(rooted)]; } let mut out: Vec = Vec::new(); for root in &roots.dirs { @@ -981,7 +1089,7 @@ impl FileCandidate { /// `/etc/hosts` printed by a `cmd.exe` pane is not, and `Path` alone /// cannot tell those apart. pub fn is_rooted(&self, style: PathStyle) -> bool { - self.path.starts_with('~') || style.is_absolute(&self.path) + self.path.starts_with('~') || style.rooted(&self.path).is_some() } /// Whether the token is written enough like a path to be worth telling the @@ -990,11 +1098,12 @@ impl FileCandidate { /// /// A backslash counts only where it separates directories. In a POSIX /// pane it is an escape or an ordinary filename character, so `foo\ bar` - /// there is a word, not a path. + /// there is a word, not a path. A drive letter is one in any pane. pub fn looks_like_a_path(&self, style: PathStyle) -> bool { self.path.starts_with('~') || self.path.contains('/') || (style == PathStyle::Windows && self.path.contains('\\')) + || drive_root(&self.path).is_some() } } @@ -1012,8 +1121,9 @@ impl FileCandidate { /// separator: naming the prefixes costs a handful of readings where /// enumerating pairs costs a probe per pair, on every hover. pub(super) fn file_candidates_at(text: &str, col: usize) -> Vec { + let quoted = quoted_candidates_at(text, col); let Some((base, _, token)) = non_ws_token_at(text, col) else { - return Vec::new(); + return quoted; }; let chars: Vec = token.chars().collect(); let mut out: Vec = Vec::new(); @@ -1030,10 +1140,93 @@ pub(super) fn file_candidates_at(text: &str, col: usize) -> Vec { .cmp(&a.path.chars().count()) .then(a.start.cmp(&b.start)) }); + // A quote is the one place a path is allowed a space, and the writer put + // it there to say where the path starts and stops — so it is asked first. + for (i, candidate) in quoted.into_iter().enumerate() { + out.retain(|c| *c != candidate); + out.insert(i, candidate); + } out.truncate(MAX_FILE_CANDIDATES); out } +/// The path a pair of quotes holds around `col`, when it has a space in it. +/// +/// Whitespace ends a path everywhere else, and guessing where a path with +/// spaces stops in unquoted prose is how `see C:\Program Files for more` +/// would become a link to a folder that happens to exist. A quote is the +/// writer saying where it stops — `"C:\Program Files\app\app.exe"`, +/// `'/Users/me/My Docs/a.txt'` — so that is the only place a space is read as +/// part of a path. +/// +/// What is inside still has to be written like a path: no space at either +/// end, a separator somewhere, and short enough to be one. A quoted sentence +/// that clears all that and still names nothing costs one reading, the same +/// as a single unquoted word — never one per word inside it. +/// +/// A token with no space in it is left to the ordinary rules, which already +/// peel quotes off a path. A location may be written inside the quotes +/// (`"my file.rs:10:2"`) or straight after the closing one +/// (`"my file.rs":10:2`, `"my file.cs"(10,2)`). +fn quoted_candidates_at(text: &str, col: usize) -> Vec { + let chars: Vec = text.chars().collect(); + if col >= chars.len() { + return Vec::new(); + } + let Some((open, close)) = ['"', '\'', '`'] + .into_iter() + .filter_map(|q| { + let open = chars[..col].iter().rposition(|&c| c == q)?; + let close = col + chars[col..].iter().position(|&c| c == q)?; + (close > col).then_some((open, close)) + }) + // The innermost pair: `"it's '/a b/c'"` means the single-quoted one. + .min_by_key(|&(open, close)| close - open) + else { + return Vec::new(); + }; + let inner = &chars[open + 1..close]; + let path_shaped = inner.len() <= MAX_QUOTED_PATH + && inner.iter().any(|c| c.is_whitespace()) + && inner.first().is_some_and(|c| !c.is_whitespace()) + && inner.last().is_some_and(|c| !c.is_whitespace()) + && inner.iter().any(|&c| matches!(c, '/' | '\\')) + && !inner.iter().any(|c| c.is_control()); + if !path_shaped { + return Vec::new(); + } + let written: String = inner.iter().collect(); + let mut location = split_file_location(&written); + if location.line.is_none() { + let after: String = chars[close + 1..] + .iter() + .take_while(|c| !c.is_whitespace()) + .collect(); + let after = after.trim_end_matches([':', ',', ';']); + if !after.is_empty() { + let outside = split_file_location(&format!("{written}{after}")); + if outside.line.is_some() && outside.path == written { + location = outside; + } + } + } + let mut readings = Vec::with_capacity(2); + if location.literal_too && location.line.is_some() && location.path != written { + readings.push((written.clone(), None, None)); + } + readings.push((location.path, location.line, location.column)); + readings + .into_iter() + .map(|(path, line, column)| FileCandidate { + start: open + 1, + end: close - 1, + path, + line, + column, + }) + .collect() +} + /// The best reading of the token under `col`, which is what most of the /// carving tests are about. #[cfg(test)] @@ -1101,6 +1294,24 @@ fn left_cuts(chars: &[char]) -> Vec { if matches!(rest.first(), Some('a' | 'b')) && rest.get(1) == Some(&'/') { offsets.push(2); } + // A Windows drive glued onto whatever came before it: `保存到:C:\out`, + // `图片已生成c:/Users/me/a.png`. CJK prose puts no space in front of a + // path, and the label rule above cannot see these — the only ASCII + // colon in the token is the drive's own. The letter must not be the + // tail of an ASCII word, or `abc:/x` would read as drive `c:`. + if let Some(i) = (1..rest.len()).find(|&i| { + !rest[i - 1].is_ascii_alphanumeric() + && rest[i].is_ascii_alphabetic() + && rest.get(i + 1) == Some(&':') + && matches!(rest.get(i + 2), Some('\\' | '/')) + }) { + offsets.push(i); + } + // A Markdown link's target, which is what an agent prints a file as: + // `![chart](c:/out/chart.png)`, `[notes](docs/notes.md)`. + if let Some(i) = rest.windows(2).position(|w| w == [']', '(']) { + offsets.push(i + 2); + } for offset in offsets { let cut = base + offset; if cut < chars.len() && !cuts.contains(&cut) { @@ -2547,4 +2758,480 @@ mod tests { "the reading behind the `note:` label is still on the ladder" ); } + + /// A Windows pane sitting in `C:\proj`, the way the reporter of #965 saw it. + fn windows_roots() -> LinkRoots { + LinkRoots { + dirs: vec![PathBuf::from(r"C:\proj")], + local_home: false, + style: PathStyle::Windows, + } + } + + /// `link_at` over `line` with the pointer on the first char of `needle`, + /// against a filesystem holding exactly `exists` — and every path it was + /// asked about, so a test can say what was *not* looked up. + fn link_with( + line: &str, + needle: &str, + roots: &LinkRoots, + exists: &str, + ) -> (Option, Vec) { + let byte = line.find(needle).expect("needle in line"); + let col = line[..byte].chars().count(); + let mut asked = Vec::new(); + let mut probe = |path: &Path, _: bool| { + let path = path.to_string_lossy().into_owned(); + let hit = path == exists; + asked.push(path); + match hit { + true => Probe::Hit { is_dir: false }, + false => Probe::Miss, + } + }; + (link_at(line, col, roots, true, &mut probe), asked) + } + + /// The path and span a Windows pane underlines under `needle`. + fn windows_link(line: &str, needle: &str, exists: &str) -> (String, usize, usize) { + let (link, asked) = link_with(line, needle, &windows_roots(), exists); + let link = link.unwrap_or_else(|| panic!("{line:?}: no link, asked {asked:?}")); + match link.target { + LinkTarget::File { path, .. } => { + (path.to_string_lossy().into_owned(), link.start, link.end) + } + LinkTarget::Url(url) => panic!("{line:?}: expected a file link, got {url}"), + } + } + + /// The columns `needle` occupies in `line`, inclusive. + fn span_of(line: &str, needle: &str) -> (usize, usize) { + let byte = line.find(needle).expect("needle in line"); + let start = line[..byte].chars().count(); + (start, start + needle.chars().count() - 1) + } + + /// #965: the path exactly as the reporter pasted it, and its backslashed + /// twin. Either separator names the same file, and the file is handed on + /// spelled the way Explorer will take it. + #[test] + fn a_drive_letter_path_is_a_link_with_either_separator() { + let want = r"C:\Users\xxxxx\images\gen_gptimage2_gen_20260925_220725.png"; + for written in [ + "c:/Users/xxxxx/images/gen_gptimage2_gen_20260925_220725.png", + r"C:\Users\xxxxx\images\gen_gptimage2_gen_20260925_220725.png", + r"C:/Users\xxxxx/images\gen_gptimage2_gen_20260925_220725.png", + ] { + let line = format!("saved {written} ok"); + let (path, start, end) = windows_link(&line, "images", want); + assert_eq!(path, want, "{line:?}"); + assert_eq!((start, end), span_of(&line, written), "{line:?}"); + } + } + + /// Prose and markup around a Windows path is not part of it — the same + /// trimming a POSIX path gets. + #[test] + fn a_drive_letter_path_sheds_the_punctuation_around_it() { + let want = r"C:\out\a.png"; + for (line, path) in [ + ("wrote c:/out/a.png.", "c:/out/a.png"), + (r"wrote C:\out\a.png, then quit", r"C:\out\a.png"), + (r"(see C:\out\a.png)", r"C:\out\a.png"), + (r#"open "C:\out\a.png";"#, r"C:\out\a.png"), + (r"`C:\out\a.png`", r"C:\out\a.png"), + ("「c:/out/a.png」。", "c:/out/a.png"), + ("", "c:/out/a.png"), + ("│ C:/out/a.png │", "C:/out/a.png"), + (r"C:\out\a.png:", r"C:\out\a.png"), + ] { + let (got, start, end) = windows_link(line, "out", want); + assert_eq!(got, want, "{line:?}"); + assert_eq!((start, end), span_of(line, path), "{line:?}"); + } + } + + /// Compilers on Windows write the location onto the path; the drive's own + /// colon must not be mistaken for one. + #[test] + fn a_drive_letter_path_keeps_its_line_and_column() { + let want = r"C:\src\main.rs"; + for (line, loc) in [ + (r"C:\src\main.rs:10:2: error", (Some(10), Some(2))), + ("c:/src/main.rs:10", (Some(10), None)), + (r"C:\src\main.rs(10,2): error C2065", (Some(10), Some(2))), + ("c:/src/main.rs#L10", (Some(10), None)), + (r#"File "C:\src\main.rs", line 7"#, (Some(7), None)), + ] { + let (link, asked) = link_with(line, "src", &windows_roots(), want); + match link.map(|l| l.target) { + Some(LinkTarget::File { + path, + line: l, + column, + .. + }) => { + assert_eq!(path, PathBuf::from(want), "{line:?}"); + assert_eq!((l, column), loc, "{line:?}"); + } + other => panic!("{line:?}: {other:?}, asked {asked:?}"), + } + } + } + + /// Chinese output glues a path straight onto the sentence in front of it, + /// often behind a full-width colon — which is how an agent reports the + /// file it just wrote in #965. The sentence is not part of the path. + #[test] + fn a_drive_letter_path_is_found_inside_the_prose_glued_onto_it() { + let want = r"C:\Users\me\a.png"; + for (line, path) in [ + ("图片已保存到:c:/Users/me/a.png", "c:/Users/me/a.png"), + ("图片已生成c:/Users/me/a.png", "c:/Users/me/a.png"), + (r"路径:C:\Users\me\a.png", r"C:\Users\me\a.png"), + ("--out=c:/Users/me/a.png", "c:/Users/me/a.png"), + ("output:c:/Users/me/a.png", "c:/Users/me/a.png"), + ] { + let (got, start, end) = windows_link(line, "Users", want); + assert_eq!(got, want, "{line:?}"); + assert_eq!((start, end), span_of(line, path), "{line:?}"); + } + } + + /// An agent prints the image it made as Markdown. The target is the link; + /// the brackets and alt text are not. + #[test] + fn a_markdown_link_target_is_the_path() { + let want = r"C:\out\chart.png"; + for line in [ + "![chart](c:/out/chart.png)", + "see [the chart](c:/out/chart.png).", + ] { + let (got, start, end) = windows_link(line, "out", want); + assert_eq!(got, want, "{line:?}"); + assert_eq!((start, end), span_of(line, "c:/out/chart.png"), "{line:?}"); + } + let posix = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + }; + let line = "[notes](docs/notes.md)"; + let (link, asked) = link_with(line, "docs", &posix, "/home/u/proj/docs/notes.md"); + let link = link.unwrap_or_else(|| panic!("no link, asked {asked:?}")); + assert_eq!((link.start, link.end), span_of(line, "docs/notes.md")); + } + + #[test] + fn a_unc_share_is_a_link_that_stands_alone() { + let want = r"\\server\share\team\a.png"; + let line = r"copied to \\server\share\team\a.png"; + let (got, start, end) = windows_link(line, "server", want); + assert_eq!(got, want); + assert_eq!((start, end), span_of(line, want)); + } + + /// Text that merely has a letter and a colon in it is not a drive path, + /// and must never be looked up as one. + #[test] + fn a_letter_and_a_colon_is_not_a_drive_path() { + for (line, needle) in [ + // Drive-relative, or a bare drive: nothing a pane can resolve. + ("a:b", "a:b"), + ("x:y and more", "x:y"), + ("C:notes.txt", "C:notes"), + ("see C: now", "C:"), + // A time, a ratio, a port. + ("at 12:30 today", "12:30"), + ("ratio 16:9", "16:9"), + ("localhost:8080", "localhost"), + // A word that ends in a letter is not a drive behind it. + ("abc:/x/y", "abc"), + ("http:/x", "http"), + ] { + let (link, asked) = link_with(line, needle, &windows_roots(), ""); + assert_eq!(link, None, "{line:?}"); + for path in &asked { + assert!( + path.starts_with(r"C:\proj\"), + "{line:?} asked about {path:?}, which is not under the pane's directory" + ); + } + } + // The drive-relative ones are not asked about at all. + for line in ["a:b", "C:notes.txt"] { + let (_, asked) = link_with(line, line, &windows_roots(), ""); + assert!(asked.is_empty(), "{line:?} asked about {asked:?}"); + } + // And nothing in `abc:/x/y` is read as drive `c:`. + assert!( + file_candidates_at("abc:/x/y", 0) + .iter() + .all(|c| !c.path.starts_with("c:")), + "{:?}", + file_candidates_at("abc:/x/y", 0) + ); + } + + /// A Windows path that does not exist is not underlined: the probe is + /// the gate, exactly as for a POSIX one. + #[test] + fn a_missing_drive_letter_path_is_not_a_link() { + let (link, asked) = link_with("wrote c:/out/gone.png", "out", &windows_roots(), ""); + assert_eq!(link, None); + assert_eq!(asked, vec![r"C:\out\gone.png".to_string()]); + let candidate = + unresolved_candidate("wrote c:/out/gone.png", 8, PathStyle::Windows).unwrap(); + assert!(candidate.looks_like_a_path(PathStyle::Windows)); + assert!(candidate.is_rooted(PathStyle::Windows)); + } + + /// A POSIX pane has no drives, but a WSL distro can reach the Windows + /// machine it runs on through `/mnt/`, so that is what it is asked. + #[test] + fn a_posix_pane_reads_a_drive_letter_through_the_wsl_mount() { + let roots = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + }; + let want = "/mnt/c/Users/me/a.png"; + for written in ["c:/Users/me/a.png", r"C:\Users\me\a.png"] { + let line = format!("wrote {written}"); + let (link, asked) = link_with(&line, "Users", &roots, want); + assert_eq!(asked.first().map(String::as_str), Some(want), "{line:?}"); + let link = link.unwrap_or_else(|| panic!("{line:?}: no link")); + assert_eq!((link.start, link.end), span_of(&line, written)); + let candidate = file_candidate_at(&line, 8).unwrap(); + assert!(candidate.is_rooted(PathStyle::Posix), "{line:?}"); + assert!(candidate.looks_like_a_path(PathStyle::Posix), "{line:?}"); + } + // Where there is no such mount the probe misses and nothing underlines. + let (link, _) = link_with("wrote c:/Users/me/a.png", "Users", &roots, ""); + assert_eq!(link, None); + } + + #[test] + fn a_rooted_token_is_spelled_the_way_its_host_looks_it_up() { + let w = PathStyle::Windows; + assert_eq!(w.rooted("c:/Users/me").as_deref(), Some(r"C:\Users\me")); + assert_eq!(w.rooted(r"D:\x/y").as_deref(), Some(r"D:\x\y")); + assert_eq!( + w.rooted(r"\\server\share\x").as_deref(), + Some(r"\\server\share\x") + ); + for relative in ["src/lib.rs", r"src\lib.rs", "C:x", "C:", "/etc/hosts", ""] { + assert_eq!(w.rooted(relative), None, "{relative:?}"); + } + + let p = PathStyle::Posix; + assert_eq!(p.rooted("/etc/hosts").as_deref(), Some("/etc/hosts")); + assert_eq!(p.rooted(r"C:\Users\me").as_deref(), Some("/mnt/c/Users/me")); + assert_eq!(p.rooted("d:/").as_deref(), Some("/mnt/d/")); + for relative in ["src/lib.rs", "C:x", r"\\server\share", "12:30"] { + assert_eq!(p.rooted(relative), None, "{relative:?}"); + } + } + + /// The Windows join is textual so a Mac looking at a Windows host asks it + /// about a path it can stat, and so it can be pinned here at all. + #[test] + fn a_windows_join_spells_the_whole_path_the_windows_way() { + let w = PathStyle::Windows; + let join = + |root: &str, rel: &str| w.join(Path::new(root), rel).to_string_lossy().into_owned(); + assert_eq!(join(r"C:\proj", "src/lib.rs"), r"C:\proj\src\lib.rs"); + assert_eq!(join(r"C:\proj\", r"src\lib.rs"), r"C:\proj\src\lib.rs"); + assert_eq!(join("c:/proj", "a.txt"), r"C:\proj\a.txt"); + assert_eq!(join(r"C:\", "a.txt"), r"C:\a.txt"); + // A leading separator is rooted on the root's own drive or share. + assert_eq!(join(r"C:\proj", "/etc/hosts"), r"C:\etc\hosts"); + assert_eq!(join(r"D:\proj", r"\tmp\x"), r"D:\tmp\x"); + assert_eq!(join(r"\\srv\share\proj", r"\x"), r"\\srv\share\x"); + assert_eq!(join(r"\\srv\share\proj", "x"), r"\\srv\share\proj\x"); + assert_eq!(windows_prefix(r"\\srv\share"), r"\\srv\share"); + assert_eq!(windows_prefix("C:"), "C:"); + assert_eq!(windows_prefix("proj"), ""); + } + + #[test] + fn a_path_handed_to_the_os_is_spelled_its_way() { + assert_eq!( + spelled_for(PathStyle::Windows, Path::new("c:/Users/me/a b.png")), + PathBuf::from(r"C:\Users\me\a b.png") + ); + assert_eq!( + spelled_for(PathStyle::Windows, Path::new(r"\\srv/share/x")), + PathBuf::from(r"\\srv\share\x") + ); + assert_eq!( + spelled_for(PathStyle::Posix, Path::new("/Users/me/a\\b.png")), + PathBuf::from("/Users/me/a\\b.png"), + "a POSIX backslash is a filename character, not a separator" + ); + } + + fn posix_roots() -> LinkRoots { + LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + } + } + + /// The file link under the first char of `needle`, as (path, line, + /// column, start, end) — or `None`, with what was asked about. + fn file_link_with( + line: &str, + needle: &str, + roots: &LinkRoots, + exists: &str, + ) -> Result<(String, Option, Option, usize, usize), Vec> { + let (link, asked) = link_with(line, needle, roots, exists); + match link { + Some(LinkMatch { + start, + end, + target: + LinkTarget::File { + path, line, column, .. + }, + }) => Ok(( + path.to_string_lossy().into_owned(), + line, + column, + start, + end, + )), + _ => Err(asked), + } + } + + /// A quote is where a path is allowed its spaces: every word of it is the + /// same link, and the quotes are not part of it. + #[test] + fn a_quoted_windows_path_keeps_its_spaces() { + let want = r"C:\Program Files\foo\bar.exe"; + for line in [ + r#"run "C:\Program Files\foo\bar.exe" now"#, + r"run 'C:\Program Files\foo\bar.exe' now", + r"run `C:\Program Files\foo\bar.exe` now", + r#"run "c:/Program Files/foo/bar.exe" now"#, + ] { + let written = &line[5..line.len() - 5]; + for needle in ["Program", "Files", "bar.exe"] { + let got = file_link_with(line, needle, &windows_roots(), want); + assert_eq!( + got, + Ok(( + want.to_string(), + None, + None, + span_of(line, written).0, + span_of(line, written).1 + )), + "{line:?} at {needle:?}" + ); + } + } + } + + #[test] + fn a_quoted_posix_path_keeps_its_spaces_and_its_location() { + let roots = posix_roots(); + let docs = "/Users/me/My Docs/a.txt"; + let line = "open '/Users/me/My Docs/a.txt' please"; + let (start, end) = span_of(line, docs); + assert_eq!( + file_link_with(line, "Docs", &roots, docs), + Ok((docs.to_string(), None, None, start, end)) + ); + + let file = "/home/u/proj/src/my file.rs"; + for (line, loc) in [ + ( + r#"error: "src/my file.rs:10:2" failed"#, + (Some(10), Some(2)), + ), + ( + r#"error: "src/my file.rs":10:2: failed"#, + (Some(10), Some(2)), + ), + ( + r#"error: "src/my file.rs"(10,2): failed"#, + (Some(10), Some(2)), + ), + ( + r#"File "src/my file.rs", line 7, in "#, + (Some(7), None), + ), + (r#"see "src/my file.rs"."#, (None, None)), + ] { + let got = file_link_with(line, "file", &roots, file) + .unwrap_or_else(|asked| panic!("{line:?}: no link, asked {asked:?}")); + assert_eq!(got.0, file, "{line:?}"); + assert_eq!((got.1, got.2), loc, "{line:?}"); + let byte = line.find("src/").unwrap(); + assert_eq!( + got.3, + line[..byte].chars().count(), + "{line:?} starts inside the quote" + ); + } + } + + /// Outside a quote a space still ends a path — the part after it is not + /// guessed onto the part before. + #[test] + fn an_unquoted_space_still_ends_a_path() { + let want = r"C:\Program Files\foo\bar.exe"; + let line = r"run C:\Program Files\foo\bar.exe now"; + for needle in ["Program", "Files", "bar.exe"] { + assert!( + file_link_with(line, needle, &windows_roots(), want).is_err(), + "{needle:?}" + ); + } + let docs = "/Users/me/My Docs/a.txt"; + assert!( + file_link_with("open /Users/me/My Docs/a.txt", "Docs", &posix_roots(), docs).is_err() + ); + } + + /// Quotes around ordinary prose are not a path, and one that looks enough + /// like one to be asked about is asked about once. + #[test] + fn a_quote_around_prose_is_not_a_path() { + let spaced = |line: &str, needle: &str| { + let byte = line.find(needle).unwrap(); + file_candidates_at(line, line[..byte].chars().count()) + .into_iter() + .filter(|c| c.path.contains(' ')) + .collect::>() + }; + for (line, needle) in [ + (r#"say "hello world" twice"#, "world"), + (r#"say " /a b/c " twice"#, "b/c"), + (r#"mismatched "/a b/c' here"#, "b/c"), + (r#"a "x" and "y/z w""#, "and"), + ("it's a /tmp/x dir", "tmp"), + ("tab\"/a\tb/c\"", "b/c"), + ] { + assert_eq!(spaced(line, needle), Vec::new(), "{line:?}"); + } + let long = format!("\"/{} x/y\"", "a".repeat(MAX_QUOTED_PATH)); + assert_eq!(spaced(&long, "x/y"), Vec::new(), "past the cap"); + let at_cap = format!("\"/{} x/y\"", "a".repeat(MAX_QUOTED_PATH - 6)); + assert_eq!(spaced(&at_cap, "x/y").len(), 1, "at the cap"); + + // A quoted sentence that clears the shape check and names nothing + // is one question, not one per word. + let line = r#"note "see the docs/ folder and the src/ tree" ok"#; + for needle in ["see", "folder", "tree"] { + let (link, asked) = link_with(line, needle, &posix_roots(), ""); + assert_eq!(link, None); + let spaced: Vec<_> = asked.iter().filter(|p| p.contains(' ')).collect(); + assert_eq!(spaced.len(), 1, "{needle:?} asked {asked:?}"); + } + } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d4924e8b..5d6a4ecd 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -27,8 +27,8 @@ use super::typeahead::{RawInput, Typeahead}; use crate::core::actions::{ CloseActiveTab, CopyLinkPathUnderPointer, DecreaseFontSize, ForkAgentSessionDown, ForkAgentSessionLeft, ForkAgentSessionRight, ForkAgentSessionUp, IncreaseFontSize, NewTab, - OpenLinkUnderPointer, RevealLinkUnderPointer, SaveAgentLaunchArgs, SendBackTab, SendTab, - SplitDown, SplitRight, ToggleMaximizePane, + OpenLinkUnderPointer, OpenLinkWithDefaultApp, RevealLinkUnderPointer, SaveAgentLaunchArgs, + SendBackTab, SendTab, SplitDown, SplitRight, ToggleMaximizePane, }; use crate::core::config::{BellMode, Config, LinkFileOpen, MouseZoomModifier, NotifyMode}; use crate::core::shell_quote::quote_for_shell; @@ -5992,6 +5992,22 @@ impl TerminalView { self.open_file_link(path, line, column, is_dir, window, cx); } + /// "Open with Default App": the file link under the menu, handed to the + /// OS association whatever `link_file_open` says "Open" does. + fn open_menu_link_with_default_app(&mut self, window: &mut Window, cx: &mut Context) { + // The menu only offers this for a local pane; a key binding could + // still fire it on a remote one, where the path means nothing here. + if !self.host_id.is_local() { + return; + } + let Some(path) = self.menu_link_path().map(std::path::Path::to_path_buf) else { + return; + }; + if let Err(e) = open_file_path(&path) { + self.warn_file_open_failed(&path, &e, window, cx); + } + } + fn reveal_menu_link(&mut self, window: &mut Window, cx: &mut Context) { let Some(path) = self.menu_link_path().map(std::path::Path::to_path_buf) else { return; @@ -7270,6 +7286,9 @@ impl Render for TerminalView { .on_action(cx.listener(|this, _: &OpenLinkUnderPointer, window, cx| { this.open_menu_link(window, cx); })) + .on_action(cx.listener(|this, _: &OpenLinkWithDefaultApp, window, cx| { + this.open_menu_link_with_default_app(window, cx); + })) .on_action(cx.listener(|this, _: &RevealLinkUnderPointer, window, cx| { this.reveal_menu_link(window, cx); })) @@ -7317,10 +7336,18 @@ impl Render for TerminalView { false => L10nKey::AppMenuRevealInFolder, }; let label = link_menu_label(path); + let default_app = + offers_default_app_open(local, cx.global::().file_open_mode()); menu.min_w(px(220.)) .action_context(menu_focus.clone()) .label(label) .menu(t(L10nKey::AppMenuOpenLink), Box::new(OpenLinkUnderPointer)) + .when(default_app, |menu| { + menu.menu( + t(L10nKey::AppMenuOpenLinkWithDefaultApp), + Box::new(OpenLinkWithDefaultApp), + ) + }) // A file on another machine has no folder here to // show it in, and naming this one's would show // whatever it happens to keep at that path. @@ -7666,9 +7693,23 @@ fn select_end_copy(enabled: bool, grid: bool, editor: bool) -> SelectEndCopy { } } +/// Whether a file link's menu offers "Open with Default App" beside "Open". +/// +/// Only for a file on this machine: the OS opener is handed a local path, and +/// a remote pane's path names nothing here — or worse, this machine's copy. +/// And not when "Open" already goes to the OS association, where the two rows +/// would do the same thing. +fn offers_default_app_open(host_is_local: bool, open_mode: LinkFileOpen) -> bool { + host_is_local && open_mode != LinkFileOpen::System +} + /// Hands a path to whatever the OS has it associated with. Also the fallback /// for a directory the file tree cannot reach. +/// +/// The path is spelled this OS's way first: Explorer takes `c:/a.png` as a +/// request to show Documents. pub(crate) fn open_file_path(path: &std::path::Path) -> std::io::Result<()> { + let path = &super::search::spelled_for(super::search::PathStyle::NATIVE, path); let opener = if cfg!(target_os = "macos") { "open" } else if cfg!(windows) { @@ -7686,6 +7727,7 @@ pub(crate) fn open_file_path(path: &std::path::Path) -> std::io::Result<()> { /// no desktop-neutral Linux equivalent exists, so there the folder is opened /// and the file is left for the eye to find. pub(crate) fn reveal_file_path(path: &std::path::Path) -> std::io::Result<()> { + let path = &super::search::spelled_for(super::search::PathStyle::NATIVE, path); #[cfg(target_os = "macos")] let mut command = { let mut c = std::process::Command::new("open"); @@ -10074,6 +10116,30 @@ mod tests { assert!(highlight_runs("", &[]).is_empty()); } + /// "Open with Default App" sits beside "Open" only where it adds + /// something: on a local file, and when "Open" is not already the OS + /// association. + #[test] + fn the_default_app_row_is_offered_only_where_it_differs_from_open() { + use super::{LinkFileOpen, offers_default_app_open}; + assert!(offers_default_app_open(true, LinkFileOpen::Internal)); + assert!(offers_default_app_open(true, LinkFileOpen::Command)); + assert!( + !offers_default_app_open(true, LinkFileOpen::System), + "it would repeat Open" + ); + for mode in [ + LinkFileOpen::Internal, + LinkFileOpen::System, + LinkFileOpen::Command, + ] { + assert!( + !offers_default_app_open(false, mode), + "a remote file has nothing here for the OS to open ({mode:?})" + ); + } + } + #[test] fn only_a_matching_host_may_answer_for_a_panes_paths() { assert!(cwd_is_on_host(false, true)); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b2e339d7..a2bd10f7 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1902,6 +1902,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuZoomPane => "Zoom Pane", L10nKey::AppMenuClearScrollback => "Clear Scrollback", L10nKey::AppMenuOpenLink => "Open", + L10nKey::AppMenuOpenLinkWithDefaultApp => "Open with Default App", L10nKey::AppMenuRevealInFinder => "Show in Finder", L10nKey::AppMenuRevealInFolder => "Show Containing Folder", L10nKey::AppMenuCopyLinkPath => "Copy Path", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0e0ebc46..0a8319b4 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1974,6 +1974,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuZoomPane => "ペインを拡大", L10nKey::AppMenuClearScrollback => "スクロールバックをクリア", L10nKey::AppMenuOpenLink => "開く", + L10nKey::AppMenuOpenLinkWithDefaultApp => "既定のアプリで開く", L10nKey::AppMenuRevealInFinder => "Finder に表示", L10nKey::AppMenuRevealInFolder => "含まれるフォルダーを開く", L10nKey::AppMenuCopyLinkPath => "パスをコピー", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index ce929130..1eecd6d1 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1046,6 +1046,7 @@ l10n_keys! { AppMenuZoomPane, AppMenuClearScrollback, AppMenuOpenLink, + AppMenuOpenLinkWithDefaultApp, AppMenuRevealInFinder, AppMenuRevealInFolder, AppMenuCopyLinkPath, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 7bfbb78f..55105350 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1793,6 +1793,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuZoomPane => "缩放窗格", L10nKey::AppMenuClearScrollback => "清除回滚内容", L10nKey::AppMenuOpenLink => "打开", + L10nKey::AppMenuOpenLinkWithDefaultApp => "用默认程序打开", L10nKey::AppMenuRevealInFinder => "在访达中显示", L10nKey::AppMenuRevealInFolder => "打开所在文件夹", L10nKey::AppMenuCopyLinkPath => "复制路径", From 39ceb35fdd75bfc4b47f5600f71bddcff9e977b1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:35:18 +0800 Subject: [PATCH 19/20] feat(cursor): give the shell prompt its own cursor shape (#963) A new prompt_cursor_style setting (follow, block, bar, underline) shapes the caret while the shell waits at a prompt, whether tty7's inline editor or the shell's own line editor draws it. `follow`, the default, keeps cursor_style everywhere, so nothing changes until it is set. Any other value leaves cursor_style to the programs the shell runs: bar here with cursor_style block gives kitty and ghostty's bar at the prompt and a block inside a TUI that never sets a shape, such as Claude Code. A vi-mode prompt keeps the shell's own insert/normal shapes. Settings shows both cursor shapes as dropdowns, kept in step with resets, language switches and config edits made outside the window. Closes #958 --- CHANGELOG.md | 9 +++ crates/tty7-core/src/core/config.rs | 50 ++++++++++++ docs/reference/configuration.mdx | 1 + src/terminal/element.rs | 19 ++++- src/terminal/view.rs | 74 +++++++++++++++++- src/ui/app.rs | 117 ++++++++++++++++++++++++++++ src/ui/i18n/en.rs | 8 ++ src/ui/i18n/ja.rs | 8 ++ src/ui/i18n/mod.rs | 4 + src/ui/i18n/zh.rs | 8 ++ src/ui/settings.rs | 89 +++++++++++++++------ 11 files changed, 361 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8775bb..01682019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tree is remembered in the config. Both are built from the status the host already sends, so a remote repository gets them unchanged. Searching the diff text itself is not part of this. +- **The cursor at the shell prompt can have its own shape** (#958). A new + `prompt_cursor_style` setting (Settings → Appearance → Cursor) takes + `follow`, `block`, `bar` or `underline`. `follow`, the default, keeps + `cursor_style` everywhere, exactly as before. Any other value is used at the + prompt, drawn by tty7's inline editor or by the shell's own line editor, and + leaves `cursor_style` to the programs the shell runs — so `bar` with + `cursor_style: "block"` gives kitty and ghostty's bar-at-the-prompt, block in + a TUI that never sets a shape itself, such as Claude Code. A shell prompt in + vi mode keeps its own insert/normal shapes. ### Removed diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 5c6b9a18..37dc6c04 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -331,6 +331,13 @@ pub struct Config { #[serde(default, deserialize_with = "de_lenient")] pub cursor_style: CursorStyle, + /// The caret's shape while the shell waits at a prompt, whether tty7's + /// inline editor or the shell's own line editor draws it. `follow` keeps + /// `cursor_style` everywhere; any other value leaves `cursor_style` to the + /// programs the shell runs, which is how kitty and ghostty put a bar at + /// the prompt and a block inside a TUI that never sets a shape itself. + #[serde(default, deserialize_with = "de_lenient")] + pub prompt_cursor_style: PromptCursorStyle, pub macos_option_as_alt: bool, pub mouse_hide_while_typing: bool, @@ -536,6 +543,28 @@ pub enum CursorStyle { Underline, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PromptCursorStyle { + #[default] + Follow, + Block, + Bar, + Underline, +} + +impl PromptCursorStyle { + /// The shape this names, or `None` when the prompt follows `cursor_style`. + pub fn shape(self) -> Option { + match self { + Self::Follow => None, + Self::Block => Some(CursorStyle::Block), + Self::Bar => Some(CursorStyle::Bar), + Self::Underline => Some(CursorStyle::Underline), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NewTabPosition { @@ -763,6 +792,7 @@ impl Default for Config { tab_completion: true, history_search: true, cursor_style: CursorStyle::Block, + prompt_cursor_style: PromptCursorStyle::Follow, macos_option_as_alt: false, mouse_hide_while_typing: true, focus_follows_mouse: false, @@ -3057,4 +3087,24 @@ mod tests { assert_eq!(back.ssh_tab_title, mode); } } + + #[test] + fn prompt_cursor_style_defaults_to_following_cursor_style() { + assert_eq!( + Config::default().prompt_cursor_style, + PromptCursorStyle::Follow + ); + assert_eq!(PromptCursorStyle::Follow.shape(), None); + + let cfg: Config = + serde_json::from_str(r#"{"cursor_style": "block", "prompt_cursor_style": "bar"}"#) + .unwrap(); + assert_eq!(cfg.cursor_style, CursorStyle::Block); + assert_eq!(cfg.prompt_cursor_style.shape(), Some(CursorStyle::Bar)); + + // A value this build does not know falls back rather than failing the + // whole config. + let cfg: Config = serde_json::from_str(r#"{"prompt_cursor_style": "beam"}"#).unwrap(); + assert_eq!(cfg.prompt_cursor_style, PromptCursorStyle::Follow); + } } diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx index f7af4865..5fa308eb 100644 --- a/docs/reference/configuration.mdx +++ b/docs/reference/configuration.mdx @@ -118,6 +118,7 @@ their id from the file name. [More about themes →](/customization/themes) | `env` | object | `{}` | Extra environment variables for every pane. | | `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. | | `cursor_style` | enum | `"block"` | `block`, `bar`, `underline`. | +| `prompt_cursor_style` | enum | `"follow"` | The cursor at the shell prompt: `follow`, `block`, `bar`, `underline`. `follow` uses `cursor_style` everywhere; any other value leaves `cursor_style` to the programs the shell runs, e.g. `bar` here with `cursor_style: "block"` gives a bar at the prompt and a block in a TUI that sets no shape of its own. A shell prompt in vi mode keeps its own shapes. | | `cursor_blink` | bool | `true` | | | `bell` | enum | `"visual"` | `none`, `visual`, `audible`, `both`. | | `per_pane_history` | bool | `false` | Give each pane its own shell history file. | diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 68f810a1..00c02980 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use alacritty_terminal::grid::Dimensions as _; use alacritty_terminal::index::{Column as AlacColumn, Line as AlacLine, Point as AlacPoint}; use alacritty_terminal::selection::SelectionRange; +use alacritty_terminal::term::TermMode; use alacritty_terminal::term::cell::{Cell, Flags}; use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb}; use gpui::{ @@ -1817,6 +1818,17 @@ impl GridSnapshot { } impl TerminalElement { + /// The shape `prompt_cursor_style` puts on the grid cursor while the shell + /// waits at a prompt with its own line editor — `prompt_editor` off, or a + /// line handed back to ZLE/readline. Shell integration has no mark for "a + /// prompt plugin set this shape", so the one it does report, vi mode, keeps + /// the shell's own insert/normal shapes. + pub(super) fn prompt_cursor_shape(&self, cx: &App) -> Option { + let shape = cx.global::().prompt_cursor_style.shape()?; + let terminal = &self.view.read(cx).terminal; + (terminal.at_prompt() && !terminal.shell_vi_mode()).then_some(shape) + } + pub(super) fn build_grid( &self, colors: &PaintColors, @@ -1834,6 +1846,7 @@ impl TerminalElement { let mut any_selected = false; let display_offset; let history_size; + let prompt_shape = self.prompt_cursor_shape(cx); { let mut palette = self.view.read(cx).terminal.palette; if let Some(active) = cx.try_global::() { @@ -1949,7 +1962,11 @@ impl TerminalElement { col, ime_col, hidden: cursor_hidden, - style: cursor_style_from_shape(cur.shape), + // Checked under the grid lock: `on_alt_screen` would take + // it a second time. + style: prompt_shape + .filter(|_| !term.mode().contains(TermMode::ALT_SCREEN)) + .unwrap_or_else(|| cursor_style_from_shape(cur.shape)), }); } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5d6a4ecd..6bdec557 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6599,7 +6599,11 @@ impl TerminalView { } } - let cursor_style = cx.global::().cursor_style; + // This caret only exists at a prompt, so the prompt's own shape wins. + let cursor_style = { + let cfg = cx.global::(); + cfg.prompt_cursor_style.shape().unwrap_or(cfg.cursor_style) + }; let cursor_paint = input_caret_paint(focused, self.cursor_visible, cursor_style); let cursor_on = cursor_paint.is_some(); let block_cursor = cursor_paint == Some(InputCaretPaint::Block); @@ -16451,6 +16455,74 @@ mod gpui_tests { "a spawned pane counts its first agent, once" ); } + + /// #958: `prompt_cursor_style` shapes the shell's own caret at a prompt, + /// leaves the running program's cursor alone, and steps aside for a vi-mode + /// prompt, whose insert/normal shapes are the shell's to set. + #[gpui::test] + fn prompt_cursor_style_shapes_only_the_prompt(cx: &mut TestAppContext) { + use crate::core::config::{CursorStyle, PromptCursorStyle}; + + let (window, mut daemon) = harness(cx); + let shape = |cx: &mut TestAppContext| { + cx.update(|cx| { + let view = window.entity(cx).unwrap(); + TerminalElement::new(view).prompt_cursor_shape(cx) + }) + }; + let wait_for = |cx: &mut TestAppContext, want: &dyn Fn(&TerminalView) -> bool| { + for _ in 0..200 { + cx.run_until_parked(); + if window.update(cx, |view, _, _| want(view)).unwrap() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the pane never reached the expected prompt state"); + }; + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Bar); + + assert_eq!( + shape(cx), + None, + "no prompt reported yet: a program owns the cursor" + ); + + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| view.terminal.at_prompt()); + assert_eq!(shape(cx), Some(CursorStyle::Bar)); + + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Follow); + assert_eq!(shape(cx), None, "follow leaves the prompt on cursor_style"); + cx.update(|cx| cx.global_mut::().prompt_cursor_style = PromptCursorStyle::Bar); + + DaemonMsg::Output(b"\x1b]133;V;1\x07\x1b]133;B\x07".to_vec()) + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| view.terminal.shell_vi_mode()); + assert_eq!( + shape(cx), + None, + "a vi-mode prompt keeps the shell's own shapes" + ); + + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for(cx, &|view| !view.terminal.at_prompt()); + assert_eq!(shape(cx), None, "a running command gets cursor_style back"); + } + /// Seeds a pane's history for the ghost tests: `(command, dirs, exit)`, /// oldest first, with `cwd` as the directory the pane is in. fn seed_ghost_history( diff --git a/src/ui/app.rs b/src/ui/app.rs index 1cd7ad3e..439de03b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3015,6 +3015,19 @@ impl Tty7App { self.apply_terminal_config_to_panes(&cfg, cx); } + pub(crate) fn set_prompt_cursor_style( + &mut self, + style: crate::core::config::PromptCursorStyle, + cx: &mut Context, + ) { + self.update_config(cx, |cfg| cfg.prompt_cursor_style = style); + for tab in &self.tabs { + for leaf in tab.pane.terminals() { + leaf.update(cx, |_, cx| cx.notify()); + } + } + } + pub(crate) fn persist_settings_config(&mut self, cx: &mut Context) { let config = cx.global::().clone(); let error = config.try_save().err().map(|error| error.to_string()); @@ -6235,6 +6248,9 @@ impl Tty7App { } L10nKey::SettingsCursorBlink => self.set_cursor_blink(defaults.cursor_blink, cx), L10nKey::SettingsCursorShape => self.set_cursor_style(defaults.cursor_style, cx), + L10nKey::SettingsPromptCursorShape => { + self.set_prompt_cursor_style(defaults.prompt_cursor_style, cx) + } L10nKey::SettingsScrollback => self.set_scrollback_limit(defaults.scrollback_limit, cx), L10nKey::SettingsNewTabPosition => { self.set_new_tab_position(defaults.new_tab_position, cx) @@ -6410,6 +6426,9 @@ impl Tty7App { s.ui_font_select = ui; } } + L10nKey::SettingsCursorShape | L10nKey::SettingsPromptCursorShape => { + self.sync_cursor_selects(window, cx) + } L10nKey::SettingsLanguage => { let value = self.build_language_select(&mut subs, window, cx); if let Some(s) = self.active_settings_mut() { @@ -6537,6 +6556,8 @@ impl Tty7App { let (font_select, font_bold_select, font_italic_select, ui_font_select) = self.build_font_selects(&mut subs, window, cx); let language_select = self.build_language_select(&mut subs, window, cx); + let (cursor_style_select, prompt_cursor_style_select) = + self.build_cursor_selects(&mut subs, window, cx); #[cfg(target_os = "windows")] let window_backdrop_select = self.build_window_backdrop_select(&mut subs, window, cx); let (shell_program_input, shell_args_input, wd_path_input) = @@ -6628,6 +6649,8 @@ impl Tty7App { font_italic_select, ui_font_select, language_select, + cursor_style_select, + prompt_cursor_style_select, #[cfg(target_os = "windows")] window_backdrop_select, shell_program_input, @@ -6833,6 +6856,98 @@ impl Tty7App { language_select } + /// The cursor shape and prompt cursor shape dropdowns. Each resolves the + /// picked label back through the row list it was built from. + fn build_cursor_selects( + &mut self, + subs: &mut Vec, + window: &mut Window, + cx: &mut Context, + ) -> ( + Entity>>, + Entity>>, + ) { + use crate::ui::settings::{ + CURSOR_SHAPES, PROMPT_CURSOR_SHAPES, cursor_shape_labels, prompt_cursor_shape_labels, + }; + let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx); + let shape = cx.new(|cx| { + SelectState::new( + SearchableVec::new(cursor_shape_labels()), + Some(IndexPath::default().row(shape_ix)), + window, + cx, + ) + }); + let prompt = cx.new(|cx| { + SelectState::new( + SearchableVec::new(prompt_cursor_shape_labels()), + Some(IndexPath::default().row(prompt_ix)), + window, + cx, + ) + }); + subs.push(cx.subscribe_in( + &shape, + window, + |this, _select, ev: &SelectEvent>, _window, cx| { + if let SelectEvent::Confirm(Some(label)) = ev { + if let Some(ix) = cursor_shape_labels().iter().position(|r| r == label) { + this.set_cursor_style(CURSOR_SHAPES[ix], cx); + } + } + }, + )); + subs.push(cx.subscribe_in( + &prompt, + window, + |this, _select, ev: &SelectEvent>, _window, cx| { + if let SelectEvent::Confirm(Some(label)) = ev { + if let Some(ix) = prompt_cursor_shape_labels().iter().position(|r| r == label) { + this.set_prompt_cursor_style(PROMPT_CURSOR_SHAPES[ix], cx); + } + } + }, + )); + (shape, prompt) + } + + /// The dropdown rows the config's two cursor shapes sit on. + fn cursor_select_rows(cx: &App) -> (usize, usize) { + use crate::ui::settings::{CURSOR_SHAPES, PROMPT_CURSOR_SHAPES}; + let cfg = cx.global::(); + let shape = CURSOR_SHAPES.iter().position(|s| *s == cfg.cursor_style); + let prompt = PROMPT_CURSOR_SHAPES + .iter() + .position(|s| *s == cfg.prompt_cursor_style); + (shape.unwrap_or(0), prompt.unwrap_or(0)) + } + + /// Puts both cursor dropdowns back on the config's values, relabelled in + /// the current language — after a reset, a language switch, or a config + /// edit made outside this window. + fn sync_cursor_selects(&mut self, window: &mut Window, cx: &mut Context) { + use crate::ui::settings::{cursor_shape_labels, prompt_cursor_shape_labels}; + let Some((shape, prompt)) = self.active_settings().map(|s| { + ( + s.cursor_style_select.clone(), + s.prompt_cursor_style_select.clone(), + ) + }) else { + return; + }; + let (shape_ix, prompt_ix) = Self::cursor_select_rows(cx); + for (select, rows, ix) in [ + (shape, cursor_shape_labels(), shape_ix), + (prompt, prompt_cursor_shape_labels(), prompt_ix), + ] { + select.update(cx, |state, cx| { + state.set_items(SearchableVec::new(rows), window, cx); + state.set_selected_index(Some(IndexPath::default().row(ix)), window, cx); + }); + } + } + fn normalize_gui_language(code: &str) -> &'static str { crate::ui::i18n::find_language(code) .map(|lang| lang.code) @@ -6902,6 +7017,7 @@ impl Tty7App { } pub(crate) fn refresh_locale_state(&mut self, window: &mut Window, cx: &mut Context) { + self.sync_cursor_selects(window, cx); self.sidebar_search.update(cx, |state, cx| { state.set_placeholder(t(L10nKey::SearchTabs), window, cx) }); @@ -7430,6 +7546,7 @@ impl Tty7App { self.terminal_scrollback_limit = config.scrollback_limit; self.apply_terminal_config_to_panes(&config, cx); } + self.sync_cursor_selects(window, cx); let (font_size, line_height, font_family, font_fallbacks, font_features) = { let cfg = cx.global::(); ( diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index a2bd10f7..18618168 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -140,6 +140,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsCursor => "Cursor", L10nKey::SettingsCursorShape => "Cursor shape", L10nKey::SettingsCursorShapeDesc => "How the terminal cursor is drawn.", + L10nKey::SettingsPromptCursorShape => "Prompt cursor shape", + L10nKey::SettingsPromptCursorShapeDesc => { + "The cursor at the shell prompt. Follow uses the cursor shape above everywhere." + } L10nKey::SettingsCursorBlink => "Cursor blink", L10nKey::SettingsCursorBlinkDesc => "Pulse the cursor while the terminal is focused.", L10nKey::SettingsLanguage => "Language", @@ -839,6 +843,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchCopyOnSelectKeywords => "clipboard selection yank mouse", L10nKey::SettingsSearchCursorBlinkKeywords => "caret blinking flash", L10nKey::SettingsSearchCursorShapeKeywords => "caret block bar underline beam", + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "prompt cursor shape caret block bar underline beam shell integration" + } L10nKey::SettingsSearchCustomThemesKeywords => { "theme duplicate edit colors folder yaml import background image wallpaper" } @@ -1825,6 +1832,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::CursorShapeBlock => "Block", L10nKey::CursorShapeBar => "Bar", L10nKey::CursorShapeUnderline => "Underline", + L10nKey::PromptCursorShapeFollow => "Follow", L10nKey::PaletteTryDifferentSearch => "Try a different search.", L10nKey::CompletionListingRemote => "listing remote…", L10nKey::CompletionRemoteListingFailed => "remote listing failed — {error}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 0a8319b4..99941ff0 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -142,6 +142,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCursor => "カーソル", L10nKey::SettingsCursorShape => "カーソルの形状", L10nKey::SettingsCursorShapeDesc => "ターミナルカーソルの描画方法", + L10nKey::SettingsPromptCursorShape => "プロンプトのカーソル形状", + L10nKey::SettingsPromptCursorShapeDesc => { + "シェルプロンプトでのカーソル。「追従」は上のカーソル形状をどこでも使う" + } L10nKey::SettingsCursorBlink => "カーソルの点滅", L10nKey::SettingsCursorBlinkDesc => { "ターミナルがフォーカスされている間、カーソルを点滅させる" @@ -865,6 +869,9 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorShapeKeywords => { "カーソル 形状 ブロック バー アンダーライン ビーム cursor shape caret block bar underline beam" } + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "プロンプト カーソル 形状 ブロック バー 下線 prompt cursor shape caret block bar underline beam" + } L10nKey::SettingsSearchCustomThemesKeywords => { "テーマ 複製 編集 色 フォルダ 背景画像 壁紙 yaml インポート custom themes duplicate edit colors folder import background image wallpaper" } @@ -1897,6 +1904,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::CursorShapeBlock => "ブロック", L10nKey::CursorShapeBar => "バー", L10nKey::CursorShapeUnderline => "下線", + L10nKey::PromptCursorShapeFollow => "追従", L10nKey::PaletteTryDifferentSearch => "別のキーワードを試してください。", L10nKey::CompletionListingRemote => "リモートを一覧しています…", L10nKey::CompletionRemoteListingFailed => "リモートの一覧に失敗しました — {error}", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1eecd6d1..bbfcea66 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -186,6 +186,8 @@ l10n_keys! { SettingsCursor, SettingsCursorShape, SettingsCursorShapeDesc, + SettingsPromptCursorShape, + SettingsPromptCursorShapeDesc, SettingsCursorBlink, SettingsCursorBlinkDesc, SettingsLanguage, @@ -642,6 +644,7 @@ l10n_keys! { SettingsSearchCopyOnSelectKeywords, SettingsSearchCursorBlinkKeywords, SettingsSearchCursorShapeKeywords, + SettingsSearchPromptCursorShapeKeywords, SettingsSearchCustomThemesKeywords, SettingsSearchDetectUrlsKeywords, SettingsSearchDiffPreviewFromCountsKeywords, @@ -1475,6 +1478,7 @@ l10n_keys! { CursorShapeBlock, CursorShapeBar, CursorShapeUnderline, + PromptCursorShapeFollow, PaletteTryDifferentSearch, CompletionListingRemote, CompletionRemoteListingFailed, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 55105350..7f88acf5 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -126,6 +126,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsCursor => "光标", L10nKey::SettingsCursorShape => "光标形状", L10nKey::SettingsCursorShapeDesc => "终端光标的绘制方式。", + L10nKey::SettingsPromptCursorShape => "提示符光标形状", + L10nKey::SettingsPromptCursorShapeDesc => { + "shell 提示符处的光标。跟随表示各处都用上面的光标形状。" + } L10nKey::SettingsCursorBlink => "光标闪烁", L10nKey::SettingsCursorBlinkDesc => "终端获得焦点时让光标闪烁。", L10nKey::SettingsLanguage => "语言", @@ -760,6 +764,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchCursorShapeKeywords => { "光标形状 光标 块 竖线 下划线 cursor shape caret block bar underline beam" } + L10nKey::SettingsSearchPromptCursorShapeKeywords => { + "提示符 光标形状 光标 块 竖线 下划线 prompt cursor shape caret block bar underline beam" + } L10nKey::SettingsSearchCustomThemesKeywords => { "自定义主题 复制 编辑 颜色 文件夹 背景图片 壁纸 yaml 导入 theme custom edit duplicate colors import background image wallpaper" } @@ -1720,6 +1727,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::CursorShapeBlock => "块状", L10nKey::CursorShapeBar => "竖线", L10nKey::CursorShapeUnderline => "下划线", + L10nKey::PromptCursorShapeFollow => "跟随", L10nKey::PaletteTryDifferentSearch => "换个关键词试试。", L10nKey::CompletionListingRemote => "正在列出远程目录…", L10nKey::CompletionRemoteListingFailed => "远程目录列表失败——{error}", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index dac43544..053c0008 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -24,7 +24,8 @@ use uuid::Uuid; use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, - SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, + PromptCursorStyle, SshTabTitle, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, + WindowBackdrop, }; use crate::core::keychain::{ CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, @@ -573,6 +574,11 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsCursorShape, keywords: SettingsSearchCursorShapeKeywords, }, + SearchEntry { + section: Appearance, + title: SettingsPromptCursorShape, + keywords: SettingsSearchPromptCursorShapeKeywords, + }, SearchEntry { section: Appearance, title: SettingsCursorBlink, @@ -902,12 +908,43 @@ fn settings_search_entries() -> &'static [SearchEntry] { ] } +/// The rows of the cursor shape dropdown, in `CURSOR_SHAPES` order. +pub(crate) fn cursor_shape_labels() -> Vec { + [ + L10nKey::CursorShapeBlock, + L10nKey::CursorShapeBar, + L10nKey::CursorShapeUnderline, + ] + .into_iter() + .map(|key| t(key).to_string()) + .collect() +} + +pub(crate) const CURSOR_SHAPES: [CursorStyle; 3] = + [CursorStyle::Block, CursorStyle::Bar, CursorStyle::Underline]; + +/// The rows of the prompt cursor shape dropdown, in `PROMPT_CURSOR_SHAPES` +/// order: follow, then the shapes. +pub(crate) fn prompt_cursor_shape_labels() -> Vec { + let mut rows = vec![t(L10nKey::PromptCursorShapeFollow).to_string()]; + rows.extend(cursor_shape_labels()); + rows +} + +pub(crate) const PROMPT_CURSOR_SHAPES: [PromptCursorStyle; 4] = [ + PromptCursorStyle::Follow, + PromptCursorStyle::Block, + PromptCursorStyle::Bar, + PromptCursorStyle::Underline, +]; + impl SearchEntry { fn config_key(&self) -> &'static str { match self.title { L10nKey::SettingsDimInactivePanes => "dim_inactive_panes", L10nKey::SettingsCursorBlink => "cursor_blink", L10nKey::SettingsCursorShape => "cursor_style", + L10nKey::SettingsPromptCursorShape => "prompt_cursor_style", L10nKey::SettingsScrollback => "scrollback_limit", L10nKey::SettingsNewTabPosition => "new_tab_position", L10nKey::SettingsTabBarPosition => "tab_bar_position", @@ -994,6 +1031,7 @@ impl SearchEntry { L10nKey::SettingsFontLigatures => t(L10nKey::SettingsFontLigaturesDesc), L10nKey::SettingsFontThicken => t(L10nKey::SettingsFontThickenDesc), L10nKey::SettingsCursorShape => t(L10nKey::SettingsCursorShapeDesc), + L10nKey::SettingsPromptCursorShape => t(L10nKey::SettingsPromptCursorShapeDesc), L10nKey::SettingsCursorBlink => t(L10nKey::SettingsCursorBlinkDesc), L10nKey::SettingsBackgroundImage => t(L10nKey::SettingsBackgroundImageDesc), L10nKey::SettingsImageOpacity => t(L10nKey::SettingsImageOpacityDesc), @@ -1060,6 +1098,9 @@ impl SearchEntry { } L10nKey::SettingsCursorBlink => cfg.cursor_blink != defaults.cursor_blink, L10nKey::SettingsCursorShape => cfg.cursor_style != defaults.cursor_style, + L10nKey::SettingsPromptCursorShape => { + cfg.prompt_cursor_style != defaults.prompt_cursor_style + } L10nKey::SettingsScrollback => cfg.scrollback_limit != defaults.scrollback_limit, L10nKey::SettingsNewTabPosition => cfg.new_tab_position != defaults.new_tab_position, L10nKey::SettingsTabBarPosition => cfg.tab_bar_position != defaults.tab_bar_position, @@ -1311,6 +1352,8 @@ pub(crate) struct SettingsState { pub(crate) font_italic_select: Entity>>, pub(crate) ui_font_select: Entity>>, pub(crate) language_select: Entity>>, + pub(crate) cursor_style_select: Entity>>, + pub(crate) prompt_cursor_style_select: Entity>>, #[cfg(target_os = "windows")] pub(crate) window_backdrop_select: Entity>>, pub(crate) shell_program_input: Entity, @@ -3535,8 +3578,14 @@ impl Tty7App { ), None => return div().into_any_element(), }; + let (cursor_style_select, prompt_cursor_style_select) = match self.active_settings() { + Some(s) => ( + s.cursor_style_select.clone(), + s.prompt_cursor_style_select.clone(), + ), + None => return div().into_any_element(), + }; let cfg = cx.global::(); - let cursor_style = cfg.cursor_style; let cursor_blink = cfg.cursor_blink; let font_thicken = cfg.font_thicken; let font_ligatures = cfg.font_features.as_ref().is_some_and(|features| { @@ -3656,29 +3705,15 @@ impl Tty7App { ) }); - let cursor_idx = match cursor_style { - CursorStyle::Block => 0, - CursorStyle::Bar => 1, - CursorStyle::Underline => 2, + let cursor_dropdown = |state: &Entity>>| { + Select::new(state) + .small() + .w(px(FIELD_W)) + .h(px(24.)) + .into_any_element() }; - let cursor_style_control = self.segmented( - "cursor-style", - &[ - t(L10nKey::CursorShapeBlock), - t(L10nKey::CursorShapeBar), - t(L10nKey::CursorShapeUnderline), - ], - cursor_idx, - cx, - |this, ix, _w, cx| { - let style = match ix { - 0 => CursorStyle::Block, - 1 => CursorStyle::Bar, - _ => CursorStyle::Underline, - }; - this.set_cursor_style(style, cx); - }, - ); + let cursor_style_control = cursor_dropdown(&cursor_style_select); + let prompt_cursor_style_control = cursor_dropdown(&prompt_cursor_style_select); let blink_switch = crate::ui::theme::switch("cursor-blink", cx) .checked(cursor_blink) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx))) @@ -3755,6 +3790,12 @@ impl Tty7App { cursor_style_control, cx, )) + .child(self.settings_row( + t(L10nKey::SettingsPromptCursorShape), + t(L10nKey::SettingsPromptCursorShapeDesc), + prompt_cursor_style_control, + cx, + )) .child(self.settings_row( t(L10nKey::SettingsCursorBlink), t(L10nKey::SettingsCursorBlinkDesc), From 6fcf659e045126af8d70c944c3ab701555f1dd61 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:38:31 +0800 Subject: [PATCH 20/20] feat(search): tabbed Search Everywhere with a Sessions tab to resume agent sessions (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(search): replace the command palette with tabbed Search Everywhere The palette was one flat list that every new kind of row had to be squeezed into: tabs and SSH hosts rode along as "Switch to Tab: …" and "SSH: …" commands, and the only way to narrow to one kind was a magic seed word. Search Everywhere splits it into sources behind one trait — All, Actions, Terminals, Hosts — each with its own empty-query layout and ranking. The All tab shows each source's top rows, ordered by best match, with a row that opens the full tab. Tab / Shift-Tab walk the tabs and keep the query. - Terminals lists every open tab of every workspace (reusing the switcher's tab rows) and jumps to it wherever it lives, plus the shells and agents. - Hosts replaces the separate "Add Connection" input: a typed address or full `ssh …` line offers to connect. - Fixes Return doing nothing after a search that found nothing, or when the search opens pre-filtered: gpui-component re-picks the row from a stale frame; the delegate now re-arms the first row. - Fixes `ssh -p 2222 me@box` being offered as a quick-connect address with user `ssh -p 2222 me`. The keymap action stays `TogglePalette` so custom bindings keep working. * feat(search): a Sessions tab to resume past agent sessions Search Everywhere gains a Sessions tab listing the Claude Code and Codex sessions on this computer, read from ~/.claude/projects and ~/.codex/sessions ($CODEX_HOME). Sessions that ran in the focused tab's directory lead; the All tab offers the last three of them before anything is typed. Return opens a new tab in the session's directory and runs the agent's resume command with its configured launch flags. - Only each transcript's head and tail are read, off the window thread, and cached by path, size and mtime: ~170ms cold for 50 sessions, under 1ms warm. The search opens on the cached list and fills in. - Titles: /rename name, then the agent's own title (ai-title, Codex's session_index.jsonl), then the first thing typed, skipping harness injections. Codex rollouts it ran for itself (subagent/internal) and sessions never asked anything are left out. - A session whose directory is gone is refused with a notice rather than resumed where the agent cannot find it. --- CHANGELOG.md | 30 + README.md | 4 +- README.zh-CN.md | 4 +- docs/agents/overview.mdx | 6 +- docs/agents/sessions.mdx | 2 +- docs/agents/status.mdx | 2 +- docs/customization/keybindings.mdx | 4 +- docs/customization/themes.mdx | 4 +- docs/design-system.md | 8 +- docs/docs.json | 10 +- docs/getting-started/first-launch.mdx | 2 +- docs/git/diffs.mdx | 2 +- docs/git/source-control.mdx | 2 +- docs/git/worktrees.mdx | 2 +- docs/images/command-palette.webp | Bin 105650 -> 0 bytes docs/reference/configuration.mdx | 4 +- docs/reference/keyboard-shortcuts.mdx | 4 +- docs/remote/port-forwarding.mdx | 4 +- docs/remote/sftp.mdx | 2 +- docs/remote/ssh.mdx | 8 +- docs/terminal/selection-and-clipboard.mdx | 2 +- docs/window/command-palette.mdx | 59 - docs/window/search-everywhere.mdx | 102 ++ docs/window/side-panel.mdx | 6 +- docs/window/sidebar.mdx | 4 +- docs/window/tabs-and-splits.mdx | 2 +- src/core/actions.rs | 2 + src/core/agent_history.rs | 640 ++++++++ src/core/mod.rs | 1 + src/ui/agent_launch.rs | 94 +- src/ui/app.rs | 384 +++-- src/ui/document_column.rs | 6 +- src/ui/home.rs | 2 +- src/ui/i18n/en.rs | 28 +- src/ui/i18n/ja.rs | 28 +- src/ui/i18n/mod.rs | 27 +- src/ui/i18n/zh.rs | 26 +- src/ui/keymap.rs | 19 +- src/ui/mod.rs | 2 +- src/ui/palette.rs | 1746 --------------------- src/ui/remote_connect.rs | 4 +- src/ui/search/command.rs | 945 +++++++++++ src/ui/search/mod.rs | 85 + src/ui/search/score.rs | 189 +++ src/ui/search/sources.rs | 685 ++++++++ src/ui/search/view.rs | 800 ++++++++++ src/ui/settings.rs | 4 +- src/ui/switcher.rs | 77 +- src/ui/tab_sidebar.rs | 6 +- src/ui/tab_strip.rs | 260 +-- src/ui/theme.rs | 2 +- 51 files changed, 4190 insertions(+), 2151 deletions(-) delete mode 100644 docs/images/command-palette.webp delete mode 100644 docs/window/command-palette.mdx create mode 100644 docs/window/search-everywhere.mdx create mode 100644 src/core/agent_history.rs delete mode 100644 src/ui/palette.rs create mode 100644 src/ui/search/command.rs create mode 100644 src/ui/search/mod.rs create mode 100644 src/ui/search/score.rs create mode 100644 src/ui/search/sources.rs create mode 100644 src/ui/search/view.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 01682019..d92dc9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The command palette is now Search Everywhere, with tabs.** ⌘ P + (Ctrl ⇧ P elsewhere) opens one search over four tabs — **All**, + **Actions**, **Terminals** and **Hosts** — walked with ⇥ / + ⇧ ⇥, keeping what is typed. **Terminals** lists every open tab of + every workspace, this window's most recently used first, and jumps to it + wherever it lives; the shells and agents it can open follow. **Hosts** holds + the saved SSH hosts and whatever address or `ssh …` command line is typed, + which replaces the separate input *SSH: Add Connection…* used to open. **All** + shows the best few rows of each, the tab with the best match first, and folds + the rest into a row that opens its tab. With nothing typed, ⌘ P + ⏎ goes back to the tab you were just in. The keybinding action is + still `TogglePalette`, so a custom binding keeps working. +- **Resume a past agent session from Search Everywhere.** Its **Sessions** tab + lists the Claude Code and Codex sessions on this computer — those that ran in + the focused tab's directory first — by the title the agent gave them, with + directory, branch and age, and ⏎ resumes one in a new tab in the + directory it ran in, with the agent's configured launch flags. Only the ends + of each transcript are read, in the background, and remembered until the file + changes. + - **The command-line ghost suggests what you ran last, not what you ran most.** It was the top prefix match by frecency, where run count and the current-directory bonus outweighed recency, so after `git commit -m x` @@ -209,6 +229,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Return runs the top row after a search that found nothing.** Backspacing + from a query with no results to one with some — or opening the search + already filtered, as the New Tab menu's *Other Shells…* and *Launch Agent…* + rows do — left no row selected, so Return did nothing until an arrow key was + pressed. +- **A typed `ssh -p 2222 me@box` is no longer offered as an address.** The + address parser read everything before the `@` as the user name and offered + to connect as `ssh -p 2222 me`; a line with spaces is now always taken as an + `ssh` command line. + - **The character under the cursor no longer disappears in vim and Neovim** (#966). An input-method composition with nothing visible in it — Windows IMEs can leave one behind — was still painted at the cursor, as a cell of diff --git a/README.md b/README.md index 218771d3..23f4ad34 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | **Agent-aware** | per-pane detection (25 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository, with pinned groups | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | -| **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | +| **Window** | tabs & splits · ⌘ P Search Everywhere · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | | **Shell integration** | injected when a pane starts, nothing to install · prompt marks · working directory · exit codes · command-finished notifications · zsh, bash, fish, PowerShell, WSL, remote panes | | **Remote workspaces** | remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off | | **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install | @@ -104,7 +104,7 @@ that tells tty7 which session to fork. None of them are wrapped or proxied — the agent you start is the agent you get, in a normal PTY, with its own interface. An agent launched through a wrapper script can be mapped to one by name with `agent_commands` in `config.json`. -Every agent on your `PATH` is also a palette command (**Agent: Claude Code**, …) +Every agent on your `PATH` is also a row in Search Everywhere (**Agent: Claude Code**, …) that opens it in a new tab; `agent_launch` sets the command line it starts with. ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index ccb7aa1d..64e9429b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -54,7 +54,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | **Agent 感知** | 逐 pane 识别 20 个 CLI agent · 状态点 · 通知 · 分支 + diff · 需要输入时托盘图标提醒 · 重启后续上会话 · 侧边栏按仓库分组 | | **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` 转发命令输出并原样返回退出码 · `split` · `send` · `wait --until free` · `capture` | | **编辑器级输入** | 从历史推出影子建议 · Tab 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊搜索历史 | -| **窗口** | 标签页与分屏 · ⌘ P 命令面板 · ⌘ F 回滚搜索 · ⌘ J 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 | +| **窗口** | 标签页与分屏 · ⌘ P 随处搜索 · ⌘ F 回滚搜索 · ⌘ J 侧栏列出进程树和监听端口 · 13 套主题,也能写自己的 YAML 或导入 iTerm2 配色 · 输入法 | | **Shell 集成** | pane 启动时自动注入,不用你装什么 · 提示符边界 · 工作目录 · 退出码 · 命令跑完发通知 · 覆盖 zsh、bash、fish、PowerShell、WSL 和远程 pane | | **远程工作区** | 远端的文件、仓库、改动、diff、worktree、标签页和 pane · 从任意客户端重连,接着离开时的位置继续 | | **SSH** | 自带 russh 实现,不依赖外部 ssh:profile 凭据存入 keychain · SFTP 面板 · 端口转发 · 跳板机 · `tty7-server` 只需安装一次,无需 root | @@ -98,7 +98,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ tty7 不包装、不代理其中任何一个 —— 你启动的就是那个 agent 本身,运行在普通 PTY 中,界面仍然是它自己的。 如果你通过 wrapper 脚本启动 agent,在 `config.json` 的 `agent_commands` 里把脚本名映射到对应 agent 即可。 -PATH 上的每个 agent 也都是一条命令面板命令(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。 +PATH 上的每个 agent 也都能在随处搜索里找到(**Agent:Claude Code** 等),会在新标签页中启动它;`agent_launch` 可以设置它的启动命令行。 ## 文档 diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 4b0ad97b..05744bf9 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -70,9 +70,9 @@ The key is your command's name; the value is one of the slugs above (`claude`, ## Quick launch -Every agent tty7 finds on your `PATH` is one palette command away: **Agent: +Every agent tty7 finds on your `PATH` is one row away in Search Everywhere: **Agent: Claude Code**, **Agent: Codex**, and so on, most-used first. The New Tab menu's -**Launch Agent…** row opens the palette on that list, and **New Agent Tab** +**Launch Agent…** row opens its Terminals tab on that list, and **New Agent Tab** (⌘ ⇧ A on macOS, unbound elsewhere) starts whichever agent you used last. @@ -120,7 +120,7 @@ Just by running an agent in a pane: flags. [More →](/agents/sessions) - Palette commands that hand the current selection or the repo's `git diff` to + Actions that hand the current selection or the repo's `git diff` to the running agent as a prompt. diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx index e47dc680..4a25c8d4 100644 --- a/docs/agents/sessions.mdx +++ b/docs/agents/sessions.mdx @@ -58,5 +58,5 @@ tty7 itself. ## Copy the session id **Copy Session ID** — in the tab's right-click menu, beside *Copy Working -Directory*, and in the command palette — puts the agent's native id on the +Directory*, and in Search Everywhere — puts the agent's native id on the clipboard. Paste it into `codex resume`, a bug report, or another tool. diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 27251cfc..40e917e7 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -96,7 +96,7 @@ Turn it off with **Settings → Window & Tabs → Show tray icon** ## Sending an agent some context -Two command-palette entries hand what is in front of you to the agent running in +Two Search Everywhere actions hand what is in front of you to the agent running in the pane, as a ready-made prompt: | Command | Sends | diff --git a/docs/customization/keybindings.mdx b/docs/customization/keybindings.mdx index 821cec0e..12c3a3d3 100644 --- a/docs/customization/keybindings.mdx +++ b/docs/customization/keybindings.mdx @@ -4,7 +4,7 @@ description: "Rebinding anything, chord sequences, and the tmux preset." --- **Settings → Keyboard & Mouse → Keyboard shortcuts** (⌘ ,) lists every shortcut in the app, -grouped the same way the command palette is. The search box at the top of +grouped the same way Search Everywhere is. The search box at the top of Settings reaches this page too: type what a feature is called — `split`, `commit`, `sftp` — and the page narrows to the shortcuts for it. Action names work as well as labels, so a binding you read about in `keybindings.json` is @@ -38,7 +38,7 @@ no undo for that one. Some actions ship deliberately unbound, because there is no obvious key left to take: pane resize and swap, workspace selection, most git commands, SFTP, and -the panel tabs. They are all in the command palette, and all bindable here. +the panel tabs. They are all in Search Everywhere, and all bindable here. ## Editing `config.json` instead diff --git a/docs/customization/themes.mdx b/docs/customization/themes.mdx index 6dd545c2..d8f0e4ab 100644 --- a/docs/customization/themes.mdx +++ b/docs/customization/themes.mdx @@ -3,8 +3,8 @@ title: "Themes" description: "Thirteen built-ins, your own YAML themes, iTerm2 imports, and a colour editor." --- -**Settings → Appearance → Theme** — or **Change Theme…** in the command -palette — opens the theme picker. +**Settings → Appearance → Theme** — or **Change Theme…** in Search +Everywhere — opens the theme picker. ## Built in diff --git a/docs/design-system.md b/docs/design-system.md index 2b07c5ea..784da4a3 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -21,14 +21,14 @@ | --- | --- | --- | | `Neutrals.background` | 工作内容、设置内容、输入框 | 安静的基础表面。输入框未聚焦时不能像已选中列表行 | | `Neutrals.sidebar` | 两侧导航 | 与内容同色,连成一整块表面;只用细分隔线划界 | -| `Neutrals.popover` | 菜单、命令面板、对话框 | 浅色下保持明亮,深色下抬高亮度;只给浮层阴影 | +| `Neutrals.popover` | 菜单、随处搜索、对话框 | 浅色下保持明亮,深色下抬高亮度;只给浮层阴影 | | `foreground` | 正文、标题、当前项 | 正常文字至少 4.5:1;不把普通正文全部加粗 | | `muted_foreground` | 描述、路径、快捷键 | 与主文字明确区分,但仍保持可读性 | | `border` / `divider` | 控件边界 / 区域分隔 | 控件边界较强,区域分隔较弱;不能把页面画成表格线框 | | `Interactions.input_border` | 输入框与普通按钮轮廓 | 对应组件库的 `input` 边框角色,不能误用内容背景色导致边界消失 | | `Interactions.primary` | 保存、连接、有效的提交等主操作 | 强调色实底,成对配置前景色;hover/pressed 保持标签对比度 | | `Surfaces.sidebar.selected` | 当前标签页 | 中性 selected 阶 + semibold;不用强调色,蓝色留给可按的东西 | -| `Interactions.choice` | 菜单、命令面板的键盘当前行 | 强调色浅底,区别于鼠标经过的中性 hover | +| `Interactions.choice` | 菜单、随处搜索的键盘当前行 | 强调色浅底,区别于鼠标经过的中性 hover | | 中性 `secondary` | 面板页签、分段选项、普通按钮 | 次级导航不能与主要操作争夺注意力 | | `Semantics.danger` | 失败、Git 冲突、破坏性动作 | 红;必须同时有文字/图标/确认语义 | | `Semantics.warning` | 需要留意的状态 | 琥珀;Git 普通修改只用于尾部标记,不能染黄整个文件树 | @@ -80,11 +80,11 @@ - **文件树**:文件名中性;A/M/? 等尾部字符承载 Git 状态;目录用小点表示子项变化;冲突才整行提升。 - **Git 面板**:改动列表是主体。可执行的提交按钮才突出;未填写信息或不可提交时保持次级。 - **设置**:章节标题 > 标签 > 描述;开关/滑块/主操作共享强调色;分隔线弱于控件边界。 -- **菜单与命令面板**:亮度抬升、有限阴影;当前键盘行清楚,普通项不着色;小窗口内可滚动。 +- **菜单与随处搜索**:亮度抬升、有限阴影;当前键盘行清楚,普通项不着色;小窗口内可滚动。 - **终端**:ANSI 是用户内容,不拿来充当 UI 错误/成功配色;应用设计调整不得反转状态含义。 ## 验证要求 `cargo test -p tty7 --bin tty7-app --locked ui::` 覆盖内置主题的文字、语义色、 交互状态对比度及已有 UI 行为。另需在真实窗口检查浅色/深色、宽/窄窗口、侧栏、文件、 -Git、设置、菜单与命令面板。测试通过不能替代视觉层级检查。 +Git、设置、菜单与随处搜索。测试通过不能替代视觉层级检查。 diff --git a/docs/docs.json b/docs/docs.json index 762af9e8..3d23da68 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -33,7 +33,7 @@ "pages": [ "window/tabs-and-splits", "window/sidebar", - "window/command-palette", + "window/search-everywhere", "window/search", "window/side-panel" ] @@ -138,5 +138,11 @@ "github": "https://github.com/l0ng-ai/tty7", "discord": "https://discord.gg/s3dethqz2V" } - } + }, + "redirects": [ + { + "source": "/window/command-palette", + "destination": "/window/search-everywhere" + } + ] } diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index 3ab597c6..74340d52 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -8,7 +8,7 @@ down the left. Everything below is optional — but these are the settings peopl end up changing anyway, so they are worth five minutes now. Open Settings with ⌘ , (Ctrl , on Windows and Linux), or -from the command palette (⌘ P → *Settings*). +from Search Everywhere (⌘ P → *Settings*). tty7 Settings diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx index 5d55cd44..1c277df1 100644 --- a/docs/git/diffs.mdx +++ b/docs/git/diffs.mdx @@ -22,7 +22,7 @@ right-clicks to a menu that fills the window with it if you prefer that — ## Side-by-side or unified -**Toggle Unified / Side-by-Side Diff** in the command palette switches between +**Toggle Unified / Side-by-Side Diff** in Search Everywhere switches between the two. The choice is global — one setting for every diff, the same call VS Code's `diffEditor.renderSideBySide` makes — and persists as `diff_view` in `config.json`. diff --git a/docs/git/source-control.mdx b/docs/git/source-control.mdx index b8260887..2df89351 100644 --- a/docs/git/source-control.mdx +++ b/docs/git/source-control.mdx @@ -56,7 +56,7 @@ The branch name at the top of the panel is a dropdown. It holds: Beside it, the sync button pulls then pushes — and relabels itself **Publish Branch** when the branch has no upstream yet. -The command palette carries the verbs under **Git** — *Git: Commit*, *Stage +Search Everywhere carries the verbs under **Git** — *Git: Commit*, *Stage All*, *Unstage All*, *Discard All*, *Create Branch*, *Sync*, *Push*, *Pull*, *Fetch* — so those are bindable. Checking out is a pick rather than a verb, so it lives only in the dropdown. diff --git a/docs/git/worktrees.mdx b/docs/git/worktrees.mdx index f9521348..458fcda5 100644 --- a/docs/git/worktrees.mdx +++ b/docs/git/worktrees.mdx @@ -8,7 +8,7 @@ working tree. A git worktree is the fix, and tty7 makes it a single dialog. ## Creating one -**New Worktree Tab…** — in the command palette, the tab's right-click menu, and +**New Worktree Tab…** — in Search Everywhere, the tab's right-click menu, and the application menu — asks three things: | Field | Default | diff --git a/docs/images/command-palette.webp b/docs/images/command-palette.webp deleted file mode 100644 index b20672c3c1a1e6a4c68c0178697b79a9e55857fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105650 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
  • 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 diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index a1613e26..0cb6b008 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -36,7 +36,7 @@ it shows what *your* copy is bound to. This is the shipped default. | Action | macOS | Windows / Linux | |---|---|---| -| Command Palette | ⌘ P | Ctrl ⇧ P | +| Search Everywhere | ⌘ P | Ctrl ⇧ P | | Toggle Left Sidebar | ⌘ B | Ctrl ⇧ B | | Toggle Right Panel | ⌘ J | Ctrl ⇧ J | | Toggle Code Panel | ⌘ ⇧ E | Ctrl ⇧ E | @@ -103,7 +103,7 @@ the font size. ## Actions with no default key -All of these are in the command palette, and all are bindable under **Settings → +All of these are in Search Everywhere, and all are bindable under **Settings → Keybindings**: | Group | Actions | diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx index 1b4c424f..59dc66cc 100644 --- a/docs/remote/port-forwarding.mdx +++ b/docs/remote/port-forwarding.mdx @@ -56,8 +56,8 @@ later `8080 → 3000` explains nothing. ## Adding one mid-session -The **+** on the Ports section — or *SSH: Port Forwarding* in the command -palette — opens a form asking for one thing: the port the remote is serving on. +The **+** on the Ports section — or *SSH: Port Forwarding* in Search +Everywhere — opens a form asking for one thing: the port the remote is serving on. It comes out here under the same number, and the form says so before you commit to it. diff --git a/docs/remote/sftp.mdx b/docs/remote/sftp.mdx index 152f7ba7..882a4d6f 100644 --- a/docs/remote/sftp.mdx +++ b/docs/remote/sftp.mdx @@ -3,7 +3,7 @@ title: "SFTP" description: "A file browser for the machine on the other end of the connection." --- -While a pane is in an SSH session, *SSH: Remote Files* (command palette) slides +While a pane is in an SSH session, *SSH: Remote Files* (Search Everywhere) slides a file panel in over it. It is a real SFTP client on the same connection — no second login, no second password. diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx index 681e8bb2..b8321a30 100644 --- a/docs/remote/ssh.mdx +++ b/docs/remote/ssh.mdx @@ -19,7 +19,7 @@ instead of a password echoing into your shell. - Open the palette (⌘ P) and type an address. IPv6 works with + Open Search Everywhere (⌘ P) and type an address. IPv6 works with brackets. ``` @@ -30,8 +30,8 @@ instead of a password echoing into your shell. - Profiles live in **Settings → SSH → Hosts**. Start typing the name in the - palette, or open the *SSH: Manage Profiles…* command. + Profiles live in **Settings → SSH → Hosts**. Start typing the name in + Search Everywhere's Hosts tab, or open the *SSH: Manage Profiles…* command. @@ -204,7 +204,7 @@ Also under Security: **Warn before closing** a live connection, off by default. ## Reconnecting -⌘ ⇧ R — or *SSH: Reconnect* in the palette — restarts the session in +⌘ ⇧ R — or *SSH: Reconnect* in Search Everywhere — restarts the session in the current pane. Useful after a laptop sleeps or a network changes. ## What is not supported diff --git a/docs/terminal/selection-and-clipboard.mdx b/docs/terminal/selection-and-clipboard.mdx index ef3547c0..4d38b371 100644 --- a/docs/terminal/selection-and-clipboard.mdx +++ b/docs/terminal/selection-and-clipboard.mdx @@ -11,7 +11,7 @@ description: "Smart double-click, copy on select, and the settings around them." | Double-click | Select the thing under the cursor — see below | | Triple-click | Select the line | | ⇧-click | Extend the current selection to where you clicked | -| ⌘ A *(macOS)* | Select all — also in the right-click menu and the command palette on every platform | +| ⌘ A *(macOS)* | Select all — also in the right-click menu and Search Everywhere on every platform | ### Smart double-click diff --git a/docs/window/command-palette.mdx b/docs/window/command-palette.mdx deleted file mode 100644 index ac098dca..00000000 --- a/docs/window/command-palette.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Command palette" -description: "One key for every action in the app — including the ones with no shortcut." ---- - -⌘ P opens the command palette. Type to filter, ↑ -↓ to move, ⏎ to run. - -Every action tty7 can perform is in here, whether or not it has a keybinding — -which makes it the fastest way to reach the ones that deliberately ship unbound, -like pane resize and swap. - - - The tty7 command palette - - -## What is in it - -Results are grouped, and the groups are the map of the app: - -| Group | Examples | -|---|---| -| **Tabs & Panes** | New Tab · Shell: zsh · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | -| **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… | -| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff | -| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch | -| **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All | -| **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding | -| **Agents** | Send Selection · Send Git Diff for Review · Copy Session ID | -| **Application** | Settings… · Keyboard Shortcuts · Check for Updates… · Documentation · Join the Discord · Report an Issue… · Restart Server… | - -Entries that do something destructive say so under the name — *Delete -Workspace…* is subtitled "ends its shells and forgets the layout", *Restart -Server…* is "ends every running shell; layout is kept". - -## Connecting from the palette - -Type an SSH address and the palette offers to connect to it: - -``` -me@devbox -me@devbox:2222 -[::1]:22 -``` - -Saved profiles show up the same way — start typing the name. A host that only -exists in `~/.ssh/config` does not: import it into a profile first, or reach it -from the workspace switcher. [SSH →](/remote/ssh) - -## Sending context to an agent - -Two palette commands hand what is in front of you to the coding agent running in -the pane, as a ready-made prompt: - -- **Agent: Send Selection** — the current selection. -- **Agent: Send Git Diff for Review** — the repository's `git diff`. - -If no agent is running, tty7 says so rather than typing into your shell. -[Agents →](/agents/overview) diff --git a/docs/window/search-everywhere.mdx b/docs/window/search-everywhere.mdx new file mode 100644 index 00000000..27a7566f --- /dev/null +++ b/docs/window/search-everywhere.mdx @@ -0,0 +1,102 @@ +--- +title: "Search Everywhere" +description: "One key for every action, open tab, past agent session and host — each in a tab of its own." +--- + +⌘ P (Ctrl ⇧ P on Linux and Windows) opens Search +Everywhere. Type to filter, ↑ ↓ to move, ⏎ to +run, ⇥ / ⇧ ⇥ to move between its tabs. Press the key again +or Esc to close it. + +## Tabs + +| Tab | What it holds | +|---|---| +| **All** | The best few rows from each tab below, each under the tab's name. The tab holding the best match comes first, so ⏎ always runs the best answer anywhere. When a tab has more rows than fit, a *N more in …* row opens that tab with your query still typed. | +| **Actions** | Every action tty7 can perform, whether or not it has a keybinding — the fastest way to reach the ones that deliberately ship unbound, like pane resize and swap. | +| **Terminals** | Every open tab of every workspace, this window's first and most recently used first, then every way to open a new one: the shells this machine has (*Shell: zsh*) and the coding agents on your `PATH` (*Agent: Claude Code*). | +| **Sessions** | Past Claude Code and Codex sessions on this computer — the ones that ran in the focused tab's directory first, then the rest, newest first. ⏎ resumes one. | +| **Hosts** | Your saved SSH hosts, most used first — and whatever address you type. | + +With nothing typed, **All** leads with this window's other tabs, most recent +first, so ⌘ P ⏎ takes you back to the tab you were just +in. The actions you run most follow under *Actions*, and the last few agent +sessions that ran in this directory under *Sessions*. + +## Actions + +With nothing typed, the Actions tab lists what you use most under *Recent*, +then everything else in groups — the groups are the map of the app: + +| Group | Examples | +|---|---| +| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | +| **Workspaces** | New Workspace… · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… | +| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff | +| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch | +| **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All | +| **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding | +| **Agents** | New Agent Tab · Send Selection · Send Git Diff for Review · Copy Session ID | +| **Application** | Settings… · Keyboard Shortcuts · Check for Updates… · Documentation · Join the Discord · Report an Issue… · Restart Server… | + +Entries that do something destructive say so under the name — *Delete +Workspace…* is subtitled "ends its shells and forgets the layout", *Restart +Server…* is "ends every running shell; layout is kept". + +Every action is found by its name in any language tty7 ships, so an English +query finds a Chinese or Japanese label. + +**Change Theme…** opens the theme list in place: moving through it previews +each theme on the window, ⏎ keeps it, Esc puts the old one +back and returns to where you were. + +## Resuming a past session + +The **Sessions** tab lists the sessions Claude Code and Codex keep on this +computer (`~/.claude/projects`, and `~/.codex/sessions` or `$CODEX_HOME`), each +under the title the agent gave it — or the name you gave it with `/rename`, or +failing both what you first asked — with where it ran, its branch and when it +was last used. Search matches the title, the directory, the branch and the +session id. + +⏎ opens a new tab in the directory the session ran in and resumes it +there (`claude --resume `, `codex resume `), with the flags +`agent_launch` gives the agent. An agent only finds a session from the directory +it started in, so a session whose directory is gone is not resumed; tty7 says +so instead. + +The list is read in the background when the search opens, so a session started +a moment ago may appear a beat after the rest. Windows on a remote workspace do +not list sessions: they are this computer's, and a new tab there opens on the +other machine. + +## Connecting to a host + +Type an SSH address — in the **Hosts** tab, or straight into **All** — and it +offers to connect to it, or to save it as a host: + +``` +me@devbox +me@devbox:2222 +[::1]:22 +``` + +A full command line works too — `ssh -p 2222 -J jump me@devbox` connects with +exactly those options, and `ssh devbox` resolves an alias from +`~/.ssh/config` the way `ssh` would. A bare word on its own is a search, not +an address. + +Saved hosts are listed by name; ⌘ E on one opens it for editing +instead of connecting. **SSH: Add Connection…** in the Actions tab takes you to +the Hosts tab. [SSH →](/remote/ssh) + +## Sending context to an agent + +Two actions hand what is in front of you to the coding agent running in the +pane, as a ready-made prompt: + +- **Agent: Send Selection** — the current selection. +- **Agent: Send Git Diff for Review** — the repository's `git diff`. + +If no agent is running, tty7 says so rather than typing into your shell. +[Agents →](/agents/overview) diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx index a5a1a749..d33bea5f 100644 --- a/docs/window/side-panel.mdx +++ b/docs/window/side-panel.mdx @@ -85,13 +85,13 @@ same column. Drag the divider for any width between a fifth and four fifths of that space — the range `document_ratio` keeps — or double-click it to cycle a third, a half -and two thirds. **Document: Third / Half / Two-Thirds Width** in the command -palette do the same. On a narrow window the terminal's own floor stops the +and two thirds. **Document: Third / Half / Two-Thirds Width** in Search +Everywhere do the same. On a narrow window the terminal's own floor stops the divider sooner. Right-click the document's header for **Fill window**, which is the old full-workspace overlay, unchanged. **Document: Fill Window** and **Document: -Dock Beside Terminal** in the palette are the same switch. +Dock Beside Terminal** in Search Everywhere are the same switch. Fill or dock is **per tab**: read a long file over the whole window in one tab while an agent keeps half of another, and neither moves the other. A fresh tab diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index e53b214d..51c1fc3a 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -48,8 +48,8 @@ Ways to pin: | To get | Do this | |---|---| | A folder group from an auto group | Click the ◆ on its header, or drag the header up among the pinned groups | -| A folder group from a folder | Drop it from Finder onto the sidebar, choose **Pin as Group** on it in the Files panel, or run **Open Folder as Group…** from the command palette | -| A label group | Right-click a tab → **Move to Group → New Group…**, or run **New Group** from the command palette | +| A folder group from a folder | Drop it from Finder onto the sidebar, choose **Pin as Group** on it in the Files panel, or run **Open Folder as Group…** from Search Everywhere | +| A label group | Right-click a tab → **Move to Group → New Group…**, or run **New Group** from Search Everywhere | Right-click a pinned group's header to rename it, point it at a folder (**Set Folder…**, or **Use Current Tab's Folder**), clear its folder, open a tab in it, diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx index 7a6ef61f..8628f78f 100644 --- a/docs/window/tabs-and-splits.mdx +++ b/docs/window/tabs-and-splits.mdx @@ -49,7 +49,7 @@ A split inherits the current pane's working directory, so splitting inside a repository keeps you in the repository. Resizing and swapping panes have no default keys — bind them under **Settings → -Keybindings**, or run them from the command palette (*Resize Pane Left*, *Swap +Keybindings**, or run them from Search Everywhere (*Resize Pane Left*, *Swap Pane Next*, and friends). Inactive panes are dimmed slightly so the focused one is obvious. Turn that off diff --git a/src/core/actions.rs b/src/core/actions.rs index b01cf6d0..a13dbbca 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -128,6 +128,8 @@ actions!( SendBackTab, SwitcherAcross, SwitcherAcrossBack, + SearchNextTab, + SearchPrevTab, Quit ] ); diff --git a/src/core/agent_history.rs b/src/core/agent_history.rs new file mode 100644 index 00000000..adb67229 --- /dev/null +++ b/src/core/agent_history.rs @@ -0,0 +1,640 @@ +//! Past coding-agent sessions on this computer, read off the agents' own +//! history files, so one can be picked and resumed. +//! +//! Only the ends of each file are read: the head carries where the session +//! ran and what it was first asked, the tail the title the agent last gave +//! it. A transcript runs to megabytes, and reading the whole of hundreds of +//! them to list their names would make opening the list the slow part. +//! +//! Parsed files are remembered by path, size and modification time, so after +//! the first scan only the sessions that moved are read again. + +use std::collections::HashMap; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::Value; + +use crate::core::cli_agent::CLIAgent; + +/// How many sessions are listed, newest first. Past this the list is a +/// search target, not something to scroll, and older sessions are rarely +/// worth resuming. +const MAX_SESSIONS: usize = 500; + +/// How much of a file's head and tail is read. +const HEAD_BYTES: u64 = 64 * 1024; +const TAIL_BYTES: u64 = 128 * 1024; + +/// How much of a prompt a title keeps. +const TITLE_CHARS: usize = 120; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PastSession { + pub agent: CLIAgent, + pub id: String, + /// Where it ran — where it has to be resumed, since agents key their + /// history by directory. + pub cwd: Option, + pub title: String, + pub branch: Option, + /// Last written, in Unix seconds. + pub updated: u64, +} + +/// Every past session found under `home`, most recently used first. +/// +/// `codex_home` is `$CODEX_HOME` when set; Codex keeps its sessions under +/// `~/.codex` otherwise. +pub fn scan(home: &Path, codex_home: Option<&Path>) -> Vec { + let codex_home = codex_home + .map(Path::to_path_buf) + .unwrap_or_else(|| home.join(".codex")); + let mut files = claude_files(home); + files.extend(codex_files(&codex_home)); + files.sort_by_key(|f| std::cmp::Reverse(f.modified)); + files.truncate(MAX_SESSIONS); + // Codex names a thread after the fact, in an index beside the sessions + // rather than in the session's own file. + let codex_titles = codex_thread_names(&codex_home); + + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut out = Vec::new(); + let mut seen = HashMap::with_capacity(files.len()); + for file in files { + let hit = cache + .get(&file.path) + .filter(|c| c.len == file.len && c.modified == file.modified) + .map(|c| c.session.clone()); + let session = match hit { + Some(session) => session, + None => match file.agent { + CLIAgent::Codex => read_codex(&file.path, unix(file.modified)), + _ => read_claude(&file.path, file.len, unix(file.modified)), + }, + }; + seen.insert( + file.path.clone(), + Cached { + len: file.len, + modified: file.modified, + session: session.clone(), + }, + ); + out.extend(session.map(|mut s| { + if let Some(name) = codex_titles + .get(&s.id) + .filter(|_| s.agent == CLIAgent::Codex) + { + s.title = name.clone(); + } + s + })); + } + // What was not seen this time was deleted or fell off the end. + *cache = seen; + out +} + +/// What the last [`scan`] found, without touching the disk — what the list +/// shows while a new scan runs. +pub fn cached() -> Vec { + let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut out: Vec = cache.values().filter_map(|c| c.session.clone()).collect(); + out.sort_by_key(|s| std::cmp::Reverse(s.updated)); + out +} + +/// `$CODEX_HOME`, if set. +pub fn codex_home() -> Option { + std::env::var_os("CODEX_HOME") + .filter(|h| !h.is_empty()) + .map(PathBuf::from) +} + +/// This user's home, where the agents keep their history. +pub fn home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .filter(|h| !h.is_empty()) + .map(PathBuf::from) +} + +struct Cached { + len: u64, + modified: SystemTime, + /// `None` for a file that holds no session worth listing — remembered + /// too, so it is not read again until it changes. + session: Option, +} + +static CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +struct Found { + agent: CLIAgent, + path: PathBuf, + len: u64, + modified: SystemTime, +} + +fn unix(t: SystemTime) -> u64 { + t.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) +} + +/// Claude Code keeps one `.jsonl` per session under +/// `~/.claude/projects//`. The directories +/// beside those files hold a session's subagent transcripts, which are not +/// sessions of their own. +fn claude_files(home: &Path) -> Vec { + let root = home.join(".claude").join("projects"); + let Ok(projects) = std::fs::read_dir(&root) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for project in projects.flatten() { + let Ok(entries) = std::fs::read_dir(project.path()) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_none_or(|e| e != "jsonl") { + continue; + } + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + out.push(Found { + agent: CLIAgent::Claude, + path, + len: meta.len(), + modified: meta.modified().unwrap_or(UNIX_EPOCH), + }); + } + } + out +} + +/// Codex writes `sessions/YYYY/MM/DD/rollout-