From 22132631ebe4b6bbce3c4e78ef1db83ac99ef419 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:40:28 +0800 Subject: [PATCH] fix: shell history, config ZDOTDIR, keymap hot-reload and window close (#1216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: shell history, config ZDOTDIR, keymap hot-reload and window close - A ZDOTDIR set in config `env` no longer replaces the zsh integration redirector; it is handed to it as TTY7_USER_ZDOTDIR instead. - Shell integration reports the shell's HISTFILE to `/shell-histfiles`, so ⌃R and GUI history find history kept outside $HOME/.zsh_history. - The config watcher watches the canonical config dir: FSEvents reports resolved paths, so a dir reached through a symlink (/tmp) never matched and `keybindings` edits were not picked up. - ⌘↩ in the ⌃R menu runs the selected line instead of toggling full screen. - Close Window (shells keep running) gets ⇧⌘W on macOS and a File menu entry. * fix: keep the newest reported histfiles; bind the menu's run to cmd-enter - shell-histfiles is append-only and panes starting together append the same path; reading the first 32 lines let stale or duplicate entries hide every path reported later. Keep the newest 32 distinct ones. - secondary-enter is ctrl-enter off macOS, where the Ctrl+R menu only accepts on that chord. Bind cmd-enter, matching the menu's own key handler on every platform. --- crates/tty7-core/src/daemon/pane.rs | 57 ++++++++++++++++++- .../tty7-core/src/daemon/shell_integration.rs | 30 +++++++++- docs/reference/keyboard-shortcuts.mdx | 3 +- src/main.rs | 4 ++ src/terminal/history.rs | 56 ++++++++++++++++++ src/terminal/view.rs | 23 +++++++- src/ui/keymap.rs | 32 ++++++++++- src/ui/theme.rs | 3 + 8 files changed, 202 insertions(+), 6 deletions(-) diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index ea3a1708..10c16418 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -548,6 +548,31 @@ fn pane_environment( env } +/// zsh integration works by pointing `ZDOTDIR` at tty7's redirector dir, which +/// then sources the user's own startup files from `TTY7_USER_ZDOTDIR`. A +/// `ZDOTDIR` in the configured `env` block is applied after that and used to +/// win outright: zsh read the user's files directly and the pane came up with +/// no prompt marks, no editor, no suggestions. When the redirector is in +/// place, the configured value is where the user's files live, so it goes +/// where the redirector looks for them instead. +fn reroute_configured_zdotdir( + env: Vec<(String, String)>, + zsh_integration: bool, +) -> Vec<(String, String)> { + if !zsh_integration { + return env; + } + env.into_iter() + .map(|(k, v)| { + if k == "ZDOTDIR" { + ("TTY7_USER_ZDOTDIR".to_string(), v) + } else { + (k, v) + } + }) + .collect() +} + fn apply_common_command_setup( cmd: &mut CommandBuilder, initial_cwd: &Option, @@ -602,7 +627,15 @@ fn apply_common_command_setup( } let dark = crate::core::machine::appearance().dark; - for (k, v) in pane_environment(&extra_env, dark, pane, workspace, shell) { + let injected_zdotdir = cmd + .get_env("ZDOTDIR") + .and_then(|z| z.to_str()) + .filter(|z| shell_integration::is_our_zdotdir(z)) + .map(str::to_owned); + for (k, v) in reroute_configured_zdotdir( + pane_environment(&extra_env, dark, pane, workspace, shell), + injected_zdotdir.is_some(), + ) { cmd.env(k, v); } @@ -4910,6 +4943,28 @@ mod tests { assert_eq!(argv, vec!["/bin/fish", "-C", "echo ready"]); } + #[test] + fn a_configured_zdotdir_feeds_the_redirector_instead_of_replacing_it() { + let env = vec![ + ("ZDOTDIR".to_string(), "/home/a/.config/zsh".to_string()), + ("EDITOR".to_string(), "vi".to_string()), + ]; + + let rerouted = reroute_configured_zdotdir(env.clone(), true); + assert_eq!( + rerouted, + vec![ + ( + "TTY7_USER_ZDOTDIR".to_string(), + "/home/a/.config/zsh".to_string() + ), + ("EDITOR".to_string(), "vi".to_string()), + ] + ); + // Without the redirector there is nothing to protect. + assert_eq!(reroute_configured_zdotdir(env.clone(), false), env); + } + #[test] fn env_only_integration_keeps_default_login_shell_builder() { let mut cmd = CommandBuilder::new_default_prog(); diff --git a/crates/tty7-core/src/daemon/shell_integration.rs b/crates/tty7-core/src/daemon/shell_integration.rs index 774e22d7..cd2c6fa4 100644 --- a/crates/tty7-core/src/daemon/shell_integration.rs +++ b/crates/tty7-core/src/daemon/shell_integration.rs @@ -6,6 +6,20 @@ const ZSH_INTEGRATION: &str = r#" if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then export TTY7_SHELL_INTEGRATION=1 + # Tell the app where this shell keeps its history. $HISTFILE is set by the + # rc file just run and is never exported, so the history menu, which reads + # the file directly, would otherwise only know the default ~/.zsh_history. + # One line per distinct path, written once. + if [[ -n "$HISTFILE" && "$HISTFILE" == /* && -n "$TTY7_CONFIG_DIR" ]]; then + __tty7_hf_list="$TTY7_CONFIG_DIR/shell-histfiles" + __tty7_hf_known= + [[ -r "$__tty7_hf_list" ]] && __tty7_hf_known="$(<"$__tty7_hf_list")" + if [[ $'\n'"$__tty7_hf_known"$'\n' != *$'\n'"$HISTFILE"$'\n'* ]]; then + builtin printf '%s\n' "$HISTFILE" >> "$__tty7_hf_list" 2>/dev/null + fi + unset __tty7_hf_list __tty7_hf_known + fi + # Per-pane history, when the app asked for it. This runs after the user's # .zshrc, which is the only reason it can work at all: $HISTFILE is theirs to # set, wherever they like, and nothing outside this shell knew where it @@ -208,6 +222,20 @@ const BASH_INTEGRATION: &str = r#" if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then export TTY7_SHELL_INTEGRATION=1 + # Tell the app where this shell keeps its history. $HISTFILE is set by the + # rc file just run and is never exported, so the history menu, which reads + # the file directly, would otherwise only know the default ~/.bash_history. + # One line per distinct path, written once. + if [[ -n "$HISTFILE" && "$HISTFILE" == /* && -n "$TTY7_CONFIG_DIR" ]]; then + __tty7_hf_list="$TTY7_CONFIG_DIR/shell-histfiles" + __tty7_hf_known= + [[ -r "$__tty7_hf_list" ]] && __tty7_hf_known="$(<"$__tty7_hf_list")" + if [[ $'\n'"$__tty7_hf_known"$'\n' != *$'\n'"$HISTFILE"$'\n'* ]]; then + builtin printf '%s\n' "$HISTFILE" >> "$__tty7_hf_list" 2>/dev/null + fi + unset __tty7_hf_list __tty7_hf_known + fi + # Per-pane history — see the zsh block for why this can only be done here, # after the user's rc has decided where their history lives. bash loads the # file after the startup files too, so repointing here still precedes the @@ -727,7 +755,7 @@ pub struct Injection { const ZDOTDIR_PREFIX: &str = "tty7-zdotdir-"; -fn is_our_zdotdir(path: &str) -> bool { +pub(crate) fn is_our_zdotdir(path: &str) -> bool { Path::new(path) .file_name() .and_then(|n| n.to_str()) diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx index 9f6b4738..069e5282 100644 --- a/docs/reference/keyboard-shortcuts.mdx +++ b/docs/reference/keyboard-shortcuts.mdx @@ -99,6 +99,7 @@ the font size. |---|---|---| | Settings | ⌘ , | Ctrl , | | Keyboard Shortcuts | ⌘ / | — | +| Close Window (shells keep running) | ⌘ ⇧ W | — | | Hide tty7 · Hide Others · Minimize | ⌘ H · ⌘ ⌥ H · ⌘ M | — | | Quit | ⌘ Q | Ctrl ⇧ Q | @@ -117,7 +118,7 @@ Keybindings**: | Panels | `ShowRightPanelInfo` · `ShowRightPanelFiles` · `ShowRightPanelSearch` · `ShowRightPanelChanges` · `ShowRightPanelGitHub` | | Documents | `ToggleDocumentFill` · `DocumentWidthThird` · `DocumentWidthHalf` · `DocumentWidthTwoThirds` · `ToggleDocumentPreview` · `ToggleDocumentWrap` | | SSH | `ToggleSftp` · `ShowSshForwards` · `OpenSshProfiles` | -| Application | `CloseWindow` · `About` · `CheckForUpdates` · `OpenDocumentation` · `OpenDiscord` · `ReportIssue` · `ShowAll` · `ZoomWindow` | +| Application | `CloseWindow` (Windows / Linux) · `About` · `CheckForUpdates` · `OpenDocumentation` · `OpenDiscord` · `ReportIssue` · `ShowAll` · `ZoomWindow` | ## Rebinding syntax diff --git a/src/main.rs b/src/main.rs index 226ce766..262c0414 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,10 @@ fn spawn_config_watcher(cx: &mut App) { return; }; let _ = std::fs::create_dir_all(&dir); + // FSEvents reports the resolved path, and a watch registered through a + // symlink (`/tmp` is `/private/tmp` on macOS) never matches it: every + // edit to config.json went unseen. + let dir = std::fs::canonicalize(&dir).unwrap_or(dir); const DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); diff --git a/src/terminal/history.rs b/src/terminal/history.rs index 3c177978..eb8f3fea 100644 --- a/src/terminal/history.rs +++ b/src/terminal/history.rs @@ -311,6 +311,11 @@ fn load_shell_history() -> Vec { if let Some(hf) = std::env::var_os("HISTFILE") { add(PathBuf::from(hf)); } + // Where tty7's own shells said their history lives: `HISTFILE` is set in + // an rc file and never reaches this process's environment. + for hf in reported_history_files() { + add(hf); + } if let Some(home) = std::env::var_os("HOME") { let home = PathBuf::from(home); add(home.join(".zsh_history")); @@ -326,6 +331,35 @@ fn load_shell_history() -> Vec { read_shell_history_files(files) } +/// Written by the shell integration snippet, one absolute path per line. +pub const REPORTED_HISTORY_FILES: &str = "shell-histfiles"; + +fn reported_history_files() -> Vec { + config_path(REPORTED_HISTORY_FILES) + .and_then(|list| std::fs::read_to_string(list).ok()) + .map(|text| parse_reported_history_files(&text)) + .unwrap_or_default() +} + +fn parse_reported_history_files(text: &str) -> Vec { + // The file is written from inside panes, so it is only a hint: a relative + // path would resolve against this process's directory, and a list that + // grew without bound is not one worth reading in full. It is only ever + // appended to, and panes starting together each append the same path, so + // the newest distinct paths are the ones kept. + let mut seen = HashSet::new(); + let mut files: Vec = text + .lines() + .rev() + .map(str::trim) + .filter(|line| looks_absolute(line) && seen.insert(*line)) + .take(32) + .map(PathBuf::from) + .collect(); + files.reverse(); + files +} + fn read_shell_history_files(mut files: Vec) -> Vec { files.sort_by_key(|p| { std::fs::metadata(p) @@ -698,6 +732,28 @@ mod tests { ); } + #[test] + fn reported_history_files_keep_only_absolute_paths() { + let files = parse_reported_history_files("/a/.zsh_history\nrelative\n\n /b/hist \n"); + assert_eq!( + files, + vec![PathBuf::from("/a/.zsh_history"), PathBuf::from("/b/hist")] + ); + } + + #[test] + fn reported_history_files_keep_the_newest_distinct_paths() { + let mut text = String::new(); + for i in 0..40 { + text.push_str(&format!("/old/{i}\n/dup\n")); + } + text.push_str("/new\n"); + let files = parse_reported_history_files(&text); + assert_eq!(files.len(), 32); + assert_eq!(files.last(), Some(&PathBuf::from("/new"))); + assert_eq!(files.iter().filter(|p| *p == Path::new("/dup")).count(), 1); + } + #[test] fn plain_bash_lines() { assert_eq!( diff --git a/src/terminal/view.rs b/src/terminal/view.rs index b8a6f3cf..decd1931 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -128,7 +128,8 @@ actions!( ClearScrollback, InsertNewline, InsertNewlineFallback, - ToggleComposer + ToggleComposer, + RunReverseSearchSelection ] ); @@ -3415,6 +3416,9 @@ impl TerminalView { if self.frame_alt_screen { context.add("alt_screen"); } + if self.reverse_search.is_some() { + context.add("reverse_search"); + } context } @@ -5607,6 +5611,20 @@ impl TerminalView { cx.notify(); } + /// The menu's Cmd+Enter, which arrives as an action rather than a key: + /// on macOS the chord is `ToggleFullscreen`, and a window-wide binding is + /// matched before the pane's key handler ever sees the keystroke. + fn run_reverse_search_selection(&mut self, cx: &mut Context) { + let Some(rs) = self.reverse_search.take() else { + return; + }; + if let Some(line) = rs.selected_line(&self.history).map(str::to_string) { + self.cmd.set(&line); + self.submit_command(cx); + } + cx.notify(); + } + fn handoff_line_to_shell(&mut self, chord: &[u8], cx: &mut Context) { if !self.accepts_input(cx) { return; @@ -8004,6 +8022,9 @@ impl Render for TerminalView { .on_action(cx.listener(|this, _: &ToggleComposer, window, cx| { this.toggle_composer(window, cx); })) + .on_action(cx.listener(|this, _: &RunReverseSearchSelection, _w, cx| { + this.run_reverse_search_selection(cx); + })) .on_action(cx.listener(|this, _: &SendTab, _w, cx| { this.tab_pressed(true, cx); })) diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 567c2d14..82d11252 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -4,7 +4,7 @@ use crate::core::actions::*; use crate::core::config::{Config, KeybindingOverride}; use crate::terminal::view::{ AlternatePaste, ClearScrollback, CopyRaw, CopyText, FindInTerminal, FindNext, FindPrevious, - InsertNewline, InsertNewlineFallback, PasteText, ToggleComposer, + InsertNewline, InsertNewlineFallback, PasteText, RunReverseSearchSelection, ToggleComposer, }; use crate::ui::i18n::{L10nKey, t, t_fmt}; use crate::ui::palette; @@ -53,6 +53,16 @@ fn fixed_bindings() -> Vec { KeyBinding::new("secondary-+", IncreaseFontSize, None), KeyBinding::new("tab", SendTab, Some("Terminal")), KeyBinding::new("shift-tab", SendBackTab, Some("Terminal")), + // The Ctrl+R menu runs its selection on Cmd+Enter, which on macOS is + // also `ToggleFullscreen`. Scoped to the open menu, it is deeper in + // the dispatch path than the window binding and wins only there. + // `cmd`, not `secondary`: the menu's own key handler runs on the + // platform key everywhere, and Ctrl+Enter off macOS only accepts. + KeyBinding::new( + "cmd-enter", + RunReverseSearchSelection, + Some("Terminal && reverse_search"), + ), ]; // The switcher's footer tells you Tab is the way across to the tab column // once a query is in the box. gpui-component's Root binds `tab` to its @@ -425,7 +435,11 @@ fn shipped_bindings() -> Vec<(&'static str, &'static str)> { // shipping unbound: the palette and the Keybindings page both carry // this, so a key is one line of config away. ("NewWindow", per_platform("secondary-n", "")), - ("CloseWindow", ""), + // Closing the window is how tty7 is put away with every shell still + // running, and Cmd+Q — the only chord a Mac user reaches for — stops + // the server instead. Cmd+Shift+W is what "Close Window" means on + // macOS and is free here. Off macOS that chord is `CloseActiveTab`. + ("CloseWindow", per_platform("secondary-shift-w", "")), ( "CloseActiveTab", per_platform("secondary-w", "secondary-shift-w"), @@ -3214,6 +3228,20 @@ mod gpui_tests { }); } + #[cfg(target_os = "macos")] + #[gpui::test] + fn cmd_shift_w_closes_the_window_with_its_shells_still_running(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + running_on_json(cx, "{}"); + assert_eq!( + fired(cx, "cmd-shift-w").first(), + Some(&CloseWindow::name_for_type()), + "Cmd+Q stops the server, so a Mac needs a chord that only puts tty7 away" + ); + }); + } + #[gpui::test] fn repeated_rebinds_do_not_grow_the_keymap(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 3a52e052..eeecf048 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -90,6 +90,9 @@ pub(crate) fn set_menus(cx: &mut App) { MenuItem::action(t(L10nKey::AppMenuForkSession), ForkAgentSession), MenuItem::separator(), MenuItem::action(t(L10nKey::AppMenuClosePaneTab), CloseActiveTab), + // Where a Mac user looks for the way to put the window away; it + // is also the one path that works when another app has taken ⇧⌘W. + MenuItem::action(t(L10nKey::CmdCloseWindow), CloseWindow), MenuItem::action(t(L10nKey::AppMenuCloseOtherTabs), CloseOtherTabs), MenuItem::action(t(L10nKey::AppMenuCloseTabsRight), CloseTabsToTheRight), MenuItem::action(t(L10nKey::AppMenuReopenClosedTab), ReopenClosedTab),