mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
feat(history): Ctrl+R fuzzy search menu with run metadata (#45)
* feat(history): Ctrl+R fuzzy search menu with run metadata Ctrl+R grows from the single-line reverse-i-search into a browsable menu of ranked candidates floating beside the prompt: - Matching is fuzzy (src/terminal/fuzzy.rs, a dependency-free affine-gap aligner: word-boundary and consecutive-run bonuses, gap penalties; space-separated query terms must all match), blended with the existing frecency scores so a command you run constantly — or ran in this directory — outranks an equally-good textual match. - An empty query lists the whole history by frecency, so bare Ctrl+R is a "recent & relevant" browser. Matched characters highlight in the rows; Ctrl+R/Down and Ctrl+S/Up move the selection, Enter loads the line into the editor, Cmd+Enter runs it outright. The classic (reverse-i-search) prompt line stays. - History records now carry run metadata: new lines are <ts>\t<exit>\t<cwd>\t<command>, written when the command finishes (zsh INC_APPEND_HISTORY_TIME-style) so the exit code sniffed from OSC 133;D lands in the record; older formats still parse, and zsh/bash HISTFILE timestamps carry over when seeding. The menu shows "ran 3h ago" and a red x-badge on commands whose last run failed. - RemoteTerminal exposes prompt_seq/last_exit_code so the view can tell a fresh post-command prompt report from the stale pre-submit state even when 1 Hz polling misses a fast command's running window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: cargo fmt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
l0ng-ai
parent
2c3b13fc99
commit
7401fd59df
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Ctrl+R history search is now a browsable menu: matching is fuzzy
|
||||
(subsequence with word-boundary/consecutive bonuses; space-separated terms
|
||||
must all match) blended with frecency, and the ranked candidates float
|
||||
beside the prompt — matched characters highlighted, selection moved by
|
||||
Ctrl+R/↓ and Ctrl+S/↑, Enter to edit, Cmd+Enter to run outright. An empty
|
||||
query lists the whole history by frecency, so bare Ctrl+R is a "recent &
|
||||
relevant" browser. The classic `(reverse-i-search)` line stays. (#45)
|
||||
- History records now carry when the command ran and its exit code: new
|
||||
entries are `<ts>\t<exit>\t<cwd>\t<command>`, written when the command
|
||||
*finishes* (zsh `INC_APPEND_HISTORY_TIME`-style, exit code sniffed from
|
||||
OSC 133;D daemon-side); older formats still load. The Ctrl+R menu shows
|
||||
"ran 3h ago" and a `✗` badge on commands whose last run failed; timestamps
|
||||
from zsh/bash history files are carried over when seeding. (#45)
|
||||
|
||||
## [0.8.0] - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -115,7 +115,7 @@ The essentials:
|
||||
| <kbd>⌘ K</kbd> | clear the screen and scrollback |
|
||||
| <kbd>⌘ P</kbd> | command palette |
|
||||
| <kbd>⌘ F</kbd> | search the scrollback |
|
||||
| <kbd>⌃ R</kbd> | reverse-search shell history |
|
||||
| <kbd>⌃ R</kbd> | fuzzy-search shell history |
|
||||
| <kbd>⌘ +</kbd> · <kbd>⌘ −</kbd> · <kbd>⌘ 0</kbd> | font size up · down · reset |
|
||||
|
||||
The full list — and any overrides — lives in **Settings → Keybindings**.
|
||||
|
||||
@@ -134,11 +134,11 @@ Features include:
|
||||
|
||||
History behavior:
|
||||
|
||||
- tty7 writes `<cwd>\t<command>` lines when cwd is known.
|
||||
- Legacy bare lines parse without cwd association.
|
||||
- Load normalizes blanks/duplicates and caps entries.
|
||||
- tty7 writes `<ts>\t<exit>\t<cwd>\t<command>` lines, deferred until the command finishes so the record carries its exit code (see `data-and-config.md` for the format and flush rules).
|
||||
- Older `<cwd>\t<command>` and legacy bare lines parse without the missing fields.
|
||||
- Load normalizes blanks/duplicates, keeps per-command last-run metadata (timestamp + exit code), and caps entries.
|
||||
- Frecency ranking combines recency, frequency, and current-directory bonus.
|
||||
- Up/Down uses chronological history; ghost suggestion uses frecency-ranked history.
|
||||
- Up/Down uses chronological history; ghost suggestion uses frecency-ranked history; Ctrl+R blends fuzzy match scores with frecency.
|
||||
|
||||
### Completion
|
||||
|
||||
@@ -157,7 +157,7 @@ Completion is intentionally shallow: whitespace-delimited word detection, simple
|
||||
|
||||
URL handling prefers OSC 8 hyperlinks and falls back to bare URL detection. Cmd/Ctrl-click opens links when `Config::link_url` is enabled.
|
||||
|
||||
`src/terminal/reverse_search.rs` implements Ctrl+R history search with case-insensitive contains matching, repeated Ctrl+R to advance older, Enter to accept into editor, and Escape/Ctrl+G/Ctrl+C to cancel.
|
||||
`src/terminal/reverse_search.rs` implements Ctrl+R history search as a ranked match list: `src/terminal/fuzzy.rs` scores case-insensitive fuzzy subsequences (whitespace-separated query terms must all match), blended with each entry's frecency; an empty query lists the whole history by frecency. The view renders the candidates in a completion-style menu (`render_reverse_search_menu`) with matched characters highlighted and last-run time / failure badges from history metadata, plus the classic `(reverse-i-search)` prompt on the input row. Ctrl+R/↓ and Ctrl+S/↑ move the selection, Enter accepts into the editor, Cmd+Enter runs the selection outright, and Escape/Ctrl+G/Ctrl+C cancels.
|
||||
|
||||
## Settings, keymap, and theme
|
||||
|
||||
|
||||
@@ -89,14 +89,25 @@ Files and sources:
|
||||
|
||||
Format:
|
||||
|
||||
- New tty7 entries are `<cwd>\t<command>` when cwd is an absolute path.
|
||||
- Legacy bare command lines are accepted.
|
||||
- New tty7 entries are `<ts>\t<exit>\t<cwd>\t<command>`: unix seconds when the
|
||||
command ran, that run's exit code (empty when the run never completed under
|
||||
tty7's watch), the absolute cwd (empty when unknown), then the command — the
|
||||
last field, so its own tabs survive.
|
||||
- The record is written when the command *finishes*, not when it is submitted
|
||||
(like zsh's `INC_APPEND_HISTORY_TIME`), so it can carry the exit code the
|
||||
daemon sniffs from OSC 133;D. A pane that goes away mid-command flushes the
|
||||
record without one.
|
||||
- Older `<cwd>\t<command>` lines and legacy bare command lines are accepted.
|
||||
- Timestamps from zsh extended history and bash `HISTTIMEFORMAT` comments are
|
||||
carried over when seeding.
|
||||
|
||||
Load behavior:
|
||||
|
||||
- Blanks are dropped.
|
||||
- Duplicates are collapsed while preserving most recent occurrence semantics.
|
||||
- Counts and cwd sets are accumulated for ranking.
|
||||
- Last-run metadata (timestamp + exit code) is kept per command — the Ctrl+R
|
||||
menu's "ran 3h ago" and failure badges.
|
||||
- Entry count is capped.
|
||||
|
||||
Ranking:
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Fuzzy subsequence matching for the Ctrl+R history search.
|
||||
//!
|
||||
//! A small affine-gap aligner in the fzf/skim family: every query character
|
||||
//! must appear in the haystack in order (a subsequence), and the returned score
|
||||
//! rewards runs of consecutive matches and matches at word boundaries while
|
||||
//! penalizing gaps — so `gst` prefers `git status` over `grep -rn "s" tests`.
|
||||
//! The matched character positions come back too, so the menu can highlight
|
||||
//! exactly which characters matched.
|
||||
//!
|
||||
//! Whitespace in the query splits it into terms that must *all* match
|
||||
//! (anywhere, in any order) — `git push` finds `git push -f origin` but also
|
||||
//! `push-all git-mirrors`. Matching is always case-insensitive, like the
|
||||
//! substring search this replaces.
|
||||
//!
|
||||
//! Kept dependency-free on purpose: command lines are short, so the O(m×n)
|
||||
//! dynamic program is comfortably cheap even against thousands of history
|
||||
//! entries per keystroke.
|
||||
|
||||
/// A successful match: the alignment score (higher is better; only comparable
|
||||
/// between matches of the *same query*) and the matched char indices into the
|
||||
/// haystack, ascending and deduplicated.
|
||||
pub(super) struct FuzzyMatch {
|
||||
pub score: i32,
|
||||
pub positions: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Every matched character is worth this much before bonuses.
|
||||
const SCORE_MATCH: i32 = 16;
|
||||
/// Bonus for a match at a word boundary (start of the line, or right after a
|
||||
/// separator) — `st` should land on the `status` in `git status`.
|
||||
const BONUS_BOUNDARY: i32 = 12;
|
||||
/// Bonus for extending a run of consecutive matches — favours tight matches
|
||||
/// over the same letters scattered across the line. Deliberately worth more
|
||||
/// than a boundary bonus reached across a gap (`BONUS_BOUNDARY +
|
||||
/// PENALTY_GAP_START = 9`), so `ab` still prefers the literal `ab` over the
|
||||
/// two word heads of `a-b`.
|
||||
const BONUS_CONSECUTIVE: i32 = 10;
|
||||
/// Cost of opening a gap between two matched characters…
|
||||
const PENALTY_GAP_START: i32 = -3;
|
||||
/// …and of each further character that gap skips.
|
||||
const PENALTY_GAP_EXTEND: i32 = -1;
|
||||
|
||||
/// "Impossible" sentinel. Kept far from `i32::MIN` so adding penalties/bonuses
|
||||
/// to a sentinel value can never wrap around into a plausible score.
|
||||
const NEG: i32 = i32::MIN / 2;
|
||||
|
||||
/// Match `query` against `line`. Whitespace splits the query into terms which
|
||||
/// must all match; scores add up and positions merge. `None` when the query is
|
||||
/// blank or any term fails to match.
|
||||
pub(super) fn match_line(line: &str, query: &str) -> Option<FuzzyMatch> {
|
||||
let terms: Vec<&str> = query.split_whitespace().collect();
|
||||
if terms.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let hay: Vec<char> = line.chars().collect();
|
||||
let hay_lc: Vec<char> = hay.iter().map(|&c| lc(c)).collect();
|
||||
let bonus: Vec<i32> = (0..hay.len())
|
||||
.map(|j| char_bonus(if j == 0 { None } else { Some(hay[j - 1]) }))
|
||||
.collect();
|
||||
|
||||
let mut score = 0;
|
||||
let mut positions = std::collections::BTreeSet::new();
|
||||
for term in terms {
|
||||
let t: Vec<char> = term.chars().map(lc).collect();
|
||||
let (s, pos) = match_term(&hay_lc, &bonus, &t)?;
|
||||
score += s;
|
||||
positions.extend(pos);
|
||||
}
|
||||
Some(FuzzyMatch {
|
||||
score,
|
||||
positions: positions.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Lowercase a char for comparison (first mapping only — `ß`→`ss` expansions
|
||||
/// don't matter for scoring command lines).
|
||||
fn lc(c: char) -> char {
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
}
|
||||
|
||||
/// The word-boundary bonus a match at a position earns, given the preceding
|
||||
/// character (`None` at the start of the line).
|
||||
fn char_bonus(prev: Option<char>) -> i32 {
|
||||
match prev {
|
||||
None => BONUS_BOUNDARY,
|
||||
Some(c)
|
||||
if c.is_whitespace() || matches!(c, '/' | '-' | '_' | '.' | ':' | '=' | ',' | '\\') =>
|
||||
{
|
||||
BONUS_BOUNDARY
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Align one lowercased `term` against the lowercased haystack, returning the
|
||||
/// best score and the matched positions. Classic affine-gap DP:
|
||||
/// `m[i][j]` is the best score with `term[i]` matched at `hay[j]`, reachable
|
||||
/// either consecutively from `m[i-1][j-1]` or across a gap (tracked by a
|
||||
/// running per-row maximum so each cell is O(1)); `parent[i][j]` remembers the
|
||||
/// chosen predecessor for the backtrack that recovers the positions.
|
||||
fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec<usize>)> {
|
||||
let (m, n) = (term.len(), hay_lc.len());
|
||||
if m == 0 || m > n {
|
||||
return None;
|
||||
}
|
||||
let mut score = vec![NEG; m * n];
|
||||
let mut parent = vec![usize::MAX; m * n];
|
||||
|
||||
for j in 0..n {
|
||||
if hay_lc[j] == term[0] {
|
||||
score[j] = SCORE_MATCH + bonus[j];
|
||||
}
|
||||
}
|
||||
for i in 1..m {
|
||||
// Best gapped predecessor for the current j: max over k ≤ j-2 of
|
||||
// `score[i-1][k]` plus the affine penalty for the k→j gap.
|
||||
let mut gap_best = NEG;
|
||||
let mut gap_arg = usize::MAX;
|
||||
for j in 0..n {
|
||||
if j >= 2 {
|
||||
let fresh = score[(i - 1) * n + (j - 2)];
|
||||
let fresh = if fresh > NEG {
|
||||
fresh + PENALTY_GAP_START
|
||||
} else {
|
||||
NEG
|
||||
};
|
||||
let extended = if gap_best > NEG {
|
||||
gap_best + PENALTY_GAP_EXTEND
|
||||
} else {
|
||||
NEG
|
||||
};
|
||||
if fresh >= extended {
|
||||
gap_best = fresh;
|
||||
gap_arg = j - 2;
|
||||
} else {
|
||||
gap_best = extended;
|
||||
}
|
||||
}
|
||||
if hay_lc[j] != term[i] {
|
||||
continue;
|
||||
}
|
||||
let cons = if j >= 1 && score[(i - 1) * n + (j - 1)] > NEG {
|
||||
score[(i - 1) * n + (j - 1)] + BONUS_CONSECUTIVE
|
||||
} else {
|
||||
NEG
|
||||
};
|
||||
let (prev, arg) = if cons >= gap_best {
|
||||
(cons, j.wrapping_sub(1))
|
||||
} else {
|
||||
(gap_best, gap_arg)
|
||||
};
|
||||
if prev > NEG {
|
||||
score[i * n + j] = prev + SCORE_MATCH + bonus[j];
|
||||
parent[i * n + j] = arg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best end position for the last term char; ties go to the earliest.
|
||||
let (mut best_j, mut best) = (usize::MAX, NEG);
|
||||
for j in 0..n {
|
||||
if score[(m - 1) * n + j] > best {
|
||||
best = score[(m - 1) * n + j];
|
||||
best_j = j;
|
||||
}
|
||||
}
|
||||
if best <= NEG {
|
||||
return None;
|
||||
}
|
||||
let mut positions = vec![0usize; m];
|
||||
let mut j = best_j;
|
||||
for i in (0..m).rev() {
|
||||
positions[i] = j;
|
||||
j = parent[i * n + j];
|
||||
}
|
||||
Some((best, positions))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn score(line: &str, query: &str) -> i32 {
|
||||
match_line(line, query).expect("expected a match").score
|
||||
}
|
||||
|
||||
fn positions(line: &str, query: &str) -> Vec<usize> {
|
||||
match_line(line, query).expect("expected a match").positions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_subsequence_is_no_match() {
|
||||
assert!(match_line("git status", "xyz").is_none());
|
||||
assert!(match_line("ls", "lss").is_none()); // longer than the line
|
||||
assert!(match_line("git status", "tg").is_none()); // out of order
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_query_is_no_match() {
|
||||
assert!(match_line("git status", "").is_none());
|
||||
assert!(match_line("git status", " ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_is_case_insensitive() {
|
||||
assert_eq!(score("Git Status", "git"), score("git status", "GIT"));
|
||||
assert!(match_line("MAKE ALL", "make").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_run_beats_scattered_letters() {
|
||||
// Both contain g,i,t as a subsequence; only one has them adjacent.
|
||||
assert!(score("git log", "git") > score("going to lunch", "git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_boundary_beats_mid_word() {
|
||||
// `st` at the start of "status" (after a space) vs inside "faster".
|
||||
assert!(score("git status", "st") > score("faster", "st"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_pick_the_best_alignment() {
|
||||
// `gs` should land on the `g` of git and the boundary `s` of status,
|
||||
// not some later `s`.
|
||||
assert_eq!(positions("git status", "gs"), vec![0, 4]);
|
||||
// A consecutive alignment is recovered exactly.
|
||||
assert_eq!(positions("cargo build", "build"), vec![6, 7, 8, 9, 10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_term_queries_must_all_match_and_merge_positions() {
|
||||
// Terms match independently (order-free) and positions merge sorted.
|
||||
let m = match_line("git push --force origin", "push git").unwrap();
|
||||
assert_eq!(m.positions, vec![0, 1, 2, 4, 5, 6, 7]);
|
||||
// One term failing fails the whole query.
|
||||
assert!(match_line("git push", "git nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_are_penalized_by_length() {
|
||||
// Same letters, tighter gap scores higher.
|
||||
assert!(score("ab", "ab") > score("a-b", "ab"));
|
||||
assert!(score("a-b", "ab") > score("a---------b", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_haystacks_match_by_char() {
|
||||
// Positions are char indices, not bytes: the CJK prefix occupies
|
||||
// char cells 0..2, so `ls` lands at 3..=4.
|
||||
assert_eq!(positions("构建 ls", "ls"), vec![3, 4]);
|
||||
}
|
||||
}
|
||||
+378
-135
@@ -5,15 +5,20 @@
|
||||
//! ↑/↓ recall and Ctrl+R search without pulling in a database. Each terminal loads
|
||||
//! a snapshot on creation and appends as commands are submitted.
|
||||
//!
|
||||
//! Each new line is `<cwd>\t<command>` — the working directory the command ran in,
|
||||
//! a tab, then the command. That cwd feeds the frecency ranking: commands you've
|
||||
//! run *in this directory* float to the top of completion and ghost text. Legacy
|
||||
//! plain-command lines (no tab) still parse fine, just without a cwd association.
|
||||
//! Each new line is `<ts>\t<exit>\t<cwd>\t<command>` — when the command ran
|
||||
//! (unix seconds), the exit code of that run (empty while unknown: the record is
|
||||
//! written once the command finishes, but a pane can die before that), the
|
||||
//! working directory it ran in (empty when unusable), then the command itself
|
||||
//! (which may contain further tabs — it's the last field). The cwd feeds the
|
||||
//! frecency ranking; ts and exit feed the Ctrl+R menu's "ran 3h ago" / failure
|
||||
//! badges. Older `<cwd>\t<command>` lines and legacy bare commands still parse
|
||||
//! fine, just without the missing fields.
|
||||
//!
|
||||
//! On load we also seed from the user's real shell histories (`~/.zsh_history`,
|
||||
//! `~/.bash_history`, and `$HISTFILE`), so recall and completion work from the
|
||||
//! very first launch — before tty7 has accumulated a history of its own. Those
|
||||
//! files are read-only inputs; tty7 only ever writes its own file.
|
||||
//! files are read-only inputs; tty7 only ever writes its own file. zsh extended
|
||||
//! and bash `HISTTIMEFORMAT` timestamps are carried over when present.
|
||||
|
||||
use crate::core::config::config_path;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -36,32 +41,61 @@ const FREQ_WEIGHT: f64 = 0.6;
|
||||
/// very frequent global one (`git status`, `ls`, …).
|
||||
const CWD_BONUS: f64 = 1.2;
|
||||
|
||||
/// One history line as parsed from disk, before de-duplication: the command,
|
||||
/// plus whatever metadata its source format carried.
|
||||
struct Raw {
|
||||
cmd: String,
|
||||
cwd: Option<String>,
|
||||
ts: Option<u64>,
|
||||
exit: Option<i32>,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
fn bare(cmd: String) -> Self {
|
||||
Self {
|
||||
cmd,
|
||||
cwd: None,
|
||||
ts: None,
|
||||
exit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Last-known run metadata for one history line: when it last ran (unix
|
||||
/// seconds) and that run's exit code (`None` when the run never completed
|
||||
/// under tty7's watch — or predates exit tracking).
|
||||
#[derive(Clone, Copy, Default, PartialEq, Debug)]
|
||||
pub struct EntryMeta {
|
||||
pub ts: Option<u64>,
|
||||
pub exit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Loaded history: the unique command lines (oldest-first, the source for ↑/↓
|
||||
/// recall and Ctrl+R search), plus the two extra dimensions the frecency ranking
|
||||
/// needs — per-line run `counts` (frequency) and, for each line, the set of
|
||||
/// directories it was run in (`cwds`), so we can favour commands used *here*.
|
||||
/// recall and Ctrl+R search), plus the extra dimensions ranking and the Ctrl+R
|
||||
/// menu need — per-line run `counts` (frequency), the set of directories each
|
||||
/// line was run in (`cwds`, so we can favour commands used *here*), and the
|
||||
/// last-run `meta` (timestamp + exit code) per line.
|
||||
pub struct History {
|
||||
pub entries: Vec<String>,
|
||||
pub counts: HashMap<String, u32>,
|
||||
pub cwds: HashMap<String, HashSet<String>>,
|
||||
pub meta: HashMap<String, EntryMeta>,
|
||||
}
|
||||
|
||||
/// Load history (oldest first), seeding from the user's shell histories and then
|
||||
/// tty7's own file. Blanks are dropped and duplicates collapsed (keeping the most
|
||||
/// recent occurrence), while occurrence counts and per-directory associations are
|
||||
/// tallied for frecency ranking. Returns empty when nothing is readable.
|
||||
/// recent occurrence), while occurrence counts, per-directory associations and
|
||||
/// last-run metadata are tallied for ranking and the Ctrl+R menu. Returns empty
|
||||
/// when nothing is readable.
|
||||
pub fn load() -> History {
|
||||
// Shell history first (older, so it sits at a lower completion priority than
|
||||
// commands actually run in tty7), then tty7's own file last (most recent).
|
||||
// Shell-history lines carry no cwd; tty7's own `<cwd>\t<cmd>` lines do.
|
||||
let mut raw: Vec<(String, Option<String>)> = load_shell_history()
|
||||
.into_iter()
|
||||
.map(|cmd| (cmd, None))
|
||||
.collect();
|
||||
if let Some(path) = config_path("history") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
raw.extend(content.lines().map(parse_own_line));
|
||||
}
|
||||
// Shell-history lines carry no cwd; tty7's own lines do.
|
||||
let mut raw: Vec<Raw> = load_shell_history();
|
||||
if let Some(path) = config_path("history")
|
||||
&& let Ok(content) = std::fs::read_to_string(&path)
|
||||
{
|
||||
raw.extend(content.lines().map(parse_own_line));
|
||||
}
|
||||
normalize(raw)
|
||||
}
|
||||
@@ -81,32 +115,52 @@ fn looks_absolute(p: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one line of tty7's own history file into `(command, cwd)`. New lines are
|
||||
/// `<cwd>\t<command>` with an absolute cwd; legacy plain lines (and anything whose
|
||||
/// pre-tab part isn't an absolute path) carry just the command, no cwd.
|
||||
fn parse_own_line(line: &str) -> (String, Option<String>) {
|
||||
/// Parse one line of tty7's own history file. Current lines are
|
||||
/// `<ts>\t<exit>\t<cwd>\t<command>` (ts all-digits; exit an integer or empty;
|
||||
/// cwd absolute or empty; the command — the last field — may itself contain
|
||||
/// tabs). Older `<cwd>\t<command>` lines and legacy bare commands still parse,
|
||||
/// carrying only the fields they have.
|
||||
fn parse_own_line(line: &str) -> Raw {
|
||||
let mut f = line.splitn(4, '\t');
|
||||
if let (Some(ts), Some(exit), Some(cwd), Some(cmd)) = (f.next(), f.next(), f.next(), f.next())
|
||||
&& !ts.is_empty()
|
||||
&& ts.bytes().all(|b| b.is_ascii_digit())
|
||||
&& (exit.is_empty() || exit.parse::<i32>().is_ok())
|
||||
&& (cwd.is_empty() || looks_absolute(cwd))
|
||||
{
|
||||
return Raw {
|
||||
cmd: cmd.to_string(),
|
||||
cwd: (!cwd.is_empty()).then(|| cwd.to_string()),
|
||||
ts: ts.parse().ok(),
|
||||
exit: exit.parse().ok(),
|
||||
};
|
||||
}
|
||||
if let Some((cwd, cmd)) = line.split_once('\t')
|
||||
&& looks_absolute(cwd)
|
||||
{
|
||||
return (cmd.to_string(), Some(cwd.to_string()));
|
||||
return Raw {
|
||||
cmd: cmd.to_string(),
|
||||
cwd: Some(cwd.to_string()),
|
||||
ts: None,
|
||||
exit: None,
|
||||
};
|
||||
}
|
||||
(line.to_string(), None)
|
||||
Raw::bare(line.to_string())
|
||||
}
|
||||
|
||||
/// Order unique history entries by *frecency* (frequency × recency, plus a
|
||||
/// current-directory bonus), most relevant first — the ranking that drives
|
||||
/// ghost-text autosuggestion and the completion menu's history recalls, so neither
|
||||
/// surfaces stale junk just because it was typed once, recently. `entries` is
|
||||
/// oldest-first as from [`load`]; `counts` and `cwds` are its companions; `cwd` is
|
||||
/// the directory to favour (none → no directory bonus).
|
||||
pub fn rank_by_frecency(
|
||||
/// The frecency score of every entry (frequency × recency, plus a
|
||||
/// current-directory bonus), index-aligned with `entries`. Shared by
|
||||
/// [`rank_by_frecency`] and the Ctrl+R search's relevance blend. `entries` is
|
||||
/// oldest-first as from [`load`]; `counts` and `cwds` are its companions; `cwd`
|
||||
/// is the directory to favour (none → no directory bonus).
|
||||
pub fn frecency_scores(
|
||||
entries: &[String],
|
||||
counts: &HashMap<String, u32>,
|
||||
cwds: &HashMap<String, HashSet<String>>,
|
||||
cwd: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
) -> Vec<f64> {
|
||||
let n = entries.len();
|
||||
let mut scored: Vec<(f64, usize)> = entries
|
||||
entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
@@ -125,25 +179,62 @@ pub fn rank_by_frecency(
|
||||
{
|
||||
score += CWD_BONUS;
|
||||
}
|
||||
(score, i)
|
||||
score
|
||||
})
|
||||
.collect();
|
||||
// Higher score first; ties broken toward the more recent entry.
|
||||
scored.sort_by(|a, b| {
|
||||
b.0.partial_cmp(&a.0)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(b.1.cmp(&a.1))
|
||||
});
|
||||
scored
|
||||
.into_iter()
|
||||
.map(|(_, i)| entries[i].clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Append one command to the history file (best effort), tagged with the `cwd` it
|
||||
/// ran in when that's a usable absolute path. Commands containing a newline are
|
||||
/// skipped, since the format is one-per-line.
|
||||
pub fn append(cmd: &str, cwd: Option<&Path>) {
|
||||
/// Order unique history entries by *frecency*, most relevant first — the
|
||||
/// ranking that drives ghost-text autosuggestion and the completion menu's
|
||||
/// history recalls, so neither surfaces stale junk just because it was typed
|
||||
/// once, recently. See [`frecency_scores`] for the inputs.
|
||||
pub fn rank_by_frecency(
|
||||
entries: &[String],
|
||||
counts: &HashMap<String, u32>,
|
||||
cwds: &HashMap<String, HashSet<String>>,
|
||||
cwd: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let scores = frecency_scores(entries, counts, cwds, cwd);
|
||||
let mut idx: Vec<usize> = (0..entries.len()).collect();
|
||||
// Higher score first; ties broken toward the more recent entry.
|
||||
idx.sort_by(|&a, &b| {
|
||||
scores[b]
|
||||
.partial_cmp(&scores[a])
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then(b.cmp(&a))
|
||||
});
|
||||
idx.into_iter().map(|i| entries[i].clone()).collect()
|
||||
}
|
||||
|
||||
/// Compact "how long ago" label for the Ctrl+R menu: `now` and `ts` are unix
|
||||
/// seconds. Coarse on purpose — the menu row has room for `3h`, not a date.
|
||||
pub fn format_ago(now: u64, ts: u64) -> String {
|
||||
let s = now.saturating_sub(ts);
|
||||
let (n, unit) = if s < 60 {
|
||||
return "now".to_string();
|
||||
} else if s < 3600 {
|
||||
(s / 60, "m")
|
||||
} else if s < 86_400 {
|
||||
(s / 3600, "h")
|
||||
} else if s < 7 * 86_400 {
|
||||
(s / 86_400, "d")
|
||||
} else if s < 30 * 86_400 {
|
||||
(s / (7 * 86_400), "w")
|
||||
} else if s < 365 * 86_400 {
|
||||
(s / (30 * 86_400), "mo")
|
||||
} else {
|
||||
(s / (365 * 86_400), "y")
|
||||
};
|
||||
format!("{n}{unit}")
|
||||
}
|
||||
|
||||
/// Append one command to the history file (best effort): `ts` is when it ran
|
||||
/// (unix seconds) and `exit` its exit code when the run completed under tty7's
|
||||
/// watch. The cwd is recorded when it's a usable absolute path — one that can't
|
||||
/// confuse the one-line format: no tab (the field separator) and no newline/CR
|
||||
/// (which would split the record across lines). Commands containing a newline
|
||||
/// are skipped, since the format is one-per-line.
|
||||
pub fn append(cmd: &str, cwd: Option<&Path>, ts: u64, exit: Option<i32>) {
|
||||
if cmd.contains('\n') {
|
||||
return;
|
||||
}
|
||||
@@ -153,16 +244,12 @@ pub fn append(cmd: &str, cwd: Option<&Path>) {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// `<cwd>\t<cmd>` when we have an absolute cwd — one that can't confuse the
|
||||
// one-line format: no tab (the field separator) and no newline/CR (a cwd
|
||||
// containing one would split the record across lines, and the stray tail
|
||||
// would load back as a bogus command). Otherwise the legacy bare form.
|
||||
let line = match cwd.and_then(Path::to_str) {
|
||||
Some(c) if looks_absolute(c) && !c.contains(['\t', '\n', '\r']) => {
|
||||
format!("{c}\t{cmd}")
|
||||
}
|
||||
_ => cmd.to_string(),
|
||||
let cwd = match cwd.and_then(Path::to_str) {
|
||||
Some(c) if looks_absolute(c) && !c.contains(['\t', '\n', '\r']) => c,
|
||||
_ => "",
|
||||
};
|
||||
let exit = exit.map(|e| e.to_string()).unwrap_or_default();
|
||||
let line = format!("{ts}\t{exit}\t{cwd}\t{cmd}");
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
@@ -180,23 +267,35 @@ pub fn append(cmd: &str, cwd: Option<&Path>) {
|
||||
|
||||
/// Drop blanks and de-duplicate (keeping the most recent occurrence, so recall
|
||||
/// and completion stay clean when shell history and tty7's own file overlap),
|
||||
/// tallying how many times each line appears and which directories it ran in, then
|
||||
/// cap to the most recent `MAX_ENTRIES`. Input is `(command, cwd)` pairs; output
|
||||
/// entries are oldest-first.
|
||||
fn normalize(raw: Vec<(String, Option<String>)>) -> History {
|
||||
/// tallying how many times each line appears, which directories it ran in, and
|
||||
/// its most recent run's metadata, then cap to the most recent `MAX_ENTRIES`.
|
||||
/// Output entries are oldest-first.
|
||||
fn normalize(raw: Vec<Raw>) -> History {
|
||||
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();
|
||||
let mut seen = HashSet::new();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (line, cwd) in raw.into_iter().rev() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
for r in raw.into_iter().rev() {
|
||||
let line = r.cmd.trim_end_matches('\r');
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
*counts.entry(line.to_string()).or_insert(0) += 1;
|
||||
if let Some(cwd) = cwd {
|
||||
if let Some(cwd) = r.cwd {
|
||||
cwds.entry(line.to_string()).or_default().insert(cwd);
|
||||
}
|
||||
// Newest-first scan: the first occurrence carrying any run metadata is
|
||||
// the last known run — its ts and exit stay a matched pair.
|
||||
if (r.ts.is_some() || r.exit.is_some()) && !meta.contains_key(line) {
|
||||
meta.insert(
|
||||
line.to_string(),
|
||||
EntryMeta {
|
||||
ts: r.ts,
|
||||
exit: r.exit,
|
||||
},
|
||||
);
|
||||
}
|
||||
if seen.insert(line.to_string()) {
|
||||
out.push(line.to_string());
|
||||
}
|
||||
@@ -208,12 +307,14 @@ fn normalize(raw: Vec<(String, Option<String>)>) -> History {
|
||||
for r in out.drain(0..cut) {
|
||||
counts.remove(&r);
|
||||
cwds.remove(&r);
|
||||
meta.remove(&r);
|
||||
}
|
||||
}
|
||||
History {
|
||||
entries: out,
|
||||
counts,
|
||||
cwds,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +323,7 @@ fn normalize(raw: Vec<(String, Option<String>)>) -> History {
|
||||
/// `$HISTFILE` if set, and orders the files by modification time so the
|
||||
/// most-recently-used shell's entries end up with the highest completion
|
||||
/// priority.
|
||||
fn load_shell_history() -> Vec<String> {
|
||||
fn load_shell_history() -> Vec<Raw> {
|
||||
let mut files: Vec<PathBuf> = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut add = |p: PathBuf| {
|
||||
@@ -256,9 +357,10 @@ fn load_shell_history() -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse one shell-history file into command lines, appending to `out`. Strips
|
||||
/// zsh's extended-format prefix (`: <start>:<elapsed>;cmd`) and skips bash
|
||||
/// `HISTTIMEFORMAT` timestamp comments.
|
||||
/// Parse one shell-history file into command lines, appending to `out`,
|
||||
/// carrying over the timestamps the file records: zsh's extended-format prefix
|
||||
/// (`: <start>:<elapsed>;cmd`) and bash's `HISTTIMEFORMAT` comment (`#<ts>` on
|
||||
/// the line *before* the command).
|
||||
///
|
||||
/// Each physical line becomes its own entry — we deliberately do *not* stitch
|
||||
/// backslash-continued multi-line commands back together. bash stores multi-line
|
||||
@@ -266,45 +368,62 @@ fn load_shell_history() -> Vec<String> {
|
||||
/// that wreck the single-line completion menu's layout and (b) on bash, wrongly
|
||||
/// swallow the following command. A few stray fragments from a zsh here-doc are a
|
||||
/// fair price for robustness.
|
||||
fn parse_shell_history(content: &str, out: &mut Vec<String>) {
|
||||
fn parse_shell_history(content: &str, out: &mut Vec<Raw>) {
|
||||
// A bash timestamp comment stamps the *next* command line.
|
||||
let mut pending_ts: Option<u64> = None;
|
||||
for raw in content.split('\n') {
|
||||
let line = raw.strip_suffix('\r').unwrap_or(raw);
|
||||
if let Some(cmd) = start_of_command(line) {
|
||||
if let Some(ts) = bash_timestamp(line) {
|
||||
pending_ts = Some(ts);
|
||||
continue;
|
||||
}
|
||||
if let Some((cmd, zsh_ts)) = start_of_command(line) {
|
||||
let cmd = cmd.trim();
|
||||
if !cmd.is_empty() {
|
||||
out.push(cmd.to_string());
|
||||
out.push(Raw {
|
||||
cmd: cmd.to_string(),
|
||||
cwd: None,
|
||||
ts: zsh_ts.or(pending_ts),
|
||||
exit: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
pending_ts = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The command text at the start of a history line, or `None` for lines that
|
||||
/// carry no command (blank lines, bash timestamp comments). Strips the zsh
|
||||
/// extended-history `": <start>:<elapsed>;"` prefix when present.
|
||||
fn start_of_command(line: &str) -> Option<&str> {
|
||||
/// The bash `HISTTIMEFORMAT` timestamp comment (`#1700000000`), if that's what
|
||||
/// this line is. It carries no command itself — it stamps the following line.
|
||||
fn bash_timestamp(line: &str) -> Option<u64> {
|
||||
let rest = line.strip_prefix('#')?;
|
||||
if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
rest.parse().ok()
|
||||
}
|
||||
|
||||
/// The command text at the start of a history line plus the zsh
|
||||
/// extended-history timestamp when the line carries one, or `None` for blank
|
||||
/// lines. Strips the `": <start>:<elapsed>;"` prefix when present.
|
||||
fn start_of_command(line: &str) -> Option<(&str, Option<u64>)> {
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// zsh extended history: ": 1700000000:0;the command". The timestamp field
|
||||
// must hold at least one digit — an empty/colon-only prefix would otherwise
|
||||
// match a *real* command like `: ;echo hi` and wrongly strip its head.
|
||||
if let Some(rest) = line.strip_prefix(": ") {
|
||||
if let Some(semi) = rest.find(';') {
|
||||
let ts = &rest[..semi];
|
||||
if ts.bytes().any(|b| b.is_ascii_digit())
|
||||
&& ts.bytes().all(|b| b.is_ascii_digit() || b == b':')
|
||||
{
|
||||
return Some(&rest[semi + 1..]);
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix(": ")
|
||||
&& let Some(semi) = rest.find(';')
|
||||
{
|
||||
let ts = &rest[..semi];
|
||||
if ts.bytes().any(|b| b.is_ascii_digit())
|
||||
&& ts.bytes().all(|b| b.is_ascii_digit() || b == b':')
|
||||
{
|
||||
let start = ts.split(':').next().and_then(|t| t.parse().ok());
|
||||
return Some((&rest[semi + 1..], start));
|
||||
}
|
||||
}
|
||||
// bash HISTTIMEFORMAT timestamp comment: "#1700000000".
|
||||
if let Some(rest) = line.strip_prefix('#') {
|
||||
if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(line)
|
||||
Some((line, None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -314,7 +433,13 @@ mod tests {
|
||||
fn parse(content: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
parse_shell_history(content, &mut out);
|
||||
out
|
||||
out.into_iter().map(|r| r.cmd).collect()
|
||||
}
|
||||
|
||||
fn parse_ts(content: &str) -> Vec<(String, Option<u64>)> {
|
||||
let mut out = Vec::new();
|
||||
parse_shell_history(content, &mut out);
|
||||
out.into_iter().map(|r| (r.cmd, r.ts)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -326,15 +451,29 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zsh_extended_prefix_is_stripped() {
|
||||
fn zsh_extended_prefix_is_stripped_and_timestamp_kept() {
|
||||
let content = ": 1700000000:0;git status\n: 1700000005:2;cargo build\n";
|
||||
assert_eq!(parse(content), ["git status", "cargo build"]);
|
||||
assert_eq!(
|
||||
parse_ts(content),
|
||||
[
|
||||
("git status".to_string(), Some(1_700_000_000)),
|
||||
("cargo build".to_string(), Some(1_700_000_005)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_timestamp_comments_are_skipped() {
|
||||
let content = "#1700000000\nls -la\n#1700000005\ncd ..\n";
|
||||
assert_eq!(parse(content), ["ls -la", "cd .."]);
|
||||
fn bash_timestamp_comments_stamp_the_next_command() {
|
||||
let content = "#1700000000\nls -la\n#1700000005\ncd ..\nuntimed\n";
|
||||
assert_eq!(
|
||||
parse_ts(content),
|
||||
[
|
||||
("ls -la".to_string(), Some(1_700_000_000)),
|
||||
("cd ..".to_string(), Some(1_700_000_005)),
|
||||
// No comment directly above → no timestamp bleeds over.
|
||||
("untimed".to_string(), None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -348,26 +487,50 @@ mod tests {
|
||||
assert!(got.iter().all(|e| !e.contains('\n')));
|
||||
}
|
||||
|
||||
fn pair(cmd: &str, cwd: Option<&str>) -> (String, Option<String>) {
|
||||
(cmd.to_string(), cwd.map(str::to_string))
|
||||
fn pair(cmd: &str, cwd: Option<&str>) -> Raw {
|
||||
Raw {
|
||||
cmd: cmd.to_string(),
|
||||
cwd: cwd.map(str::to_string),
|
||||
ts: None,
|
||||
exit: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_own_line_reads_cwd_prefix_and_legacy_lines() {
|
||||
fn parse_own_line_reads_all_generations() {
|
||||
// Current format: ts, exit, cwd, command.
|
||||
let r = parse_own_line("1700000000\t0\t/home/me\tgit status");
|
||||
assert_eq!(r.cmd, "git status");
|
||||
assert_eq!(r.cwd.as_deref(), Some("/home/me"));
|
||||
assert_eq!(r.ts, Some(1_700_000_000));
|
||||
assert_eq!(r.exit, Some(0));
|
||||
// Exit unknown (pane died mid-command) and cwd unknown stay empty fields.
|
||||
let r = parse_own_line("1700000000\t\t\tmake");
|
||||
assert_eq!(
|
||||
parse_own_line("/home/me\tgit status"),
|
||||
("git status".to_string(), Some("/home/me".to_string()))
|
||||
(r.cmd.as_str(), r.cwd, r.ts, r.exit),
|
||||
("make", None, Some(1_700_000_000), None)
|
||||
);
|
||||
// The command is the last field, so its own tabs survive.
|
||||
let r = parse_own_line("1700000000\t1\t/a\techo\tfoo");
|
||||
assert_eq!(r.cmd, "echo\tfoo");
|
||||
assert_eq!(r.exit, Some(1));
|
||||
// Previous generation: `<cwd>\t<command>`.
|
||||
let r = parse_own_line("/home/me\tgit status");
|
||||
assert_eq!(
|
||||
(r.cmd.as_str(), r.cwd.as_deref(), r.ts),
|
||||
("git status", Some("/home/me"), None)
|
||||
);
|
||||
// Windows absolute cwd is recognized too (cross-platform, host-independent).
|
||||
// `\\` are literal backslashes; `\t` is the real tab separator.
|
||||
let r = parse_own_line("C:\\Users\\me\tgit status");
|
||||
assert_eq!(r.cwd.as_deref(), Some("C:\\Users\\me"));
|
||||
// Legacy bare command — no tab, no metadata.
|
||||
let r = parse_own_line("ls -la");
|
||||
assert_eq!(
|
||||
parse_own_line("C:\\Users\\me\tgit status"),
|
||||
("git status".to_string(), Some("C:\\Users\\me".to_string()))
|
||||
(r.cmd.as_str(), r.cwd, r.ts, r.exit),
|
||||
("ls -la", None, None, None)
|
||||
);
|
||||
// Legacy bare command — no tab, no cwd.
|
||||
assert_eq!(parse_own_line("ls -la"), ("ls -la".to_string(), None));
|
||||
// A tab whose pre-part isn't an absolute path is not treated as a cwd.
|
||||
assert_eq!(parse_own_line("echo\tfoo"), ("echo\tfoo".to_string(), None));
|
||||
assert_eq!(parse_own_line("echo\tfoo").cmd, "echo\tfoo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -398,6 +561,34 @@ mod tests {
|
||||
assert_eq!(dirs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_keeps_the_most_recent_runs_metadata() {
|
||||
let with_meta = |cmd: &str, ts: u64, exit: Option<i32>| Raw {
|
||||
cmd: cmd.to_string(),
|
||||
cwd: None,
|
||||
ts: Some(ts),
|
||||
exit,
|
||||
};
|
||||
let raw = vec![
|
||||
with_meta("make", 100, Some(2)),
|
||||
pair("ls", None),
|
||||
with_meta("make", 200, Some(0)),
|
||||
// The newest occurrence has no metadata (a shell-history duplicate):
|
||||
// the newest occurrence *with* metadata still wins.
|
||||
pair("make", None),
|
||||
];
|
||||
let h = normalize(raw);
|
||||
assert_eq!(
|
||||
h.meta.get("make"),
|
||||
Some(&EntryMeta {
|
||||
ts: Some(200),
|
||||
exit: Some(0)
|
||||
})
|
||||
);
|
||||
// No metadata anywhere → no entry.
|
||||
assert_eq!(h.meta.get("ls"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frecency_ranks_frequent_over_merely_recent() {
|
||||
// `git status` is old but run many times; `oops typo` is the newest line
|
||||
@@ -437,6 +628,29 @@ mod tests {
|
||||
assert_eq!(neutral[1], "npm test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frecency_scores_align_with_the_ranking() {
|
||||
let entries = vec!["a".to_string(), "b".to_string()];
|
||||
let scores = frecency_scores(&entries, &HashMap::new(), &HashMap::new(), None);
|
||||
assert_eq!(scores.len(), 2);
|
||||
// Same count, so the newer entry scores strictly higher (recency).
|
||||
assert!(scores[1] > scores[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_ago_picks_readable_units() {
|
||||
let now = 1_700_000_000;
|
||||
assert_eq!(format_ago(now, now - 5), "now");
|
||||
assert_eq!(format_ago(now, now - 300), "5m");
|
||||
assert_eq!(format_ago(now, now - 2 * 3600), "2h");
|
||||
assert_eq!(format_ago(now, now - 3 * 86_400), "3d");
|
||||
assert_eq!(format_ago(now, now - 20 * 86_400), "2w");
|
||||
assert_eq!(format_ago(now, now - 90 * 86_400), "3mo");
|
||||
assert_eq!(format_ago(now, now - 800 * 86_400), "2y");
|
||||
// A clock that went backwards degrades to "now", never underflows.
|
||||
assert_eq!(format_ago(now, now + 100), "now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looks_absolute_recognizes_unix_and_windows_roots() {
|
||||
assert!(looks_absolute("/home/me"));
|
||||
@@ -451,37 +665,45 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_of_command_strips_prefixes_and_skips_timestamps() {
|
||||
// zsh extended-history prefix is stripped.
|
||||
fn start_of_command_strips_prefixes_and_keeps_timestamps() {
|
||||
// zsh extended-history prefix is stripped, its start timestamp kept.
|
||||
assert_eq!(
|
||||
start_of_command(": 1700000000:0;git status"),
|
||||
Some("git status")
|
||||
Some(("git status", Some(1_700_000_000)))
|
||||
);
|
||||
// A colon-prefixed line whose middle isn't numeric is taken verbatim.
|
||||
assert_eq!(start_of_command(": not-a-ts;cmd"), Some(": not-a-ts;cmd"));
|
||||
assert_eq!(
|
||||
start_of_command(": not-a-ts;cmd"),
|
||||
Some((": not-a-ts;cmd", None))
|
||||
);
|
||||
// Regression: an empty or colon-only "timestamp" is not the zsh format —
|
||||
// the line is a real command (`: ;echo hi` runs the colon builtin, then
|
||||
// echo) and must NOT have its head stripped.
|
||||
assert_eq!(start_of_command(": ;echo hi"), Some(": ;echo hi"));
|
||||
assert_eq!(start_of_command(": :::;cmd"), Some(": :::;cmd"));
|
||||
// A bash timestamp comment carries no command.
|
||||
assert_eq!(start_of_command("#1700000000"), None);
|
||||
// A real comment-looking line with non-digits is a command.
|
||||
assert_eq!(start_of_command("#notdigits"), Some("#notdigits"));
|
||||
assert_eq!(start_of_command(": ;echo hi"), Some((": ;echo hi", None)));
|
||||
assert_eq!(start_of_command(": :::;cmd"), Some((": :::;cmd", None)));
|
||||
// Blank → None.
|
||||
assert_eq!(start_of_command(""), None);
|
||||
// Plain command passes through.
|
||||
assert_eq!(start_of_command("ls -la"), Some("ls -la"));
|
||||
assert_eq!(start_of_command("ls -la"), Some(("ls -la", None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_timestamp_recognizes_only_all_digit_comments() {
|
||||
assert_eq!(bash_timestamp("#1700000000"), Some(1_700_000_000));
|
||||
// A real comment-looking line with non-digits is a command, not a stamp.
|
||||
assert_eq!(bash_timestamp("#notdigits"), None);
|
||||
assert_eq!(bash_timestamp("#"), None);
|
||||
assert_eq!(bash_timestamp("ls"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_dedups_counts_and_caps_entries() {
|
||||
// Duplicates collapse to the most recent position, with a run count tallied.
|
||||
let raw = vec![
|
||||
("ls".to_string(), Some("/a".to_string())),
|
||||
("git".to_string(), None),
|
||||
("".to_string(), None), // blank dropped
|
||||
("ls".to_string(), Some("/b".to_string())),
|
||||
pair("ls", Some("/a")),
|
||||
pair("git", None),
|
||||
pair("", None), // blank dropped
|
||||
pair("ls", Some("/b")),
|
||||
];
|
||||
let h = normalize(raw);
|
||||
// "ls" moved to the end (most recent) and "git" stayed; blank gone.
|
||||
@@ -492,8 +714,8 @@ mod tests {
|
||||
assert!(dirs.contains("/a") && dirs.contains("/b"));
|
||||
|
||||
// The cap keeps only the most recent MAX_ENTRIES unique lines.
|
||||
let big: Vec<(String, Option<String>)> = (0..MAX_ENTRIES + 50)
|
||||
.map(|i| (format!("cmd{i}"), None))
|
||||
let big: Vec<Raw> = (0..MAX_ENTRIES + 50)
|
||||
.map(|i| pair(&format!("cmd{i}"), None))
|
||||
.collect();
|
||||
let capped = normalize(big);
|
||||
assert_eq!(capped.entries.len(), MAX_ENTRIES);
|
||||
@@ -505,23 +727,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_then_load_recovers_the_command() {
|
||||
fn append_then_load_recovers_the_command_and_metadata() {
|
||||
// Pin the config dir so history writes to a temp file, not the real one.
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
crate::core::config::set_config_dir(dir);
|
||||
|
||||
// A command with an embedded newline is rejected (one-per-line format).
|
||||
append("bad\ncmd", None);
|
||||
append("bad\ncmd", None, 1_700_000_000, None);
|
||||
|
||||
// A unique command tagged with an absolute cwd round-trips through load().
|
||||
// A unique command tagged with cwd/ts/exit round-trips through load().
|
||||
let unique = format!("tty7_cov_marker_{}", std::process::id());
|
||||
append(&unique, Some(Path::new("/tmp")));
|
||||
append(&unique, Some(Path::new("/tmp")), 1_700_000_123, Some(1));
|
||||
let loaded = load();
|
||||
assert!(
|
||||
loaded.entries.iter().any(|e| e == &unique),
|
||||
"appended command should be recalled by load()"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.meta.get(&unique),
|
||||
Some(&EntryMeta {
|
||||
ts: Some(1_700_000_123),
|
||||
exit: Some(1)
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
loaded.cwds.get(&unique).is_some_and(|d| d.contains("/tmp")),
|
||||
"cwd association should round-trip"
|
||||
);
|
||||
assert!(
|
||||
!loaded.entries.iter().any(|e| e.contains('\n')),
|
||||
"newline command was never written"
|
||||
@@ -544,7 +777,12 @@ mod tests {
|
||||
let tag = tag.clone();
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..25 {
|
||||
append(&format!("{tag}_{t}_{i}"), Some(Path::new("/tmp")));
|
||||
append(
|
||||
&format!("{tag}_{t}_{i}"),
|
||||
Some(Path::new("/tmp")),
|
||||
1_700_000_000,
|
||||
Some(0),
|
||||
);
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -568,17 +806,22 @@ mod tests {
|
||||
#[test]
|
||||
fn append_rejects_a_cwd_that_would_break_the_line_format() {
|
||||
// Regression: a cwd containing a newline used to be written verbatim into
|
||||
// the `<cwd>\t<cmd>` line, splitting the record — the pre-newline half
|
||||
// loaded back as a bogus command and the real command gained a wrong cwd.
|
||||
// Such a cwd is dropped (legacy bare form) so the record stays one line.
|
||||
// the record, splitting it — the pre-newline half loaded back as a bogus
|
||||
// command and the real command gained a wrong cwd. Such a cwd is dropped
|
||||
// (empty field) so the record stays one line.
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
crate::core::config::set_config_dir(dir);
|
||||
|
||||
let unique = format!("tty7_nlcwd_marker_{}", std::process::id());
|
||||
append(&unique, Some(Path::new("/tmp/evil\n/tmp/tail")));
|
||||
append(
|
||||
&unique,
|
||||
Some(Path::new("/tmp/evil\n/tmp/tail")),
|
||||
1_700_000_000,
|
||||
None,
|
||||
);
|
||||
let loaded = load();
|
||||
// The command itself survives, as a bare entry…
|
||||
// The command itself survives…
|
||||
assert!(loaded.entries.iter().any(|e| e == &unique));
|
||||
// …with no cwd association (the unusable path was dropped, not split)…
|
||||
assert!(loaded.cwds.get(&unique).is_none_or(|d| d.is_empty()));
|
||||
|
||||
@@ -19,6 +19,7 @@ mod cmd_editor;
|
||||
mod completion;
|
||||
pub mod element;
|
||||
pub mod fps;
|
||||
mod fuzzy;
|
||||
mod highlight;
|
||||
mod history;
|
||||
mod hold;
|
||||
|
||||
@@ -84,6 +84,11 @@ struct ShellState {
|
||||
active: bool,
|
||||
at_prompt: bool,
|
||||
last_exit: Option<i32>,
|
||||
/// Monotonic count of `Prompt` reports applied. Lets the view tell a
|
||||
/// *fresh* prompt (the shell cycled through the submitted command and came
|
||||
/// back) from the stale pre-submit state — even when 1 Hz polling misses
|
||||
/// the intermediate not-at-prompt window of a fast command.
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
/// The shared handles the reader thread writes into as daemon frames arrive;
|
||||
@@ -513,6 +518,7 @@ impl RemoteTerminal {
|
||||
active,
|
||||
at_prompt,
|
||||
last_exit,
|
||||
seq: guard.seq + 1,
|
||||
};
|
||||
}
|
||||
// The shell just reported a fresh prompt, so at
|
||||
@@ -724,6 +730,19 @@ impl RemoteTerminal {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Monotonic count of `Prompt` reports applied so far — see
|
||||
/// [`ShellState::seq`]. Comparing values from before and after a submit
|
||||
/// tells whether the shell has reported back since.
|
||||
pub fn prompt_seq(&self) -> u64 {
|
||||
self.shell_state.lock().map(|s| s.seq).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Exit code of the most recently completed foreground command, as sniffed
|
||||
/// from OSC 133;D daemon-side. `None` before any command has finished.
|
||||
pub fn last_exit_code(&self) -> Option<i32> {
|
||||
self.shell_state.lock().ok().and_then(|s| s.last_exit)
|
||||
}
|
||||
|
||||
/// Whether shell integration has engaged at all (the daemon has seen any
|
||||
/// OSC 133 from this pane). False for the whole rc-sourcing window after
|
||||
/// spawn, and forever for shells without integration. Gates the gap-input
|
||||
|
||||
+248
-118
@@ -1,96 +1,177 @@
|
||||
//! Ctrl+R reverse-history search, extracted from the terminal view so the search
|
||||
//! *logic* (query editing + scanning `history` for a match) lives apart from the
|
||||
//! GPUI plumbing (focus, repaint). The view owns an `Option<ReverseSearch>`,
|
||||
//! forwards keys and typed text to it, and acts on the returned [`Action`] —
|
||||
//! it never reaches into the query or match index directly.
|
||||
//! Ctrl+R history search, extracted from the terminal view so the search
|
||||
//! *logic* (query editing + ranking `history` into a match list) lives apart
|
||||
//! from the GPUI plumbing (focus, repaint). The view owns an
|
||||
//! `Option<ReverseSearch>`, forwards keys and typed text to it, and acts on the
|
||||
//! returned [`Action`] — it never reaches into the query or match list beyond
|
||||
//! the read-only accessors the menu renderer uses.
|
||||
//!
|
||||
//! Matching is fuzzy (see the [`fuzzy`](super::fuzzy) module), blended with the
|
||||
//! entry's frecency so a command you run constantly — or ran *in this
|
||||
//! directory* — outranks an equally-good textual match you typed once. An
|
||||
//! empty query ranks the whole history by frecency alone, so bare Ctrl+R is a
|
||||
//! browsable "recent & relevant" list rather than a blank prompt.
|
||||
|
||||
/// In-progress reverse search: the typed query and the history index of the
|
||||
/// current match (the most recent match at or older than the last step).
|
||||
use super::fuzzy;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// How much an entry's frecency score (roughly `0..7`: recency `0..1` +
|
||||
/// dampened frequency + current-directory bonus) adds to its fuzzy match
|
||||
/// score (16+ per matched char). At 2× it decides ties and near-ties between
|
||||
/// textually similar matches without ever drowning a clearly better match.
|
||||
const FRECENCY_WEIGHT: f64 = 2.0;
|
||||
|
||||
/// In-progress search: the typed query and the ranked matches, best first.
|
||||
pub(super) struct ReverseSearch {
|
||||
query: String,
|
||||
match_index: Option<usize>,
|
||||
matches: Vec<Match>,
|
||||
/// Cursor into `matches`: the entry Enter accepts, highlighted in the menu.
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
/// One ranked match: where it lives in the view's chronological `history`, and
|
||||
/// which of its chars the query matched (for menu highlighting; empty for the
|
||||
/// empty-query frecency listing).
|
||||
pub(super) struct Match {
|
||||
pub index: usize,
|
||||
pub positions: Vec<usize>,
|
||||
}
|
||||
|
||||
/// What the view should do after handing a key to an active search.
|
||||
pub(super) enum Action {
|
||||
/// Stay open; just repaint (query or match changed, or the key was ignored).
|
||||
/// Stay open; just repaint (query, matches or selection changed).
|
||||
Redraw,
|
||||
/// Close the search and leave the edited line untouched (Esc / Ctrl+G / Ctrl+C).
|
||||
Cancel,
|
||||
/// Close the search; if `Some`, load that history line into the editor (Enter).
|
||||
/// Close the search; if `Some`, load that history line into the editor
|
||||
/// (Enter — the user still presses Enter again to run it).
|
||||
Accept(Option<String>),
|
||||
/// Close the search and run that history line outright (Cmd+Enter).
|
||||
Run(String),
|
||||
}
|
||||
|
||||
impl ReverseSearch {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
/// Open a search: the empty query immediately lists the history by
|
||||
/// frecency, so the menu is useful before a single key is typed.
|
||||
pub(super) fn new(history: &[String], frecency: &[f64]) -> Self {
|
||||
let mut rs = Self {
|
||||
query: String::new(),
|
||||
match_index: None,
|
||||
}
|
||||
matches: Vec::new(),
|
||||
selected: 0,
|
||||
};
|
||||
rs.update(history, frecency);
|
||||
rs
|
||||
}
|
||||
|
||||
/// The `(reverse-i-search)` query text, for the prompt the view renders.
|
||||
/// The typed query, for the prompt the view renders.
|
||||
pub(super) fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
|
||||
/// History index of the current match, if any — the view highlights it.
|
||||
pub(super) fn match_index(&self) -> Option<usize> {
|
||||
self.match_index
|
||||
/// The ranked matches, best first — the menu renders a window of these.
|
||||
pub(super) fn matches(&self) -> &[Match] {
|
||||
&self.matches
|
||||
}
|
||||
|
||||
/// Recompute the current match. `advance` continues to the next *older* match
|
||||
/// (Ctrl+R again); otherwise the scan starts from the newest entry.
|
||||
pub(super) fn update(&mut self, history: &[String], advance: bool) {
|
||||
if self.query.is_empty() {
|
||||
self.match_index = None;
|
||||
return;
|
||||
}
|
||||
let q = self.query.to_lowercase();
|
||||
// Upper bound (exclusive): everything when refining the query, or the
|
||||
// current match when stepping to an older one.
|
||||
let upper = if advance {
|
||||
self.match_index.unwrap_or(history.len())
|
||||
} else {
|
||||
history.len()
|
||||
};
|
||||
let found = (0..upper)
|
||||
.rev()
|
||||
.find(|&i| history[i].to_lowercase().contains(&q));
|
||||
// Stepping to an older match (Ctrl+R again) that finds nothing means we're
|
||||
// already on the oldest hit — keep it rather than blanking the match. When
|
||||
// refining the query, an empty result genuinely means "no match".
|
||||
if !(advance && found.is_none()) {
|
||||
self.match_index = found;
|
||||
}
|
||||
/// Index of the selected match within [`matches`](Self::matches).
|
||||
pub(super) fn selected(&self) -> usize {
|
||||
self.selected
|
||||
}
|
||||
|
||||
/// Append typed text to the query and re-search. Text arrives either via the
|
||||
/// IME path (`replace_text_in_range` → the view's `input_text`) or, for a plain
|
||||
/// ASCII input source, as a direct `key_char` the view forwards from
|
||||
/// The history line the selection sits on, if any.
|
||||
pub(super) fn selected_line<'a>(&self, history: &'a [String]) -> Option<&'a str> {
|
||||
self.matches
|
||||
.get(self.selected)
|
||||
.map(|m| history[m.index].as_str())
|
||||
}
|
||||
|
||||
/// Recompute the match list. Entries are deduplicated by content (the most
|
||||
/// recent occurrence wins) and ranked by fuzzy score blended with frecency;
|
||||
/// an empty query ranks everything by frecency alone. `frecency` is
|
||||
/// index-aligned with `history`. Resets the selection to the best match.
|
||||
fn update(&mut self, history: &[String], frecency: &[f64]) {
|
||||
self.selected = 0;
|
||||
let list_all = self.query.trim().is_empty();
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut scored: Vec<(f64, Match)> = Vec::new();
|
||||
// Newest → oldest, so the stable sort below keeps recent entries first
|
||||
// among equal scores.
|
||||
for i in (0..history.len()).rev() {
|
||||
let line = history[i].as_str();
|
||||
if !seen.insert(line) {
|
||||
continue;
|
||||
}
|
||||
let f = frecency.get(i).copied().unwrap_or(0.0);
|
||||
if list_all {
|
||||
scored.push((
|
||||
f,
|
||||
Match {
|
||||
index: i,
|
||||
positions: Vec::new(),
|
||||
},
|
||||
));
|
||||
} else if let Some(m) = fuzzy::match_line(line, &self.query) {
|
||||
scored.push((
|
||||
f64::from(m.score) + FRECENCY_WEIGHT * f,
|
||||
Match {
|
||||
index: i,
|
||||
positions: m.positions,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
self.matches = scored.into_iter().map(|(_, m)| m).collect();
|
||||
}
|
||||
|
||||
/// Move the selection `delta` steps down the ranked list (positive → worse
|
||||
/// matches, the classic "older hit" direction of a repeated Ctrl+R),
|
||||
/// sticking at the ends.
|
||||
fn step(&mut self, delta: isize) {
|
||||
let last = self.matches.len().saturating_sub(1);
|
||||
self.selected = self.selected.saturating_add_signed(delta).min(last);
|
||||
}
|
||||
|
||||
/// Append typed text to the query and re-rank. Text arrives either via the
|
||||
/// IME path (`replace_text_in_range` → the view's `input_text`) or, for a
|
||||
/// plain ASCII input source, as a direct `key_char` the view forwards from
|
||||
/// `handle_reverse_search_key`.
|
||||
pub(super) fn push_query(&mut self, text: &str, history: &[String]) {
|
||||
pub(super) fn push_query(&mut self, text: &str, history: &[String], frecency: &[f64]) {
|
||||
self.query.push_str(text);
|
||||
self.update(history, false);
|
||||
self.update(history, frecency);
|
||||
}
|
||||
|
||||
/// Handle a key while the search is active. Query text itself arrives via
|
||||
/// [`push_query`](Self::push_query); this covers the control keys only.
|
||||
pub(super) fn handle_key(&mut self, ks: &gpui::Keystroke, history: &[String]) -> Action {
|
||||
pub(super) fn handle_key(
|
||||
&mut self,
|
||||
ks: &gpui::Keystroke,
|
||||
history: &[String],
|
||||
frecency: &[f64],
|
||||
) -> Action {
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
if m.control && key == "r" {
|
||||
self.update(history, true);
|
||||
if (m.control && key == "r") || key == "down" {
|
||||
// Next (worse-ranked) match — the classic repeated-Ctrl+R step.
|
||||
self.step(1);
|
||||
Action::Redraw
|
||||
} else if (m.control && key == "s") || key == "up" {
|
||||
// Back toward the best match (readline's forward-search direction).
|
||||
self.step(-1);
|
||||
Action::Redraw
|
||||
} else if (m.control && (key == "g" || key == "c")) || key == "escape" {
|
||||
Action::Cancel
|
||||
} else if key == "enter" {
|
||||
// Accept: hand back the match (the user still presses Enter to run it).
|
||||
// A bare Enter with no match just exits the search.
|
||||
Action::Accept(self.match_index.map(|i| history[i].clone()))
|
||||
let line = self.selected_line(history).map(str::to_string);
|
||||
match (m.platform, line) {
|
||||
// Cmd+Enter: run the selected line outright.
|
||||
(true, Some(line)) => Action::Run(line),
|
||||
// Enter: hand back the match (the user still presses Enter to
|
||||
// run it). A bare Enter with no match just exits the search.
|
||||
(_, line) => Action::Accept(line),
|
||||
}
|
||||
} else if key == "backspace" {
|
||||
self.query.pop();
|
||||
self.update(history, false);
|
||||
self.update(history, frecency);
|
||||
Action::Redraw
|
||||
} else {
|
||||
// Other keys are ignored while searching.
|
||||
@@ -111,42 +192,9 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_finds_the_most_recent_match() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
rs.push_query("git", &h);
|
||||
assert_eq!(rs.match_index(), Some(2)); // "git commit -m x"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_steps_to_older_matches_then_sticks_on_the_oldest() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
rs.push_query("git", &h);
|
||||
assert_eq!(rs.match_index(), Some(2));
|
||||
rs.update(&h, true);
|
||||
assert_eq!(rs.match_index(), Some(0)); // "git status"
|
||||
rs.update(&h, true); // no older match — keep the oldest hit
|
||||
assert_eq!(rs.match_index(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refining_the_query_can_drop_the_match() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
rs.push_query("cargo", &h);
|
||||
assert_eq!(rs.match_index(), Some(3)); // "cargo test"
|
||||
rs.push_query("_nope", &h);
|
||||
assert_eq!(rs.match_index(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_has_no_match() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
rs.update(&h, false);
|
||||
assert_eq!(rs.match_index(), None);
|
||||
/// Uniform frecency: ranking falls back to fuzzy score + recency order.
|
||||
fn flat(h: &[String]) -> Vec<f64> {
|
||||
vec![0.0; h.len()]
|
||||
}
|
||||
|
||||
fn key(spec: &str) -> gpui::Keystroke {
|
||||
@@ -154,62 +202,144 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_ctrl_r_steps_to_older_match_and_redraws() {
|
||||
fn empty_query_lists_everything_newest_first_under_flat_frecency() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
rs.push_query("git", &h);
|
||||
assert_eq!(rs.match_index(), Some(2));
|
||||
// Ctrl+R again advances to the older match and asks for a redraw.
|
||||
assert!(matches!(rs.handle_key(&key("ctrl-r"), &h), Action::Redraw));
|
||||
assert_eq!(rs.match_index(), Some(0));
|
||||
let rs = ReverseSearch::new(&h, &flat(&h));
|
||||
let order: Vec<usize> = rs.matches().iter().map(|m| m.index).collect();
|
||||
assert_eq!(order, [3, 2, 1, 0]);
|
||||
assert_eq!(rs.selected_line(&h), Some("cargo test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_ranks_the_most_recent_equal_match_first() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("git", &h, &flat(&h));
|
||||
// Both git commands match equally well; the newer one wins the tie.
|
||||
assert_eq!(rs.selected_line(&h), Some("git commit -m x"));
|
||||
assert_eq!(rs.matches().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_matching_spans_words() {
|
||||
// `gst` is a subsequence of `git status` — the substring search this
|
||||
// replaces could never find it.
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("gst", &h, &flat(&h));
|
||||
assert_eq!(rs.selected_line(&h), Some("git status"));
|
||||
// The matched positions point at g, s, t for the menu highlight.
|
||||
assert_eq!(rs.matches()[0].positions, vec![0, 4, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frecency_outranks_recency_between_equal_text_matches() {
|
||||
let h = history();
|
||||
// "git status" (oldest) is heavily used; the newer "git commit -m x"
|
||||
// is a one-off. The blend should float the frequent one on top.
|
||||
let frecency = vec![5.0, 0.0, 0.0, 0.0];
|
||||
let mut rs = ReverseSearch::new(&h, &frecency);
|
||||
rs.push_query("git", &h, &frecency);
|
||||
assert_eq!(rs.selected_line(&h), Some("git status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_collapse_to_their_most_recent_occurrence() {
|
||||
let h: Vec<String> = ["ls", "make", "ls"].into_iter().map(String::from).collect();
|
||||
let rs = ReverseSearch::new(&h, &flat(&h));
|
||||
let idx: Vec<usize> = rs.matches().iter().map(|m| m.index).collect();
|
||||
assert_eq!(idx, [2, 1]); // one "ls", at its newest position
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_r_and_arrows_step_through_matches_and_stick_at_the_ends() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("git", &h, &flat(&h));
|
||||
assert_eq!(rs.selected(), 0);
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("ctrl-r"), &h, &flat(&h)),
|
||||
Action::Redraw
|
||||
));
|
||||
assert_eq!(rs.selected_line(&h), Some("git status"));
|
||||
// Already on the last match — a further step sticks.
|
||||
rs.handle_key(&key("ctrl-r"), &h, &flat(&h));
|
||||
assert_eq!(rs.selected(), 1);
|
||||
// Ctrl+S / Up steps back toward the best match, sticking at the top.
|
||||
rs.handle_key(&key("ctrl-s"), &h, &flat(&h));
|
||||
assert_eq!(rs.selected(), 0);
|
||||
rs.handle_key(&key("up"), &h, &flat(&h));
|
||||
assert_eq!(rs.selected(), 0);
|
||||
rs.handle_key(&key("down"), &h, &flat(&h));
|
||||
assert_eq!(rs.selected(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_cancel_keys() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
assert!(matches!(rs.handle_key(&key("ctrl-g"), &h), Action::Cancel));
|
||||
assert!(matches!(rs.handle_key(&key("ctrl-c"), &h), Action::Cancel));
|
||||
assert!(matches!(rs.handle_key(&key("escape"), &h), Action::Cancel));
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("ctrl-g"), &h, &flat(&h)),
|
||||
Action::Cancel
|
||||
));
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("ctrl-c"), &h, &flat(&h)),
|
||||
Action::Cancel
|
||||
));
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("escape"), &h, &flat(&h)),
|
||||
Action::Cancel
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_enter_accepts_current_match_or_none() {
|
||||
fn enter_accepts_and_cmd_enter_runs_the_selection() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
rs.push_query("cargo", &h);
|
||||
match rs.handle_key(&key("enter"), &h) {
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("cargo", &h, &flat(&h));
|
||||
match rs.handle_key(&key("enter"), &h, &flat(&h)) {
|
||||
Action::Accept(Some(line)) => assert_eq!(line, "cargo test"),
|
||||
_ => panic!("expected Accept(Some) with the matched line"),
|
||||
_ => panic!("expected Accept(Some) with the selected line"),
|
||||
}
|
||||
// A bare Enter with no active match accepts nothing (just exits).
|
||||
let mut empty = ReverseSearch::new();
|
||||
match empty.handle_key(&key("enter"), &h) {
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("cargo", &h, &flat(&h));
|
||||
match rs.handle_key(&key("cmd-enter"), &h, &flat(&h)) {
|
||||
Action::Run(line) => assert_eq!(line, "cargo test"),
|
||||
_ => panic!("expected Run with the selected line"),
|
||||
}
|
||||
// A bare Enter with no match accepts nothing (just exits).
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("zzz_nope", &h, &flat(&h));
|
||||
assert!(rs.matches().is_empty());
|
||||
match rs.handle_key(&key("enter"), &h, &flat(&h)) {
|
||||
Action::Accept(None) => {}
|
||||
_ => panic!("expected Accept(None) with no match"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_backspace_pops_query_and_re_searches() {
|
||||
fn handle_key_backspace_pops_query_and_re_ranks() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
rs.push_query("gitx", &h); // no match (no "gitx" in history)
|
||||
assert_eq!(rs.match_index(), None);
|
||||
// Backspace drops the trailing 'x', restoring the "git" match.
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
rs.push_query("gitq", &h, &flat(&h)); // no match (no q anywhere)
|
||||
assert!(rs.matches().is_empty());
|
||||
// Backspace drops the trailing 'q', restoring the git matches.
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("backspace"), &h),
|
||||
rs.handle_key(&key("backspace"), &h, &flat(&h)),
|
||||
Action::Redraw
|
||||
));
|
||||
assert_eq!(rs.query(), "git");
|
||||
assert_eq!(rs.match_index(), Some(2));
|
||||
assert_eq!(rs.selected_line(&h), Some("git commit -m x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_key_other_keys_are_ignored_with_redraw() {
|
||||
let h = history();
|
||||
let mut rs = ReverseSearch::new();
|
||||
let mut rs = ReverseSearch::new(&h, &flat(&h));
|
||||
// A plain letter is handled via push_query, not handle_key; here it's a no-op redraw.
|
||||
assert!(matches!(rs.handle_key(&key("a"), &h), Action::Redraw));
|
||||
assert!(matches!(
|
||||
rs.handle_key(&key("a"), &h, &flat(&h)),
|
||||
Action::Redraw
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+530
-17
@@ -186,12 +186,20 @@ pub struct TerminalView {
|
||||
/// current-directory half of the frecency ranking, so commands used *here*
|
||||
/// float up. Kept in step with `history` on submit.
|
||||
history_cwds: std::collections::HashMap<String, std::collections::HashSet<String>>,
|
||||
/// Last-run metadata (timestamp + exit code) per history line, feeding the
|
||||
/// Ctrl+R menu's "ran 3h ago" and failure badges. Kept in step with
|
||||
/// `history` on submit; the exit code lands when the shell reports back.
|
||||
history_meta: std::collections::HashMap<String, super::history::EntryMeta>,
|
||||
/// `history` re-ordered by frecency (frequency × recency + a current-directory
|
||||
/// bonus), most relevant first. Drives the ghost-text autosuggestion — the
|
||||
/// sole whole-line recall surface besides Ctrl+R (the Tab menu stays
|
||||
/// history-free). Recomputed when a command is run or the working directory
|
||||
/// changes.
|
||||
history_ranked: Vec<String>,
|
||||
/// The frecency score of each `history` entry, index-aligned with it — the
|
||||
/// relevance half of the Ctrl+R search's fuzzy+frecency blend. Recomputed
|
||||
/// alongside `history_ranked`.
|
||||
history_frecency: Vec<f64>,
|
||||
/// The directory `history_ranked` was last computed for, so the polling loop
|
||||
/// only re-ranks when the working directory actually changes.
|
||||
ranked_cwd: Option<std::path::PathBuf>,
|
||||
@@ -201,14 +209,20 @@ pub struct TerminalView {
|
||||
/// The in-progress line saved when history navigation starts, so pressing ↓
|
||||
/// past the newest entry restores what the user was typing.
|
||||
history_stash: String,
|
||||
/// A submitted command whose history-file record is deferred until the
|
||||
/// shell reports back at its prompt, so the record can carry the command's
|
||||
/// exit code (see [`PendingHistory`]).
|
||||
pending_history: Option<PendingHistory>,
|
||||
/// Open Tab-completion menu, if any — a picker over the candidates gathered
|
||||
/// when it opened. Typing/Backspace re-filter it in place; it closes on
|
||||
/// accept, on Escape, or once the edited word no longer matches anything.
|
||||
completion: Option<CompletionSession>,
|
||||
/// Active Ctrl+R reverse-history search, if any. While set, the editor shows a
|
||||
/// `(reverse-i-search)` prompt instead of the line: typing edits the query,
|
||||
/// Ctrl+R steps to older matches, Enter accepts the match into the line, and
|
||||
/// Escape/Ctrl+G cancels.
|
||||
/// Active Ctrl+R history search, if any. While set, the editor shows a
|
||||
/// `(reverse-i-search)` prompt instead of the line and a menu of the ranked
|
||||
/// matches floats beside it: typing edits the query (fuzzy, blended with
|
||||
/// frecency), Ctrl+R/↓ and Ctrl+S/↑ move the selection, Enter accepts the
|
||||
/// selection into the line, Cmd+Enter runs it outright, and Escape/Ctrl+G
|
||||
/// cancels.
|
||||
reverse_search: Option<ReverseSearch>,
|
||||
/// True while a left-drag that began on the command-editor line is in progress,
|
||||
/// so mouse-move extends the editor selection rather than the terminal's.
|
||||
@@ -240,6 +254,26 @@ pub(super) struct HoveredLink {
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
/// A submitted command whose history-file record is deferred so it can carry
|
||||
/// the command's exit code (like zsh's `INC_APPEND_HISTORY_TIME`). `seq` is
|
||||
/// [`RemoteTerminal::prompt_seq`] at submit time: a later report that puts the
|
||||
/// shell back at its prompt means the run completed and `last_exit_code()` is
|
||||
/// this command's. Flushed without an exit code if the view goes away first.
|
||||
struct PendingHistory {
|
||||
line: String,
|
||||
cwd: Option<std::path::PathBuf>,
|
||||
ts: u64,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
/// Seconds since the unix epoch — the timestamp history records carry.
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Outcome of a ⌘ shortcut at the terminal surface — the three control-flow
|
||||
/// paths the key dispatcher needs. Splitting the ⌘ block into its own method
|
||||
/// keeps `on_key_down` readable; the caller maps each variant back to the
|
||||
@@ -622,6 +656,8 @@ impl TerminalView {
|
||||
&history.cwds,
|
||||
None,
|
||||
);
|
||||
let history_frecency =
|
||||
super::history::frecency_scores(&history.entries, &history.counts, &history.cwds, None);
|
||||
|
||||
Self {
|
||||
terminal,
|
||||
@@ -658,10 +694,13 @@ impl TerminalView {
|
||||
history: history.entries,
|
||||
history_counts: history.counts,
|
||||
history_cwds: history.cwds,
|
||||
history_meta: history.meta,
|
||||
history_ranked,
|
||||
history_frecency,
|
||||
ranked_cwd: None,
|
||||
history_nav: None,
|
||||
history_stash: String::new(),
|
||||
pending_history: None,
|
||||
completion: None,
|
||||
reverse_search: None,
|
||||
editor_selecting: false,
|
||||
@@ -1755,6 +1794,19 @@ impl TerminalView {
|
||||
}
|
||||
let at_prompt = self.terminal.at_prompt();
|
||||
|
||||
// A deferred history record is finalized once the shell has reported
|
||||
// back at its prompt: the daemon's `last_exit` is now this command's.
|
||||
// Sequence-based, so a fast command whose not-at-prompt window fell
|
||||
// between polls still gets its exit code.
|
||||
if self
|
||||
.pending_history
|
||||
.as_ref()
|
||||
.is_some_and(|p| at_prompt && self.terminal.prompt_seq() > p.seq)
|
||||
{
|
||||
self.flush_pending_history();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
// Re-rank history when the working directory changes (a `cd`), so ghost text
|
||||
// and completion start favouring commands run in the new directory. Only on
|
||||
// a real, known change — an unknown cwd keeps the previous ranking.
|
||||
@@ -2038,10 +2090,14 @@ impl TerminalView {
|
||||
}
|
||||
let line = self.cmd.text();
|
||||
// Record in history (skip blanks and immediate duplicates for ↑/↓ recall),
|
||||
// but always tally the run — count and the directory it ran in — for
|
||||
// frecency, then refresh the ranked view for the current directory.
|
||||
// but always tally the run — count, the directory it ran in, and when —
|
||||
// for ranking and the Ctrl+R menu, then refresh the ranked view for the
|
||||
// current directory. The file record is deferred until the shell reports
|
||||
// back at its prompt, so it can carry this run's exit code; a previous
|
||||
// record still deferred goes out first.
|
||||
if !line.trim().is_empty() {
|
||||
let cwd = self.cwd();
|
||||
let now = unix_now();
|
||||
*self.history_counts.entry(line.clone()).or_insert(0) += 1;
|
||||
if let Some(dir) = cwd.as_ref().and_then(|p| p.to_str()) {
|
||||
self.history_cwds
|
||||
@@ -2049,10 +2105,23 @@ impl TerminalView {
|
||||
.or_default()
|
||||
.insert(dir.to_string());
|
||||
}
|
||||
self.history_meta.insert(
|
||||
line.clone(),
|
||||
super::history::EntryMeta {
|
||||
ts: Some(now),
|
||||
exit: None,
|
||||
},
|
||||
);
|
||||
if self.history.last().map(String::as_str) != Some(line.as_str()) {
|
||||
self.history.push(line.clone());
|
||||
super::history::append(&line, cwd.as_deref());
|
||||
}
|
||||
self.flush_pending_history();
|
||||
self.pending_history = Some(PendingHistory {
|
||||
line: line.clone(),
|
||||
cwd: cwd.clone(),
|
||||
ts: now,
|
||||
seq: self.terminal.prompt_seq(),
|
||||
});
|
||||
self.rerank_history(cwd.as_deref());
|
||||
}
|
||||
self.history_nav = None;
|
||||
@@ -2118,15 +2187,42 @@ impl TerminalView {
|
||||
/// in that directory float to the top of ghost text and completion. Records the
|
||||
/// directory used, so `poll_foreground` can skip re-ranking until it changes.
|
||||
fn rerank_history(&mut self, cwd: Option<&std::path::Path>) {
|
||||
let cwd_str = cwd.and_then(|p| p.to_str());
|
||||
self.history_ranked = super::history::rank_by_frecency(
|
||||
&self.history,
|
||||
&self.history_counts,
|
||||
&self.history_cwds,
|
||||
cwd.and_then(|p| p.to_str()),
|
||||
cwd_str,
|
||||
);
|
||||
self.history_frecency = super::history::frecency_scores(
|
||||
&self.history,
|
||||
&self.history_counts,
|
||||
&self.history_cwds,
|
||||
cwd_str,
|
||||
);
|
||||
self.ranked_cwd = cwd.map(std::path::Path::to_path_buf);
|
||||
}
|
||||
|
||||
/// Write the deferred history record (see [`PendingHistory`]), if any. The
|
||||
/// exit code is attached only when the shell has reported back *and* sits
|
||||
/// at its prompt again — then `last_exit_code()` is this command's;
|
||||
/// otherwise (pane going away mid-command, a new submit racing in) the
|
||||
/// record goes out without one, like a plain shell history line.
|
||||
fn flush_pending_history(&mut self) {
|
||||
let Some(p) = self.pending_history.take() else {
|
||||
return;
|
||||
};
|
||||
let exit = (self.terminal.prompt_seq() > p.seq && self.terminal.at_prompt())
|
||||
.then(|| self.terminal.last_exit_code())
|
||||
.flatten();
|
||||
if exit.is_some()
|
||||
&& let Some(m) = self.history_meta.get_mut(&p.line)
|
||||
{
|
||||
m.exit = exit;
|
||||
}
|
||||
super::history::append(&p.line, p.cwd.as_deref(), p.ts, exit);
|
||||
}
|
||||
|
||||
/// The autosuggestion (ghost text): the most *frecent* history entry that
|
||||
/// starts with the current line, when the caret is at the end. Returns the
|
||||
/// *full* suggested line; the renderer shows the remainder in muted text and
|
||||
@@ -2144,10 +2240,12 @@ impl TerminalView {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Begin a Ctrl+R reverse-history search (no-op if one is already active).
|
||||
/// Begin a Ctrl+R history search (no-op if one is already active). Opens
|
||||
/// with the empty query's frecency listing, so the menu is browsable
|
||||
/// before a single key is typed.
|
||||
fn start_reverse_search(&mut self) {
|
||||
if self.reverse_search.is_none() {
|
||||
self.reverse_search = Some(ReverseSearch::new());
|
||||
self.reverse_search = Some(ReverseSearch::new(&self.history, &self.history_frecency));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2168,7 +2266,7 @@ impl TerminalView {
|
||||
if let Some(ch) = ks.key_char.as_deref() {
|
||||
if !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}') {
|
||||
if let Some(rs) = self.reverse_search.as_mut() {
|
||||
rs.push_query(ch, &self.history);
|
||||
rs.push_query(ch, &self.history, &self.history_frecency);
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
@@ -2178,7 +2276,7 @@ impl TerminalView {
|
||||
let Some(rs) = self.reverse_search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match rs.handle_key(ks, &self.history) {
|
||||
match rs.handle_key(ks, &self.history, &self.history_frecency) {
|
||||
reverse_search::Action::Redraw => {}
|
||||
reverse_search::Action::Cancel => self.reverse_search = None,
|
||||
reverse_search::Action::Accept(line) => {
|
||||
@@ -2187,6 +2285,12 @@ impl TerminalView {
|
||||
self.cmd.set(&line);
|
||||
}
|
||||
}
|
||||
reverse_search::Action::Run(line) => {
|
||||
// Cmd+Enter: accept the selection and run it in one stroke.
|
||||
self.reverse_search = None;
|
||||
self.cmd.set(&line);
|
||||
self.submit_command(cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
@@ -2369,7 +2473,7 @@ impl TerminalView {
|
||||
}
|
||||
// While reverse-searching, typed text edits the query, not the line.
|
||||
if let Some(rs) = self.reverse_search.as_mut() {
|
||||
rs.push_query(text, &self.history);
|
||||
rs.push_query(text, &self.history, &self.history_frecency);
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
return;
|
||||
@@ -2747,13 +2851,14 @@ impl TerminalView {
|
||||
let cy_top = px(8.) + self.line_height * (crow as f32);
|
||||
|
||||
// Reverse-search mode replaces the line with a `(reverse-i-search)` prompt
|
||||
// showing the query and the current match.
|
||||
// showing the query and the selected match; the ranked candidates float
|
||||
// in their own menu (`render_reverse_search_menu`).
|
||||
if let Some(rs) = &self.reverse_search {
|
||||
let label = format!("(reverse-i-search)`{}': ", rs.query());
|
||||
let matched = rs
|
||||
.match_index()
|
||||
.map(|i| self.history[i].clone())
|
||||
.unwrap_or_default();
|
||||
.selected_line(&self.history)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
return div()
|
||||
.absolute()
|
||||
.left(cx_left)
|
||||
@@ -3080,6 +3185,168 @@ impl TerminalView {
|
||||
)
|
||||
}
|
||||
|
||||
/// The floating Ctrl+R history menu: the ranked matches (best first) in a
|
||||
/// completion-style popup anchored to the input row — matched characters
|
||||
/// highlighted, the last-run time and a failure badge on the right. The
|
||||
/// classic `(reverse-i-search)` prompt stays on the input row itself
|
||||
/// (`render_input_bar`); this menu is the browsable view of the candidates,
|
||||
/// windowed around the selection like the completion menu.
|
||||
fn render_reverse_search_menu(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<impl IntoElement + use<>> {
|
||||
let rs = self.reverse_search.as_ref()?;
|
||||
let matches = rs.matches();
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (srow, _) = self.cursor_cell()?;
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
let (total_rows, total_cols) = {
|
||||
let term = self.terminal.term.lock();
|
||||
(term.screen_lines(), term.columns())
|
||||
};
|
||||
let (place_above, visible, first) =
|
||||
menu_layout(total_rows, srow, matches.len(), rs.selected(), MAX_ROWS);
|
||||
let hidden_above = first;
|
||||
let hidden_below = matches.len() - first - visible;
|
||||
|
||||
let theme = cx.theme();
|
||||
let lh = self.line_height;
|
||||
let now = unix_now();
|
||||
let row = |i: usize| {
|
||||
let m = &matches[i];
|
||||
let line = self.history[m.index].as_str();
|
||||
let selected = rs.selected() == i;
|
||||
let base = if selected {
|
||||
theme.foreground
|
||||
} else {
|
||||
theme.popover_foreground
|
||||
};
|
||||
|
||||
// The command in runs of matched/unmatched characters, so the
|
||||
// query's hits read highlighted inside the (possibly clipped) text.
|
||||
let mut spans: Vec<gpui::AnyElement> = Vec::new();
|
||||
let mut flush = |run: &mut String, hit: bool| {
|
||||
if run.is_empty() {
|
||||
return;
|
||||
}
|
||||
spans.push(
|
||||
div()
|
||||
.flex_none()
|
||||
.whitespace_nowrap()
|
||||
.text_color(if hit { theme.blue } else { base })
|
||||
.child(std::mem::take(run))
|
||||
.into_any_element(),
|
||||
);
|
||||
};
|
||||
let mut pos = m.positions.iter().copied().peekable();
|
||||
let mut run = String::new();
|
||||
let mut run_hit = false;
|
||||
for (ci, ch) in line.chars().enumerate() {
|
||||
let hit = pos.next_if_eq(&ci).is_some();
|
||||
if hit != run_hit {
|
||||
flush(&mut run, run_hit);
|
||||
run_hit = hit;
|
||||
}
|
||||
run.push(ch);
|
||||
}
|
||||
flush(&mut run, run_hit);
|
||||
|
||||
// Right column: a failure badge when the last run exited non-zero,
|
||||
// and how long ago that run was.
|
||||
let meta = self.history_meta.get(line);
|
||||
let failed = meta.and_then(|em| em.exit).filter(|&e| e != 0);
|
||||
let ago = meta
|
||||
.and_then(|em| em.ts)
|
||||
.map(|ts| super::history::format_ago(now, ts));
|
||||
|
||||
div()
|
||||
.h(lh)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.px_2()
|
||||
.whitespace_nowrap()
|
||||
// Same selection fill as the completion menu (see the note
|
||||
// there on `list_active` vs the stock `accent`).
|
||||
.when(selected, |d| d.bg(theme.list_active))
|
||||
.child(div().flex_1().flex().overflow_hidden().children(spans))
|
||||
.when_some(failed, |d, code| {
|
||||
d.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(theme.red)
|
||||
.child(format!("✗ {code}")),
|
||||
)
|
||||
})
|
||||
.when_some(ago, |d, ago| {
|
||||
d.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(ago),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
};
|
||||
let rows: Vec<gpui::AnyElement> = (first..first + visible).map(row).collect();
|
||||
|
||||
// Menu height (for upward placement) = rows + any overflow footers.
|
||||
let footer = |n: usize, label: String| {
|
||||
(n > 0).then(|| {
|
||||
div()
|
||||
.h(lh)
|
||||
.flex()
|
||||
.items_center()
|
||||
.px_2()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(label)
|
||||
.into_any_element()
|
||||
})
|
||||
};
|
||||
let footer_lines = (hidden_above > 0) as usize + (hidden_below > 0) as usize;
|
||||
let line_count = visible + footer_lines;
|
||||
let menu_h = lh * (line_count as f32) + px(10.);
|
||||
|
||||
// Anchored at the line's left edge (unlike the completion menu, which
|
||||
// anchors at the current word): history rows are whole commands, so
|
||||
// the menu spans the input area at a fixed width — that keeps the
|
||||
// right-hand metadata column vertically aligned across rows. A small
|
||||
// gap keeps it clear of the input line and its caret.
|
||||
let gap = px(6.);
|
||||
let grid_w = self.cell_width * (total_cols as f32);
|
||||
let menu_w = if grid_w < px(720.) { grid_w } else { px(720.) };
|
||||
let y = if place_above {
|
||||
px(8.) + lh * (srow as f32) - menu_h - gap
|
||||
} else {
|
||||
px(8.) + lh * ((srow + 1) as f32) + gap
|
||||
};
|
||||
|
||||
Some(
|
||||
div()
|
||||
.absolute()
|
||||
.left(px(16.))
|
||||
.top(y)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.py_1()
|
||||
.w(menu_w)
|
||||
.overflow_hidden()
|
||||
.bg(theme.popover)
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded(px(6.))
|
||||
.font_family(self.font.family.clone())
|
||||
.text_size(self.font_size)
|
||||
.text_color(theme.popover_foreground)
|
||||
.children(footer(hidden_above, format!("↑ {hidden_above} more")))
|
||||
.children(rows)
|
||||
.children(footer(hidden_below, format!("↓ {hidden_below} more"))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Map a highlighter token kind to a theme color.
|
||||
fn kind_color(&self, kind: TokenKind, cx: &App) -> gpui::Hsla {
|
||||
let theme = cx.theme();
|
||||
@@ -3101,6 +3368,15 @@ impl Focusable for TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalView {
|
||||
fn drop(&mut self) {
|
||||
// A history record still deferred when the pane goes away (tab closed,
|
||||
// window closed — possibly mid-command) is flushed rather than lost;
|
||||
// it carries an exit code only if the shell had already reported back.
|
||||
self.flush_pending_history();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Editor live: adopt anything typed while it was disengaged. Held gap
|
||||
@@ -3133,6 +3409,10 @@ impl Render for TerminalView {
|
||||
.input_active()
|
||||
.then(|| self.render_completion_menu(cx))
|
||||
.flatten();
|
||||
let reverse_search_menu = self
|
||||
.input_active()
|
||||
.then(|| self.render_reverse_search_menu(cx))
|
||||
.flatten();
|
||||
|
||||
// Captured for the right-click menu: the focus handle routes dispatched
|
||||
// actions to this terminal (and lets tab/split ones bubble to the root),
|
||||
@@ -3213,6 +3493,7 @@ impl Render for TerminalView {
|
||||
.children(search_bar)
|
||||
.children(input_bar)
|
||||
.children(completion_menu)
|
||||
.children(reverse_search_menu)
|
||||
// Right-click context menu (gpui-component PopupMenu).
|
||||
.context_menu(move |menu, _window, _cx| {
|
||||
// Small size = tighter 20px rows; the default 26px felt too airy.
|
||||
@@ -4321,6 +4602,238 @@ mod gpui_tests {
|
||||
assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x0c]));
|
||||
}
|
||||
|
||||
fn key(spec: &str) -> gpui::Keystroke {
|
||||
gpui::Keystroke::parse(spec).expect("valid keystroke spec")
|
||||
}
|
||||
|
||||
/// The Ctrl+R flow end-to-end at the editor dispatcher: Ctrl+R opens the
|
||||
/// search, typed text (the IME/commit path) edits the query with fuzzy
|
||||
/// matching, Enter loads the selection into the editor without running it.
|
||||
#[gpui::test]
|
||||
fn ctrl_r_fuzzy_search_accepts_into_the_editor(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.history = ["git status", "cargo build", "git commit -m x"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
view.history_frecency = vec![0.0; view.history.len()];
|
||||
|
||||
view.handle_editor_key(&key("ctrl-r"), cx);
|
||||
assert!(view.reverse_search.is_some(), "Ctrl+R opens the search");
|
||||
// `gst` is a subsequence of `git status` — fuzzy, not substring.
|
||||
view.commit_text("gst", cx);
|
||||
assert_eq!(
|
||||
view.reverse_search
|
||||
.as_ref()
|
||||
.and_then(|rs| rs.selected_line(&view.history)),
|
||||
Some("git status")
|
||||
);
|
||||
view.handle_editor_key(&key("enter"), cx);
|
||||
assert!(view.reverse_search.is_none(), "Enter closes the search");
|
||||
assert_eq!(view.cmd.text(), "git status");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Repeated Ctrl+R steps down the ranked matches, and Cmd+Enter runs the
|
||||
/// selection outright: the line must come out of the client socket as
|
||||
/// `Input` bytes ending in `\r`.
|
||||
#[gpui::test]
|
||||
fn ctrl_r_steps_matches_and_cmd_enter_runs(cx: &mut TestAppContext) {
|
||||
// `submit_command` defers a history-file record; pin the config dir to
|
||||
// the shared test scratch so nothing touches the real user history.
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
crate::core::config::set_config_dir(dir);
|
||||
|
||||
let (window, mut daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.history = ["git status", "cargo build", "git commit -m x"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
view.history_frecency = vec![0.0; view.history.len()];
|
||||
|
||||
view.handle_editor_key(&key("ctrl-r"), cx);
|
||||
view.commit_text("git", cx);
|
||||
// Equal fuzzy scores: the newer entry ranks first; a second
|
||||
// Ctrl+R steps to the older match.
|
||||
assert_eq!(
|
||||
view.reverse_search
|
||||
.as_ref()
|
||||
.and_then(|rs| rs.selected_line(&view.history)),
|
||||
Some("git commit -m x")
|
||||
);
|
||||
view.handle_editor_key(&key("ctrl-r"), cx);
|
||||
assert_eq!(
|
||||
view.reverse_search
|
||||
.as_ref()
|
||||
.and_then(|rs| rs.selected_line(&view.history)),
|
||||
Some("git status")
|
||||
);
|
||||
view.handle_editor_key(&key("cmd-enter"), cx);
|
||||
assert!(view.reverse_search.is_none());
|
||||
assert!(view.cmd.is_empty(), "submit clears the editor");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
next_input_until_timeout(&mut daemon),
|
||||
Some(b"git status\r".to_vec()),
|
||||
"Cmd+Enter ships the selected line to the PTY"
|
||||
);
|
||||
}
|
||||
|
||||
/// The Ctrl+R menu actually renders while the shell sits at its prompt:
|
||||
/// with `input_active` true and a search open over entries carrying run
|
||||
/// metadata, a real (headless) frame draws `render_reverse_search_menu` —
|
||||
/// guarding the row/highlight/badge layout code against panics that unit
|
||||
/// tests of the search logic can't reach.
|
||||
#[gpui::test]
|
||||
fn reverse_search_menu_survives_a_real_render_pass(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
// Put the shell at its prompt so `input_active()` is true and the
|
||||
// menu branch of `render` runs.
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit: None,
|
||||
}
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
for _ in 0..200 {
|
||||
if window
|
||||
.update(cx, |view, _, _| view.terminal.at_prompt())
|
||||
.unwrap()
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
assert!(view.input_active(), "prompt report engages the editor");
|
||||
view.history = ["git status", "cargo build --release", "echo hello"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
view.history_frecency = vec![0.0; view.history.len()];
|
||||
// Metadata for the badge/ago column: one failed run, one aged.
|
||||
view.history_meta.insert(
|
||||
"cargo build --release".into(),
|
||||
super::super::history::EntryMeta {
|
||||
ts: Some(unix_now().saturating_sub(7200)),
|
||||
exit: Some(1),
|
||||
},
|
||||
);
|
||||
view.handle_editor_key(&key("ctrl-r"), cx);
|
||||
view.commit_text("c", cx);
|
||||
assert!(
|
||||
view.reverse_search
|
||||
.as_ref()
|
||||
.is_some_and(|rs| !rs.matches().is_empty()),
|
||||
"the query has matches for the menu to draw"
|
||||
);
|
||||
cx.notify();
|
||||
})
|
||||
.unwrap();
|
||||
// Let the notified frame actually draw — a panic in the menu layout
|
||||
// or row rendering fails the test here.
|
||||
cx.run_until_parked();
|
||||
window
|
||||
.update(cx, |view, _, _| {
|
||||
assert!(view.reverse_search.is_some(), "search survives the frame");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The deferred history record picks up the command's exit code once the
|
||||
/// shell reports back at its prompt (OSC 133;D → daemon `Prompt` frame →
|
||||
/// `prompt_seq`/`last_exit_code`), and the file line carries it.
|
||||
#[gpui::test]
|
||||
fn submitted_command_backfills_its_exit_code(cx: &mut TestAppContext) {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
crate::core::config::set_config_dir(dir.clone());
|
||||
|
||||
let (window, mut daemon) = harness(cx);
|
||||
let wait = |cx: &mut TestAppContext, pred: &dyn Fn(&TerminalView) -> bool, what: &str| {
|
||||
for _ in 0..200 {
|
||||
if window.update(cx, |view, _, _| pred(view)).unwrap() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
};
|
||||
|
||||
// The shell reaches its prompt (integration active).
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit: None,
|
||||
}
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
wait(cx, &|v| v.terminal.at_prompt(), "the initial prompt report");
|
||||
|
||||
let marker = format!("tty7_gpui_exit_marker_{}", std::process::id());
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set(&marker);
|
||||
view.submit_command(cx);
|
||||
assert!(view.pending_history.is_some(), "record defers for the exit");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// The command runs (leaves the prompt) and finishes with exit 3.
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: false,
|
||||
last_exit: None,
|
||||
}
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit: Some(3),
|
||||
}
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
wait(
|
||||
cx,
|
||||
&|v| v.terminal.at_prompt() && v.terminal.last_exit_code() == Some(3),
|
||||
"the post-command prompt report",
|
||||
);
|
||||
|
||||
window
|
||||
.update(cx, |view, window, cx| {
|
||||
view.poll_foreground(window, cx);
|
||||
assert!(view.pending_history.is_none(), "poll flushed the record");
|
||||
assert_eq!(
|
||||
view.history_meta.get(&marker).and_then(|m| m.exit),
|
||||
Some(3),
|
||||
"in-memory metadata learned the exit code"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// The file record is the current format with the exit code attached.
|
||||
let content = std::fs::read_to_string(dir.join("history")).expect("history file written");
|
||||
let line = content
|
||||
.lines()
|
||||
.find(|l| l.contains(&marker))
|
||||
.expect("the submitted command was recorded");
|
||||
let mut fields = line.splitn(4, '\t');
|
||||
let ts = fields.next().unwrap();
|
||||
assert!(!ts.is_empty() && ts.bytes().all(|b| b.is_ascii_digit()));
|
||||
assert_eq!(fields.next(), Some("3"), "exit code field");
|
||||
}
|
||||
|
||||
/// Readline's Meta word chords act on the local prompt editor: M-b / M-f
|
||||
/// move by word, M-d deletes the word right of the caret. (On macOS these
|
||||
/// chords reach the editor only with `macos_option_as_alt` on — the
|
||||
|
||||
Reference in New Issue
Block a user