fix: shell history, config ZDOTDIR, keymap hot-reload and window close (#1216)

* 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
  `<config>/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.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-10-23 20:40:28 +08:00
1 parent c8cc08a1d0
commit 22132631eb
8 files changed
+202 -6

No files matched your search

+56 -1
View File
@@ -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<PathBuf>,
@@ -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();
@@ -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())
+2 -1
View File
@@ -99,6 +99,7 @@ the font size.
|---|---|---|
| Settings | <kbd>⌘ ,</kbd> | <kbd>Ctrl ,</kbd> |
| Keyboard Shortcuts | <kbd>⌘ /</kbd> | — |
| Close Window (shells keep running) | <kbd>⌘ ⇧ W</kbd> | — |
| Hide tty7 · Hide Others · Minimize | <kbd>⌘ H</kbd> · <kbd>⌘ ⌥ H</kbd> · <kbd>⌘ M</kbd> | — |
| Quit | <kbd>⌘ Q</kbd> | <kbd>Ctrl ⇧ Q</kbd> |
@@ -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
+4
View File
@@ -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);
+56
View File
@@ -311,6 +311,11 @@ fn load_shell_history() -> Vec<Raw> {
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<Raw> {
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<PathBuf> {
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<PathBuf> {
// 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<PathBuf> = 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<PathBuf>) -> Vec<Raw> {
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!(
+22 -1
View File
@@ -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<Self>) {
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<Self>) {
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);
}))
+30 -2
View File
@@ -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> {
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| {
+3
View File
@@ -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),