feat(shells): let the new-tab menu carry entries the user wrote (#534)

Closes #443
This commit is contained in:
webdev
2026-08-12 10:52:35 +08:00
committed by GitHub
parent bc7141404e
commit 4da3868797
9 changed files with 290 additions and 3 deletions
+14
View File
@@ -128,6 +128,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
automount off cannot see, and a `ZDOTDIR` aimed at nothing would start zsh
with none of the user's own startup files at all. (#135)
- **Put your own entries in the new-tab menu** — `custom_shells` in
`config.json` takes a list of `{label, program, args}`, and each one becomes a
row in the menu behind the sidebar's **+**, after the shells tty7 detected. A
distro, a container, a REPL, the same shell against a different profile: they
are launched exactly as written, since tty7 chose none of the command and has
no defaults to add to it — including its shell integration, so a custom entry
opens without prompt marks or working-directory tracking even where the
detected row beside it has both. `shell` still names the one command that
stands in for the platform default; these are the rest. An entry with no
`program` is
skipped, one with no `label` is named after what it runs, and one that
borrows a name already in the menu is marked `(Custom)` so the row telling
you what a plain new tab opens with stays the only one wearing it. (#443)
### Changed
- **`tty7 pane close --json` now reports `{"closed": [ids]}`** rather than a
+45
View File
@@ -148,6 +148,12 @@ pub struct Config {
#[serde(default = "default_prefix")]
pub prefix: String,
pub shell: Option<ShellConfig>,
/// Lenient on purpose: this is a hand-edited key with a nested shape, and a
/// typo in one entry must not fail the whole `Config` and hand the user
/// back defaults that the next settings write would then persist over what
/// they wrote.
#[serde(default, deserialize_with = "de_lenient")]
pub custom_shells: Vec<CustomShell>,
pub link_url: bool,
pub link_file_command: Option<String>,
@@ -432,6 +438,21 @@ pub struct ShellConfig {
pub args: Vec<String>,
}
/// A launcher the user put in the new-tab menu themselves.
///
/// `shell` names the one command that stands in for the platform default;
/// these are the rest — a distro, a container, a REPL, the same shell against a
/// different profile. They are launched exactly as written, which is why `args`
/// crosses as user-authored: tty7 has no defaults to contribute to a command it
/// did not choose.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct CustomShell {
pub label: String,
pub program: String,
pub args: Vec<String>,
}
pub fn default_font_fallbacks() -> Vec<String> {
let names: &[&str] = if cfg!(target_os = "macos") {
&[
@@ -494,6 +515,7 @@ impl Default for Config {
keybinding_preset: default_preset(),
prefix: default_prefix(),
shell: None,
custom_shells: Vec::new(),
link_url: true,
link_file_command: None,
ssh_loopback_forward: false,
@@ -1510,6 +1532,29 @@ mod tests {
);
}
#[test]
fn a_mistyped_custom_shell_does_not_cost_the_user_the_rest_of_the_config() {
let cfg = Config::default();
assert!(cfg.custom_shells.is_empty());
let cfg: Config =
serde_json::from_str(r#"{"font_size": 15.0, "custom_shells": {"Ubuntu": "wsl.exe"}}"#)
.expect("a bad custom_shells must not fail the whole config parse");
assert!(cfg.custom_shells.is_empty());
// The point of the leniency: `Config::load` hands back defaults for a
// config that fails to parse, and the next settings write persists them
// over the file. Everything the user wrote beside the typo survives.
assert_eq!(cfg.font_size, 15.0);
let cfg: Config = serde_json::from_str(
r#"{"custom_shells":[{"label":"Ubuntu","program":"wsl.exe","args":["-d","Ubuntu"]}]}"#,
)
.expect("a well-formed list parses");
assert_eq!(cfg.custom_shells.len(), 1);
assert_eq!(cfg.custom_shells[0].program, "wsl.exe");
assert_eq!(cfg.custom_shells[0].args, ["-d", "Ubuntu"]);
}
#[test]
fn config_path_resolves_under_the_pinned_dir() {
pin_config_dir();
+192 -2
View File
@@ -16,6 +16,14 @@ pub struct DetectedShell {
/// as tty7 defaults, so `true` preserves the previous protocol behavior.
#[serde(default = "default_true")]
pub args_are_tty7_defaults: bool,
/// Marks a row the user wrote into `custom_shells` rather than one tty7
/// found. Detection is what Settings offers to stand in for the platform
/// default; an entry the user added is a menu extra, and a picker that
/// carries only a program would quietly drop the arguments that make it
/// what it is. Older peers omit this field and had no such rows, so `false`
/// is the honest reading of their inventory.
#[serde(default)]
pub user_authored: bool,
}
impl DetectedShell {
@@ -25,6 +33,7 @@ impl DetectedShell {
program: program.into(),
args: Vec::new(),
args_are_tty7_defaults: true,
user_authored: false,
}
}
}
@@ -40,8 +49,66 @@ pub struct ShellInventory {
}
pub fn inventory() -> ShellInventory {
let configured = crate::core::config::shell_command();
inventory_from(detect_shells(), configured, &login_shell())
// One read for both halves: `shell_command()` would open and parse
// `config.json` a second time, and this runs every time the menu is built.
let config = crate::core::config::Config::load();
let configured = config
.shell
.as_ref()
.map(|s| (s.program.clone(), s.args.clone()));
let mut inventory = inventory_from(detect_shells(), configured, &login_shell());
append_custom(&mut inventory, &config.custom_shells);
inventory
}
/// Adds the user's own menu entries after everything that was detected.
///
/// After, not among: the detected list is ordered so the shell a new tab
/// actually opens with sits at the top, and that ordering is the menu's answer
/// to "what do I get if I just click". A user-authored entry is an extra, not a
/// candidate for that position — so this runs once the default has already been
/// settled and cannot move it.
fn append_custom(inventory: &mut ShellInventory, custom: &[crate::core::config::CustomShell]) {
for (index, entry) in custom.iter().enumerate() {
let program = entry.program.trim();
if program.is_empty() {
// Nothing to launch. The rest of the entry may be perfectly well
// formed, but a menu row that opens nothing is worse than no row.
// Say which one, though: a misspelled key deserializes to an entry
// that is simply empty, and a row that never appears is otherwise
// indistinguishable from the feature not working.
log::warn!("custom_shells[{index}] has no program; skipping it");
continue;
}
let mut label = entry.label.trim().to_string();
if label.is_empty() {
label = basename(program);
}
// The menu marks its default row by matching the label
// (`tab_strip::shell_menu`), so a custom entry that borrows a name
// already in the list would wear a mark meant for another row. Same
// answer the configured shell already gets when it collides — and it
// has to keep answering until the name is actually free, since the
// suffixed name can collide in its turn with a second entry that
// borrowed the same one, or with a label the user wrote suffix and
// all. `default_name` counts even when no row carries it: a hole
// `inventory_from` can leave, and the one name a custom row must never
// occupy.
while inventory.default_name == label
|| inventory.shells.iter().any(|shell| shell.label == label)
{
label.push_str(" (Custom)");
}
inventory.shells.push(DetectedShell {
label,
program: program.to_string(),
args: entry.args.clone(),
// tty7 chose none of this, so none of it is a tty7 default: the
// arguments have to survive into the pane exactly as written.
args_are_tty7_defaults: false,
user_authored: true,
});
}
}
pub fn detect_shells() -> Vec<DetectedShell> {
@@ -118,6 +185,7 @@ fn inventory_from(
program,
args,
args_are_tty7_defaults: false,
user_authored: false,
},
);
}
@@ -427,6 +495,7 @@ fn detect_windows() -> Vec<DetectedShell> {
program: bash.to_string_lossy().into_owned(),
args: vec!["-i".into(), "-l".into()],
args_are_tty7_defaults: true,
user_authored: false,
});
}
@@ -436,6 +505,7 @@ fn detect_windows() -> Vec<DetectedShell> {
program: "wsl.exe".into(),
args: vec!["--distribution".into(), distro, "--cd".into(), "~".into()],
args_are_tty7_defaults: true,
user_authored: false,
});
}
@@ -1181,4 +1251,124 @@ mod tests {
assert!(!default_shell_name(None).is_empty());
assert!(!default_shell_name(Some(" ")).is_empty());
}
fn custom(label: &str, program: &str, args: &[&str]) -> crate::core::config::CustomShell {
crate::core::config::CustomShell {
label: label.to_string(),
program: program.to_string(),
args: args.iter().map(|a| a.to_string()).collect(),
}
}
fn menu(shells: &[&str]) -> ShellInventory {
ShellInventory {
shells: shells.iter().map(|s| DetectedShell::bare(*s, *s)).collect(),
default_name: shells.first().map(|s| s.to_string()).unwrap_or_default(),
}
}
#[test]
fn a_custom_entry_joins_the_menu_with_its_arguments_intact() {
let mut inventory = menu(&["zsh"]);
append_custom(
&mut inventory,
&[custom("Ubuntu (dev)", "wsl.exe", &["-d", "Ubuntu"])],
);
let added = inventory.shells.last().expect("the entry");
assert_eq!(added.label, "Ubuntu (dev)");
assert_eq!(added.program, "wsl.exe");
assert_eq!(added.args, vec!["-d".to_string(), "Ubuntu".to_string()]);
assert!(
!added.args_are_tty7_defaults,
"tty7 contributed none of this command, so none of it may be replaced as a default"
);
assert_eq!(
inventory.default_name, "zsh",
"an extra entry does not change what a new tab opens with"
);
}
#[test]
fn a_custom_entry_with_nothing_to_launch_is_not_offered() {
let mut inventory = menu(&["zsh"]);
append_custom(&mut inventory, &[custom("Broken", " ", &[])]);
assert_eq!(inventory.shells.len(), 1);
}
#[test]
fn a_custom_entry_with_no_label_is_named_after_what_it_runs() {
let mut inventory = menu(&["zsh"]);
append_custom(&mut inventory, &[custom(" ", "/opt/homebrew/bin/nu", &[])]);
assert_eq!(inventory.shells.last().expect("the entry").label, "nu");
}
#[test]
fn a_custom_entry_cannot_take_another_rows_name() {
let mut inventory = menu(&["zsh", "bash"]);
append_custom(&mut inventory, &[custom("zsh", "/usr/local/bin/zsh", &[])]);
// The menu tells its default row apart by label alone, so two rows
// called "zsh" would put that mark on whichever came first.
assert_eq!(
inventory.shells.last().expect("the entry").label,
"zsh (Custom)"
);
assert_eq!(
inventory
.shells
.iter()
.filter(|s| s.label == inventory.default_name)
.count(),
1
);
}
#[test]
fn custom_entries_that_all_want_the_same_name_still_get_one_each() {
let mut inventory = menu(&["zsh"]);
append_custom(
&mut inventory,
&[
custom("zsh", "/a", &[]),
custom("zsh", "/b", &[]),
custom("zsh (Custom)", "/c", &[]),
],
);
// Two rows with the same name launching different programs is the one
// outcome the menu cannot present: `conformance` fails a host whose
// inventory names a label twice, and the user cannot tell the rows
// apart to pick between them.
let labels: Vec<_> = inventory.shells.iter().map(|s| &s.label).collect();
let unique: std::collections::HashSet<_> = labels.iter().collect();
assert_eq!(labels.len(), unique.len(), "{labels:?}");
}
#[test]
fn a_custom_entry_cannot_claim_a_default_name_no_row_carries() {
// `inventory_from` can name a default that is in no row — a login shell
// recorded in passwd whose file is gone. The menu marks its default by
// label, so a custom entry landing on that name would wear the mark
// while a plain new tab opened something else entirely.
let mut inventory = menu(&["zsh"]);
inventory.default_name = "ksh".into();
append_custom(&mut inventory, &[custom("ksh", "/usr/bin/ksh", &[])]);
assert_eq!(
inventory.shells.last().expect("the entry").label,
"ksh (Custom)"
);
}
#[test]
fn no_custom_entries_leaves_the_menu_exactly_as_it_was() {
let before = menu(&["zsh", "bash"]);
let mut after = before.clone();
append_custom(&mut after, &[]);
assert_eq!(before, after);
}
}
+1
View File
@@ -1037,6 +1037,7 @@ mod tests {
program: "/usr/bin/zsh".into(),
args: vec!["--no-rcs".into()],
args_are_tty7_defaults: false,
user_authored: false,
}],
default_name: "zsh".into(),
})),
+1
View File
@@ -85,6 +85,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. |
| `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 — which also means tty7 adds no shell integration to one, so a custom entry has no prompt marks, working-directory tracking, or command-finished notifications even where the detected row beside it does. 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 (100100,000). New panes only. |
+4
View File
@@ -90,6 +90,10 @@ fn spawn_config_watcher(cx: &mut App) {
// in-app language picker does.
crate::ui::theme::set_menus(cx);
crate::ui::windows::WindowRegistry::refresh_locale(cx, None);
// `custom_shells` is only ever hand-edited, so this file is the
// one place it can change from — and the inventory that carries
// it to the new-tab menu is cached per window.
crate::ui::windows::WindowRegistry::refresh_shells(cx);
cx.refresh_windows();
});
}
+12 -1
View File
@@ -4615,7 +4615,18 @@ impl Tty7App {
// so the same choice was a menu in one place and a blind text field in
// the other. The field stays: a shell tty7 did not find still has to be
// reachable by path.
let shells = self.shells.shells.clone();
// Detected shells only. A `custom_shells` row is a menu extra rather
// than a candidate for the platform default, and this picker hands its
// choice on as a program alone — so offering one here would set the
// default to a bare program and drop the arguments the user wrote it
// for, silently.
let shells: Vec<_> = self
.shells
.shells
.iter()
.filter(|shell| !shell.user_authored)
.cloned()
.collect();
let current_program = program_input.read(cx).value().trim().to_string();
let platform_default_item: SharedString = if cfg!(windows) {
"PowerShell".into()
+1
View File
@@ -2103,6 +2103,7 @@ mod tests {
program: "custom-shell".into(),
args: vec!["--login".into()],
args_are_tty7_defaults: false,
user_authored: false,
};
let spec = shell_spec(&shell);
+20
View File
@@ -132,6 +132,26 @@ impl WindowRegistry {
}
}
/// Rebuilds every window's shell inventory from disk.
///
/// `custom_shells` is read while that inventory is assembled, and it lives
/// in `config.json` — which hot-reloads. The menu it feeds has to follow the
/// file there, or the one surface the feature has appears not to work until
/// the app is restarted, while every other key in the same save takes hold
/// at once.
pub fn refresh_shells(cx: &mut App) {
Self::sweep(cx);
let apps: Vec<_> = cx
.global::<Self>()
.windows
.iter()
.map(|entry| entry.app.clone())
.collect();
for app in apps {
let _ = app.update(cx, |app, cx| app.refresh_shells(cx));
}
}
fn register(
cx: &mut App,
workspace: WorkspaceId,