diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index 4b207795..e6b806b4 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -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() } diff --git a/src/terminal/history.rs b/src/terminal/history.rs index 0e72f24c..8167eb0a 100644 --- a/src/terminal/history.rs +++ b/src/terminal/history.rs @@ -104,7 +104,9 @@ pub fn load_with_shell_files(scope: &Scope, shell_files: Vec<(String, Vec)>) 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) -> 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 = HashMap::new(); let mut cwds: HashMap> = HashMap::new(); let mut meta: HashMap = HashMap::new(); @@ -329,12 +344,37 @@ fn load_shell_history() -> Vec { 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 { + 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) { + 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) { @@ -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![ diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 293da4d1..316d10cd 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -351,6 +351,7 @@ pub struct TerminalView { ranked_cwd: Option, history_nav: Option, history_stash: String, + history_prefix: String, last_word_nav: Option, pending_history: Option, completion: Option, @@ -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) { - 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) { 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();