feat(ui): collapse the New Tab menu's shell list to three by frecency (#960)

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.
This commit is contained in:
l0ng-ai
2026-09-25 16:44:32 +08:00
committed by GitHub
parent bd0dd22bfa
commit 27483e893d
11 changed files with 331 additions and 29 deletions
+11
View File
@@ -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
+7
View File
@@ -404,6 +404,12 @@ pub struct Config {
/// beside them.
#[serde(default, skip_serializing)]
pub ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
/// 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<String, ProfileUsage>,
#[serde(default)]
pub command_frecency: HashMap<String, ProfileUsage>,
@@ -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,
+4 -3
View File
@@ -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.
<Note>
If the file cannot be parsed, tty7 starts on defaults, keeps your original at
+1 -1
View File
@@ -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 |
+30
View File
@@ -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::<Config>().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) {
+2
View File
@@ -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",
+2
View File
@@ -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 => "デフォルト",
+3
View File
@@ -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.
+2
View File
@@ -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 => "默认",
+5
View File
@@ -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,
+264 -25
View File
@@ -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<Tty7App>,
shells: Vec<(SharedString, ShellSpec)>,
/// The shells the menu names, by label — at most [`MENU_SHELLS`].
shells: Vec<SharedString>,
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<String, crate::core::config::ProfileUsage>,
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<String, crate::core::config::ProfileUsage>,
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<Self>, cx: &App) -> NewTabMenu {
let default_shell = self.default_shell_label(cx);
let usage = &cx.global::<Config>().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<Self>,
) {
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<DetectedShell> {
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<String, crate::core::config::ProfileUsage> {
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<String> {
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"
);
}
}