From 6e193c404f9fdd506012faaf5f3e9dc4e79ecee6 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 16 Aug 2026 18:17:59 +0800 Subject: [PATCH] fix(editor): kill paths one component at a time on ctrl-w (#658) (#659) The built-in command editor intercepts ctrl-w before the shell sees it, and its whitespace-only word boundaries killed a whole path in one stroke. fish binds ctrl-w to backward-kill-path-component, so users coming from kitty or Terminal.app expect /usr/local/bin to go one segment at a time. Mirror fish's path-component word motion: at most one run per character class, separators (slash, equals, quotes, ...) end a kill next to whitespace on their own. alt-backspace keeps the coarse whitespace-delimited kill, matching fish's split between the two chords. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/terminal/cmd_editor.rs | 76 ++++++++++++++++++++++++++++++++++++++ src/terminal/view.rs | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index 69f795c2..4b207795 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -320,6 +320,50 @@ impl CmdEditor { self.kill_range(self.cursor, end); } + fn eat_left(&mut self, pred: impl Fn(char) -> bool) { + while self.cursor > 0 && pred(self.chars[self.cursor - 1]) { + self.cursor -= 1; + } + } + + /// fish's ⌃W (`backward-kill-path-component`): stop at `/` and friends + /// instead of eating a whole whitespace-delimited token, so a path is + /// killed one component at a time (#658). Mirrors fish's word-motion + /// state machine: at most one run of each character class, and a + /// separator run next to whitespace ends the kill on its own. + pub fn delete_path_component_left(&mut self) { + fn sep(c: char) -> bool { + !c.is_whitespace() && "/={,}'\":@|;<>&".contains(c) + } + fn word(c: char) -> bool { + !c.is_whitespace() && !sep(c) + } + self.checkpoint(); + let end = self.cursor; + let before = |cursor: usize, chars: &[char]| (cursor > 0).then(|| chars[cursor - 1]); + match before(self.cursor, &self.chars) { + Some(c) if c.is_whitespace() => { + self.eat_left(char::is_whitespace); + match before(self.cursor, &self.chars) { + Some('/') => { + self.eat_left(|c| c == '/'); + self.eat_left(word); + } + Some(c) if word(c) => self.eat_left(word), + Some(_) => self.eat_left(sep), + None => {} + } + } + Some(c) if word(c) => self.eat_left(word), + Some(_) => { + self.eat_left(sep); + self.eat_left(word); + } + None => {} + } + self.kill_range(self.cursor, end); + } + pub fn delete_to_start(&mut self) { self.checkpoint(); let end = self.cursor; @@ -452,6 +496,38 @@ mod tests { assert_eq!(d.cursor(), 9); } + #[test] + fn path_component_delete_walks_a_path_one_segment_at_a_time() { + let mut e = ed("ls /usr/local/bin", 17); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls /usr/local/"); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls /usr/"); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls /"); + e.delete_path_component_left(); + assert_eq!(e.text(), "ls "); + e.delete_path_component_left(); + assert_eq!((e.text().as_str(), e.cursor()), ("", 0)); + e.yank(); + assert_eq!(e.text(), "ls "); + + let mut f = ed("--out=/tmp/x", 12); + f.delete_path_component_left(); + assert_eq!(f.text(), "--out=/tmp/"); + f.delete_path_component_left(); + assert_eq!(f.text(), "--out=/"); + f.delete_path_component_left(); + assert_eq!(f.text(), ""); + } + + #[test] + fn path_component_delete_matches_word_delete_on_plain_words() { + let mut e = ed("echo hello world", 16); + e.delete_path_component_left(); + assert_eq!((e.text().as_str(), e.cursor()), ("echo hello ", 11)); + } + #[test] fn kills_fill_the_kill_buffer_and_yank_puts_it_back() { let mut e = ed("git push origin", 15); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 48247876..196f4887 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2251,7 +2251,7 @@ impl TerminalView { } "w" => { if !self.cmd.delete_selection() { - self.cmd.delete_word_left(); + self.cmd.delete_path_component_left(); } } "u" => {