From 27483e893da7b1bcc13f16a59415fb05a3587f13 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 25 Sep 2026 16:44:32 +0800 Subject: [PATCH] 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" + ); + } }