mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-28 08:55:06 +08:00
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:
1 parent
c8cc08a1d0
commit
22132631eb
8 files changed
+202
-6
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user