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" + ); + } }