fix(terminal): recall the last matching command on ↑ and Ctrl+P (#768)

* fix(terminal): recall the last matching command on ↑ and Ctrl+P

The prompt editor walked history in file-load order and ignored the
prefix on the line, so the first press showed whatever had been
concatenated last — often an old tty7 record, or a command that had
nothing to do with what was already typed. Keep the prefix from when
navigation started, the way zsh's up-line-or-beginning-search does,
and order merged history files by timestamp.

* fix(terminal): search history on the text left of the cursor

up-line-or-beginning-search matches on $BUFFER[1,CURSOR], not on the
whole line, so Ctrl+A followed by UP has to walk every entry rather than
filter on text the user is about to type in front of. Keep the search
prefix and the line stashed for DOWN in separate fields: restoring what
was typed still needs the part sitting right of the cursor.

Also cover the borrowed-mtime path, which had no test: untimestamped
bash lines must take the file mtime, keep a real timestamp when they
have one, and survive an unreadable mtime untouched.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
momo
2026-09-03 16:06:58 +08:00
committed by GitHub
co-authored by l0ng-ai
parent e231b16fb3
commit 2584efa28e
3 changed files with 249 additions and 15 deletions
-1
View File
@@ -32,7 +32,6 @@ impl CmdEditor {
self.cursor
}
#[allow(dead_code)]
pub fn cursor_byte(&self) -> usize {
self.chars[..self.cursor].iter().map(|c| c.len_utf8()).sum()
}
+151
View File
@@ -104,7 +104,9 @@ pub fn load_with_shell_files(scope: &Scope, shell_files: Vec<(String, Vec<u8>)>)
if let Some(path) = scope.file()
&& let Ok(content) = std::fs::read_to_string(&path)
{
let start = raw.len();
raw.extend(content.lines().map(parse_own_line));
stamp_missing(&mut raw[start..], file_mtime_secs(&path));
}
normalize(raw)
}
@@ -252,6 +254,19 @@ pub fn append(scope: &Scope, cmd: &str, cwd: Option<&Path>, ts: u64, exit: Optio
}
fn normalize(raw: Vec<Raw>) -> History {
let mut raw = raw;
// Sources are concatenated (shell files, then tty7's own file), so vector
// order is "who was loaded last", not "what ran last". Sort by timestamp
// first: a command from ~/.zsh_history a minute ago must outrank one
// recorded in tty7 last week, or ↑ shows the wrong line. Untimestamped
// entries stay older than timestamped ones and keep relative file order
// among themselves (stable sort).
raw.sort_by(|a, b| match (a.ts, b.ts) {
(Some(ta), Some(tb)) => ta.cmp(&tb),
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
});
let mut counts: HashMap<String, u32> = HashMap::new();
let mut cwds: HashMap<String, HashSet<String>> = HashMap::new();
let mut meta: HashMap<String, EntryMeta> = HashMap::new();
@@ -329,12 +344,37 @@ fn load_shell_history() -> Vec<Raw> {
for path in files {
if let Ok(bytes) = std::fs::read(&path) {
let name = path.file_name().unwrap_or_default().to_string_lossy();
let start = out.len();
parse_history_file(&name, &String::from_utf8_lossy(&bytes), &mut out);
// bash (and a bare HISTFILE) often have no per-line timestamp.
// Borrow the file's mtime so those entries can still be ordered
// against zsh/fish/tty7 records that do carry one — otherwise a
// month-old tty7 file concatenated last always wins ↑.
stamp_missing(&mut out[start..], file_mtime_secs(&path));
}
}
out
}
fn file_mtime_secs(path: &Path) -> Option<u64> {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
}
fn stamp_missing(raw: &mut [Raw], ts: Option<u64>) {
let Some(ts) = ts else {
return;
};
for r in raw {
if r.ts.is_none() {
r.ts = Some(ts);
}
}
}
/// Which reader a history file gets, by file name. The remote side has only
/// bytes and a name, so the choice has to hang off the name in both paths.
fn parse_history_file(name: &str, content: &str, out: &mut Vec<Raw>) {
@@ -690,6 +730,117 @@ mod tests {
assert_eq!(parse_own_line("echo\tfoo").cmd, "echo\tfoo");
}
#[test]
fn normalize_orders_by_timestamp_not_by_which_file_was_concatenated_last() {
// load() appends tty7's own file after the shell histories, so without
// a timestamp sort an old tty7 record becomes "most recent" and ↑
// recalls it instead of the command actually run last.
let raw = vec![
Raw {
cmd: "recent shell".into(),
cwd: None,
ts: Some(200),
exit: None,
},
Raw {
cmd: "old tty7".into(),
cwd: None,
ts: Some(100),
exit: None,
},
];
let h = normalize(raw);
assert_eq!(
h.entries.last().map(String::as_str),
Some("recent shell"),
"the later timestamp must be the one ↑ recalls first: {:?}",
h.entries
);
assert_eq!(h.entries, ["old tty7", "recent shell"]);
}
#[test]
fn stamp_missing_fills_blanks_and_leaves_real_timestamps_alone() {
let mut raw = vec![
Raw {
cmd: "bash line".into(),
cwd: None,
ts: None,
exit: None,
},
Raw {
cmd: "zsh line".into(),
cwd: None,
ts: Some(100),
exit: None,
},
];
stamp_missing(&mut raw, Some(500));
assert_eq!(raw[0].ts, Some(500), "a bash line borrows the file mtime");
assert_eq!(
raw[1].ts,
Some(100),
"a line that knows its own time keeps it"
);
// An unreadable mtime must not wipe what is already there.
let mut blank = vec![Raw {
cmd: "no time".into(),
cwd: None,
ts: None,
exit: None,
}];
stamp_missing(&mut blank, None);
assert_eq!(blank[0].ts, None);
}
#[test]
fn file_mtime_secs_reads_a_real_file_and_gives_up_on_a_missing_one() {
let dir = std::env::temp_dir().join(format!("tty7-hist-mtime-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("history");
std::fs::write(&path, b"echo hi\n").unwrap();
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let ts = file_mtime_secs(&path).expect("a file just written has an mtime");
assert!(ts.abs_diff(now) < 60, "mtime {ts} should sit near {now}");
assert_eq!(file_mtime_secs(&dir.join("absent")), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_borrowed_mtime_slots_untimestamped_entries_into_the_timeline() {
// Without a borrowed mtime these sort as the oldest thing there is and
// ↑ can never walk back to them; with it the block lands where the
// file was last written.
let mut raw = vec![Raw {
cmd: "bash cmd".into(),
cwd: None,
ts: None,
exit: None,
}];
stamp_missing(&mut raw, Some(150));
raw.push(Raw {
cmd: "old zsh".into(),
cwd: None,
ts: Some(100),
exit: None,
});
raw.push(Raw {
cmd: "new zsh".into(),
cwd: None,
ts: Some(200),
exit: None,
});
let h = normalize(raw);
assert_eq!(h.entries, ["old zsh", "bash cmd", "new zsh"]);
}
#[test]
fn normalize_dedups_keeping_latest_and_drops_blanks() {
let raw = vec![
+98 -14
View File
@@ -351,6 +351,7 @@ pub struct TerminalView {
ranked_cwd: Option<std::path::PathBuf>,
history_nav: Option<usize>,
history_stash: String,
history_prefix: String,
last_word_nav: Option<LastWordWalk>,
pending_history: Option<PendingHistory>,
completion: Option<CompletionSession>,
@@ -1403,6 +1404,7 @@ impl TerminalView {
ranked_cwd: None,
history_nav: None,
history_stash: String::new(),
history_prefix: String::new(),
last_word_nav: None,
pending_history: None,
completion: None,
@@ -4083,6 +4085,7 @@ impl TerminalView {
}
self.history_nav = None;
self.history_stash.clear();
self.history_prefix.clear();
self.close_completion();
self.wipe_pending_typeahead();
@@ -4144,32 +4147,44 @@ impl TerminalView {
cx.notify();
}
/// Walk older history, matching the prefix captured when navigation
/// started — zsh's `up-line-or-beginning-search`, which macOS users get
/// from ↑ and Ctrl+P. The prefix is the text left of the cursor, so
/// Ctrl+A then ↑ walks every entry; the whole line is stashed separately
/// because ↓ past the newest match has to restore what was typed, cursor
/// tail included.
fn history_prev(&mut self, cx: &mut Context<Self>) {
if self.history.is_empty() {
return;
}
let next = match self.history_nav {
let from = match self.history_nav {
None => {
self.history_stash = self.cmd.text();
self.history.len() - 1
let line = self.cmd.text();
self.history_prefix = line[..self.cmd.cursor_byte()].to_string();
self.history_stash = line;
self.history.len()
}
Some(0) => 0,
Some(i) => i - 1,
Some(i) => i,
};
self.history_nav = Some(next);
self.cmd.set(&self.history[next]);
cx.notify();
if let Some(next) = (0..from)
.rev()
.find(|&i| self.history[i].starts_with(&self.history_prefix))
{
self.history_nav = Some(next);
self.cmd.set(&self.history[next]);
cx.notify();
}
}
fn history_next(&mut self, cx: &mut Context<Self>) {
let Some(i) = self.history_nav else {
return;
};
if i + 1 < self.history.len() {
self.history_nav = Some(i + 1);
self.cmd.set(&self.history[i + 1]);
if let Some(next) =
(i + 1..self.history.len()).find(|&j| self.history[j].starts_with(&self.history_prefix))
{
self.history_nav = Some(next);
self.cmd.set(&self.history[next]);
} else {
self.history_nav = None;
self.history_prefix.clear();
let stash = std::mem::take(&mut self.history_stash);
self.cmd.set(&stash);
}
@@ -11747,6 +11762,75 @@ mod gpui_tests {
.unwrap();
}
#[gpui::test]
fn up_and_ctrl_p_recall_the_last_command_matching_the_typed_prefix(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = ["git status", "echo hello", "git log", "ls"]
.into_iter()
.map(String::from)
.collect();
view.cmd.set("git");
view.handle_editor_key(&key("up"), cx);
assert_eq!(
view.cmd.text(),
"git log",
"↑ skips ls and echo hello, which do not start with git"
);
view.handle_editor_key(&key("up"), cx);
assert_eq!(view.cmd.text(), "git status");
view.handle_editor_key(&key("down"), cx);
assert_eq!(view.cmd.text(), "git log");
view.handle_editor_key(&key("down"), cx);
assert_eq!(
view.cmd.text(),
"git",
"↓ past the newest match restores the prefix"
);
view.cmd.set("echo");
view.handle_editor_key(&key("ctrl-p"), cx);
assert_eq!(view.cmd.text(), "echo hello");
})
.unwrap();
}
#[gpui::test]
fn history_search_takes_its_prefix_from_the_text_left_of_the_cursor(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
view.history = ["git status", "echo hello", "git log", "ls"]
.into_iter()
.map(String::from)
.collect();
// Ctrl+A parks the cursor at the start. Nothing sits left of
// it, so UP walks the whole list instead of filtering on a line
// the user is about to edit in front of.
view.cmd.set_with_cursor("git", 0);
view.handle_editor_key(&key("up"), cx);
assert_eq!(view.cmd.text(), "ls", "an empty prefix filters nothing");
// A cursor parked mid-line searches on what is behind it, and
// DOWN past the newest match restores the whole line, the part
// right of the cursor included.
view.history_nav = None;
view.cmd.set_with_cursor("git hello", 4);
view.handle_editor_key(&key("up"), cx);
assert_eq!(
view.cmd.text(),
"git log",
"the prefix is `git `, not the whole `git hello`"
);
view.handle_editor_key(&key("down"), cx);
assert_eq!(view.cmd.text(), "git hello");
})
.unwrap();
}
#[gpui::test]
fn ctrl_e_accepts_a_ghost_suggestion_at_the_end(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();