mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
feat(shells): let the new-tab menu carry entries the user wrote (#534)
Closes #443
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
|
||||
@@ -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 (100–100,000). New panes only. |
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user