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>
This commit is contained in:
l0ng-ai
2026-08-16 18:17:59 +08:00
committed by GitHub
co-authored by l0ng-ai
parent ccd21fe97d
commit 6e193c404f
2 changed files with 77 additions and 1 deletions
+76
View File
@@ -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);
+1 -1
View File
@@ -2251,7 +2251,7 @@ impl TerminalView {
}
"w" => {
if !self.cmd.delete_selection() {
self.cmd.delete_word_left();
self.cmd.delete_path_component_left();
}
}
"u" => {