diff --git a/ROADMAP.md b/ROADMAP.md index 16abbc8..0f4660c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -754,9 +754,11 @@ and a first GitHub release so the download buttons resolve. - ☑ Blame view (per-line author + commit jump) - ☑ Reflog browser - ☑ File history (log for a path) -- ◐ Commit search — in-graph message/author/hash search shipped (highlight + ‹/› - nav, lanes intact); `-G` / `-S` content search still pending (needs a backend - `git log` search) +- ☑ Commit search — in-graph message/author/hash highlight over the loaded + window (instant, lanes intact) **plus** full-history message/author + `-G` + content (pickaxe) search via a backend `Repo::search_log`, surfaced in a + results dropdown (out-of-window hits open in the detail panel). `-S` + occurrence-count pickaxe is a possible future refinement. - ☑ Stashes shown inline on the graph (synthetic node per stash, attached to its base commit; distinct diamond marker + `stash@{n}` chip; right-click Apply/Pop/Drop) - ☐ Drag-and-drop renames in file tree @@ -1012,6 +1014,32 @@ successful delete clears the row with no optimistic bookkeeping. New `tsc`, and an end-to-end bare-remote run confirming both the remote branch and the local tracking ref are removed. +**Full-history commit search (2026-06-24):** Closed the last ◐ on the commit +search line. The in-graph search highlighted matches over the loaded log +client-side — instant, but blind past the loaded window and unable to search +file *contents* (it holds no diffs). A backend `Repo::search_log(query, mode, +limit)` (`log.rs`) now shells out to `git log` with the matching filter: +`--grep` / `--author` (`--fixed-strings -i`, mirroring the client's +case-insensitive substring) reach the whole history, and **`-G` (the pickaxe)** +searches each commit's diff for an added/removed line matching the query — the +one search the client can't do. Refs + empty-repo handling mirror `log`; the +`--format`/parse path is shared (`commit_format`). New `repo_search_log` IPC + +`repoSearchLog` wrapper + a `searchLog` store action that stashes hits in +`commitSearchResults`. UI: the search pill gained a **Content** field mode and a +"Search all history" button (⌘↵, or ↵ in Content mode) that opens a **results +dropdown** — keyboard-navigable (↑/↓/↵, combobox + listbox/`aria-activedescendant` +semantics) — listing matches across history with author/short-hash/date and a +marker for hits older than the loaded graph. Clicking a result scrolls + focuses +it when it's a loaded row, and opens the detail panel either way (`CommitDetail` +falls back to `commitSearchResults` so an out-of-window commit still renders; +its diff loads by oid). The loaded-window highlight + ‹/› stay untouched for +message/author/hash — the deep search is an explicit, separate action so a +`git log -G` over full history never fires per-keystroke. Verified with +`cargo test -p strand-core` (+5 `log` tests: empty query, case-insensitive +message, author name/email, the `-G` pickaxe touching-commit-only invariant, and +limit + cross-branch reach), `clippy`, `tsc`, `vitest` (131), and `vite build`. +`-S` occurrence-count pickaxe is a possible future refinement. + --- ## 1.1+ — Post-1.0 diff --git a/TASKS.md b/TASKS.md index 20ea3ca..78ac5fe 100644 --- a/TASKS.md +++ b/TASKS.md @@ -63,10 +63,13 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked `FileBlob` + `BlobSource`, base64 over IPC via a std-only `base64_encode`, 8 MB cap with a metadata pre-check on the worktree path, behind `safe_workdir_path`; powers the image diff preview) -- ◐ Commit search (message, author, hash) — in-graph highlight over the loaded - log is done **client-side** (no backend; `Commits.tsx` `commitMatches`), so no - `Repo` search command exists yet. Full-history search + `-G` / `-S` content - search (which need `git log --grep`/`-G`) are still ☐. +- ☑ Commit search (message, author, hash, content). In-graph highlight over the + loaded log stays **client-side** (`Commits.tsx` `commitMatches`); full-history + search is now backed by `Repo::search_log(query, mode, limit)` (`log.rs`): + `--grep` / `--author` (`--fixed-strings -i`) reach beyond the loaded window, + and **`-G` (the pickaxe)** searches commit diffs — exposed via `repo_search_log` + and a Content field mode + results dropdown. (`-S` occurrence-count pickaxe is + a possible refinement; hash search stays a client-side prefix match.) ### Writes - ☑ Stage / unstage path (`Repo::stage_path` / `unstage_path` via git2) @@ -234,6 +237,7 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked ## strand-tauri (IPC + app shell) - ☑ Read commands: `repo_open`, `repo_meta`, `repo_status`, `repo_log`, + `repo_search_log`, `repo_refs`, `repo_diff_unstaged` / `_staged` / `_between`, `repo_tree`, `repo_submodules`, `repo_blame`, `repo_reflog`, `repo_file_content`, `repo_file_blob`, `repo_file_history`, `repo_diff_commit_file`, @@ -492,8 +496,13 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked N/M counter; `/` focuses the field, ⌘K "Search commits…" jumps to it, Esc clears. Client-side over the loaded log (message **subject**, author name/email, hash prefix — body is excluded so `Co-Authored-By:`/`Signed-off-by:` - trailers don't match nearly every commit) — full-history + `-G`/`-S` content - search remain ☐ under Reads. + trailers don't match nearly every commit). **Full-history + content search + shipped (2026-06-24):** a **Content** field mode + a "Search all history" + button (⌘↵, or ↵ in Content) run `Repo::search_log` and open a + keyboard-navigable results dropdown (combobox + listbox) over `--grep` / + `--author` / `-G` matches across all history; an out-of-window hit opens in the + detail panel (`CommitDetail` falls back to `commitSearchResults`). The + loaded-window highlight + ‹/› are untouched. - ☑ Stashes shown inline on the graph (`mergeStashRows` in `Commits.tsx` splices a synthetic node per stash above its base commit; `Stash` gained `base` + `time_unix` from `stash_list`; `GraphRow.isStash` → neutral diamond in `CommitGraphCell`; @@ -984,7 +993,8 @@ quick-wins from that audit already landed (see ROADMAP changelog). - ☑ Wire commit-graph search (`Commits.tsx`). Resolved by **highlighting matches in place instead of filtering** — every commit stays in the list, so the lane algorithm's parent→child continuity is never broken. (Backend `git log`-based - search for full history / `-G`/`-S` is still ☐ under strand-core → Reads.) + full-history / `-G` content search shipped 2026-06-24 — `Repo::search_log`, + surfaced in a results dropdown; see strand-core → Reads.) --- diff --git a/crates/strand-core/src/log.rs b/crates/strand-core/src/log.rs index 1a4c3fd..7e86133 100644 --- a/crates/strand-core/src/log.rs +++ b/crates/strand-core/src/log.rs @@ -20,6 +20,32 @@ pub struct Commit { /// Field separator inside one commit's format line (ASCII unit separator). const FS: char = '\u{1f}'; +/// The `--format` arg shared by [`Repo::log`] and [`Repo::search_log`], so both +/// produce records [`parse_log`] can read. One line per commit, fields split by +/// FS; with `-z` each record is NUL-terminated so a multi-line body can't be +/// mistaken for the next record. `%ct` is committer time (what git2's +/// `commit.time()` returned); `%P` is space-separated full parent hashes (empty +/// for a root commit); `%s`/`%b` are subject and body. +fn commit_format() -> String { + format!("--format=%H{FS}%an{FS}%ae{FS}%ct{FS}%P{FS}%s{FS}%b") +} + +/// Which field [`Repo::search_log`] matches against. Serialized lowercase +/// (`"message"` / `"author"` / `"content"`) over IPC. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SearchMode { + /// The commit *message* (subject + body) — `git log --grep`. + Message, + /// The author name / email — `git log --author`. + Author, + /// The commit's *diff* content — `git log -G` (the "pickaxe"): commits + /// whose change added or removed a line matching the query. This is the + /// one search the client side can't do over the loaded log (it has no + /// diffs), and the reason this command exists. + Content, +} + impl Repo { /// Walk commits across **all refs** (`git log --all`-style), newest first, /// up to `limit`. @@ -47,11 +73,7 @@ impl Repo { /// would also pull in `refs/stash` and notes). pub fn log(&self, limit: usize) -> Result> { let limit_arg = limit.to_string(); - // One line per commit, fields split by FS; `-z` terminates each commit - // record with NUL, so a multi-line body can't be mistaken for the next - // record. `%ct` is committer time (what git2's `commit.time()` returned); - // `%P` is space-separated full parent hashes (empty for a root commit). - let format = format!("--format=%H{FS}%an{FS}%ae{FS}%ct{FS}%P{FS}%s{FS}%b"); + let format = commit_format(); let out = crate::git_command() .current_dir(&self.path) .env("GIT_TERMINAL_PROMPT", "0") @@ -96,6 +118,81 @@ impl Repo { Ok(parse_log(&String::from_utf8_lossy(&out.stdout), limit)) } + + /// Search commits across **all history** (every ref, not just the loaded + /// window) by message, author, or diff content, newest first, up to + /// `limit` matches. + /// + /// The in-graph search highlights matches over the already-loaded log + /// client-side — instant, but blind to commits past the loaded window and + /// unable to search file *contents* (it holds no diffs). This shells out to + /// `git log` with the matching filter so both gaps close: + /// `--grep` / `--author` reach the full history, and `-G` (the pickaxe) + /// searches each commit's diff for an added/removed line matching the query. + /// + /// `--grep` / `--author` use `--fixed-strings` so a plain-text query is a + /// literal substring match (mirroring the client side's `includes`); `-G` + /// is always a regular expression (the pickaxe has no fixed-string form), so + /// content queries are treated as regexes. `-i` makes all three + /// case-insensitive. Note `--grep` matches the **whole** message (subject + + /// body), unlike the client-side subject-only highlight — full-history + /// search is an explicit, scannable result list, not a wash over the graph, + /// so a body/trailer hit is acceptable here. + /// + /// A blank query returns an empty list rather than matching everything. The + /// ref selectors and empty-repo handling mirror [`Repo::log`]. + pub fn search_log(&self, query: &str, mode: SearchMode, limit: usize) -> Result> { + let query = query.trim(); + if query.is_empty() { + return Ok(Vec::new()); + } + let limit_arg = limit.to_string(); + let format = commit_format(); + // Attached forms (`--grep=…`, `-G…`) so a query beginning with `-` + // can't be re-read as an option. + let filter = match mode { + SearchMode::Message => format!("--grep={query}"), + SearchMode::Author => format!("--author={query}"), + SearchMode::Content => format!("-G{query}"), + }; + let mut cmd = crate::git_command(); + cmd.current_dir(&self.path) + .env("GIT_TERMINAL_PROMPT", "0") + .args(crate::GIT_SAFE_CONFIG) + .args(["log", "--date-order", "--no-color", "-z", "-i", "-n", &limit_arg]); + // Fixed-string match for message/author; `-G` stays a regex. + if !matches!(mode, SearchMode::Content) { + cmd.arg("--fixed-strings"); + } + cmd.arg(&format) + .arg(&filter) + .args(["HEAD", "--branches", "--remotes", "--tags"]); + + let out = cmd + .output() + .map_err(|e| Error::Other(format!("spawn git failed: {e}")))?; + + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + // Same unborn-HEAD / empty-repo mapping as `log`. + let e = err.to_lowercase(); + if e.contains("does not have any commits") + || e.contains("bad revision") + || e.contains("unknown revision") + || e.contains("bad default revision") + { + return Ok(Vec::new()); + } + let err = err.trim().to_string(); + return Err(Error::Other(if err.is_empty() { + "git log search failed".to_string() + } else { + err + })); + } + + Ok(parse_log(&String::from_utf8_lossy(&out.stdout), limit)) + } } /// Parse `git log -z --format=…` output. Records are NUL-terminated; within a @@ -236,4 +333,98 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// Run a search with a generous default limit, unwrapping the result. + fn search(repo: &Repo, q: &str, mode: SearchMode) -> Vec { + repo.search_log(q, mode, 100).unwrap() + } + + #[test] + fn search_empty_query_returns_empty() { + let (repo, dir) = scratch(); + commit(&dir, "a.txt", "x\n", "only commit"); + // Blank / whitespace-only must not match everything. + assert!(search(&repo, "", SearchMode::Message).is_empty()); + assert!(search(&repo, " ", SearchMode::Content).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn search_message_matches_subject_case_insensitive() { + let (repo, dir) = scratch(); + commit(&dir, "a.txt", "1\n", "fix the parser"); + commit(&dir, "b.txt", "2\n", "add a feature"); + + // Substring, case-insensitive. + let got = search(&repo, "PARSER", SearchMode::Message); + assert_eq!(got.len(), 1); + assert_eq!(got[0].subject, "fix the parser"); + // No match → empty (not an error). + assert!(search(&repo, "nonexistent", SearchMode::Message).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn search_author_matches_name_or_email() { + let (repo, dir) = scratch(); + commit(&dir, "a.txt", "1\n", "by test"); + // A commit by a different author. + std::fs::write(dir.join("b.txt"), "2\n").unwrap(); + git(&dir, &["add", "b.txt"]); + git(&dir, &["commit", "-q", "-m", "by alice", "--author=Alice "]); + + let alice = search(&repo, "alice", SearchMode::Author); + assert_eq!(alice.len(), 1, "only Alice's commit matches"); + assert_eq!(alice[0].subject, "by alice"); + // The default author (Test ) authored the other. + let test = search(&repo, "test@example.com", SearchMode::Author); + assert_eq!(test.len(), 1); + assert_eq!(test[0].subject, "by test"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn search_content_pickaxe_finds_touching_commit_only() { + let (repo, dir) = scratch(); + // Content tokens deliberately absent from the messages, so a hit can + // only come from the diff — not the subject. + commit(&dir, "f.txt", "needle_x\n", "first change"); + commit(&dir, "f.txt", "needle_x\nneedle_y\n", "second change"); + + let yy = search(&repo, "needle_y", SearchMode::Content); + assert_eq!(yy.len(), 1, "only the commit whose diff added needle_y"); + assert_eq!(yy[0].subject, "second change"); + + // `-G needle_x` matches only the commit that added that line, not the + // one where it's mere context — that's the pickaxe's whole point. + let xx = search(&repo, "needle_x", SearchMode::Content); + assert_eq!(xx.len(), 1); + assert_eq!(xx[0].subject, "first change"); + + // A message-mode search for the same token finds nothing (no message + // contains it), proving content search reaches where message can't. + assert!(search(&repo, "needle_y", SearchMode::Message).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn search_respects_limit_and_searches_all_branches() { + let (repo, dir) = scratch(); + commit(&dir, "a.txt", "1\n", "match one"); + commit(&dir, "b.txt", "2\n", "match two"); + // A commit on another branch must still be reachable (mirrors `log`'s + // `--branches` ref set, so search isn't limited to the current branch). + git(&dir, &["checkout", "-q", "-b", "side"]); + commit(&dir, "c.txt", "3\n", "match three"); + git(&dir, &["checkout", "-q", "main"]); + + let all = search(&repo, "match", SearchMode::Message); + let unique: std::collections::HashSet<_> = all.iter().map(|c| &c.hash).collect(); + assert_eq!(unique.len(), 3, "all three across both branches"); + + // `limit` bounds the result count (newest first). + let capped = repo.search_log("match", SearchMode::Message, 2).unwrap(); + assert_eq!(capped.len(), 2); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index b8c33a5..e7f91d7 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -10,7 +10,7 @@ use strand_core::{ apply::ApplyTarget, blame::BlameLine, branch::CheckoutOutcome, commit::CommitOutcome, diff::FileDiff, file::{BlobSource, FileBlob, FileContent, FileHistoryEntry}, gitconfig::{self, GlobalIdentity}, - history::{MergeMode, RebaseEntry, RebaseStep}, log::Commit, + history::{MergeMode, RebaseEntry, RebaseStep}, log::{Commit, SearchMode}, network::{clone as core_clone, CancelHandle, CloneOutcome, NetworkOutcome, Progress}, reflog::ReflogEntry, refs::Refs, repo::RepoMeta, reset::{ResetMode, ResetOutcome}, @@ -126,6 +126,19 @@ pub fn repo_log(path: String, limit: Option) -> CmdResult> { Ok(Repo::discover(&path)?.log(limit.unwrap_or(500))?) } +/// Full-history commit search (message / author / diff content) — the backend +/// reach the client-side, loaded-window highlight can't cover. `mode` is one of +/// `"message"` / `"author"` / `"content"`. +#[tauri::command(async)] +pub fn repo_search_log( + path: String, + query: String, + mode: SearchMode, + limit: Option, +) -> CmdResult> { + Ok(Repo::discover(&path)?.search_log(&query, mode, limit.unwrap_or(200))?) +} + #[tauri::command(async)] pub fn repo_refs(path: String) -> CmdResult { Ok(Repo::discover(&path)?.refs()?) diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 90746c5..dd23c93 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -66,6 +66,7 @@ fn main() { commands::repo_diff_unstaged_full, commands::repo_merge_base, commands::repo_log, + commands::repo_search_log, commands::repo_refs, commands::repo_diff_unstaged, commands::repo_diff_staged, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bfc1f98..214d8b8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -905,6 +905,7 @@ export function App() { { id: 'worktrees', label: 'Show: Worktrees', group: 'Actions', shortcut: keyHint('view-worktrees'), keywords: 'worktree agent feature checkout overview', run: () => { setView('worktrees'); selectFile(null); } }, { id: 'worktree-new', label: 'New worktree…', group: 'Actions', keywords: 'worktree add branch checkout agent', run: () => setWorktreeOpen(true) }, { id: 'search-commits', label: 'Search commits…', group: 'Actions', shortcut: '/', keywords: 'find filter grep message author hash', run: () => { requestCommitSearch(); } }, + { id: 'search-content', label: 'Search file contents…', group: 'Actions', keywords: 'pickaxe content diff code history full grep -G -S', run: () => { requestCommitSearch('content'); } }, // Opens the ⌘F bar in whichever diff view is showing; other views // route to Local Changes first (the signal is consumed on mount). { id: 'search-diff', label: 'Search in diff…', group: 'Actions', shortcut: formatBinding('Mod+F', platform), keywords: 'find in diff grep text content search', run: () => { diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index 0c0e034..d346342 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -5,6 +5,7 @@ import type { CheckoutOutcome, CloneOutcome, Commit, + CommitSearchMode, CommitOutcome, FileBlob, FileContent, @@ -74,6 +75,10 @@ export const tauri = { repoUnwatch: (path: string) => invoke('repo_unwatch', { path }), repoCancelOp: (opId: string) => invoke('repo_cancel_op', { opId }), repoLog: (path: string, limit?: number) => invoke('repo_log', { path, limit }), + // Full-history search by message / author / diff content — the backend reach + // the client-side loaded-window highlight can't cover. + repoSearchLog: (path: string, query: string, mode: CommitSearchMode, limit?: number) => + invoke('repo_search_log', { path, query, mode, limit }), repoRefs: (path: string) => invoke('repo_refs', { path }), repoDiffUnstaged: (path: string) => invoke('repo_diff_unstaged', { path }), repoDiffStaged: (path: string) => invoke('repo_diff_staged', { path }), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 206e712..568e3e0 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -86,6 +86,14 @@ export interface Commit { parents: string[]; } +/** + * Field a full-history commit search matches against (the Rust `SearchMode`). + * `content` is the pickaxe (`git log -G`) — commits whose diff added or removed + * a matching line, which the loaded-window highlight can't do. `hash` is + * deliberately absent: hash lookup stays a client-side prefix match. + */ +export type CommitSearchMode = 'message' | 'author' | 'content'; + export type DiffStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange'; export interface FileDiff { diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index d2fd79c..777bf3b 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -17,6 +17,7 @@ import { tauri } from '../lib/tauri'; import { useSettings, type DiffMode } from './settings'; import type { Commit, + CommitSearchMode, FileDiff, FileStatus, MergeMode, @@ -85,6 +86,13 @@ export interface RepoState { meta: RepoMeta | null; status: FileStatus[]; commits: Commit[]; + /** + * Results of the last full-history commit search ({@link RepoState.searchLog}). + * Held in the store (not just the All Commits view) so {@link CommitDetail} + * can render a commit the search surfaced that isn't in the loaded `commits` + * window. Reset per tab. + */ + commitSearchResults: Commit[]; unstagedDiffs: FileDiff[]; stagedDiffs: FileDiff[]; @@ -486,6 +494,14 @@ export interface RepoState { /** Open the commit-detail panel for `hash`, or close it when null. */ selectCommit(hash: string | null): Promise; + /** + * Run a full-history commit search (message / author / diff content) and + * stash the matches in {@link RepoState.commitSearchResults}. Returns the + * matches so the caller can drive its own UI (count, dropdown). A blank query + * clears the results and returns `[]`. + */ + searchLog(query: string, mode: CommitSearchMode): Promise; + /** Switch to the All Commits graph and reveal (scroll to + highlight) * `hash` — the tip of a sidebar branch/remote/tag row. */ revealInGraph(hash: string): void; @@ -499,7 +515,13 @@ export interface RepoState { * graph once it mounts (mirrors {@link RepoState.revealCommit}). */ commitSearchFocus: boolean; - requestCommitSearch(): void; + /** + * Field the search field should switch to when the focus signal is consumed — + * lets "Search file contents…" jump straight into Content mode. `null` leaves + * the current field. Cleared alongside {@link RepoState.commitSearchFocus}. + */ + commitSearchMode: CommitSearchMode | null; + requestCommitSearch(mode?: CommitSearchMode): void; clearCommitSearchFocus(): void; /** @@ -632,6 +654,7 @@ const EMPTY_ACTIVE = { meta: null as RepoMeta | null, status: [] as FileStatus[], commits: [] as Commit[], + commitSearchResults: [] as Commit[], unstagedDiffs: [] as FileDiff[], stagedDiffs: [] as FileDiff[], localSelection: null as LocalSelection | null, @@ -716,6 +739,7 @@ export const useRepo = create((set, get) => ({ fileTab: 'content', selectedRef: null, commitSearchFocus: false, + commitSearchMode: null, diffSearchSignal: false, selectSinceBaseline: false, diffsTick: 0, @@ -1712,6 +1736,21 @@ export const useRepo = create((set, get) => ({ } }, + async searchLog(query, mode) { + const path = get().activePath; + if (!path) return []; + const q = query.trim(); + if (!q) { + set({ commitSearchResults: [] }); + return []; + } + const results = await tauri.repoSearchLog(path, q, mode); + // Bail if the active repo changed mid-flight (mirrors refreshLog). + if (get().activePath !== path) return []; + set({ commitSearchResults: results }); + return results; + }, + async refreshRecents() { try { set({ recents: await recentsDb.list() }); @@ -1727,8 +1766,9 @@ export const useRepo = create((set, get) => ({ setView: (view) => set({ view }), revealInGraph: (hash) => set({ view: 'commits', revealCommit: hash }), clearReveal: () => set({ revealCommit: null }), - requestCommitSearch: () => set({ view: 'commits', commitSearchFocus: true }), - clearCommitSearchFocus: () => set({ commitSearchFocus: false }), + requestCommitSearch: (mode) => + set({ view: 'commits', commitSearchFocus: true, commitSearchMode: mode ?? null }), + clearCommitSearchFocus: () => set({ commitSearchFocus: false, commitSearchMode: null }), requestDiffSearch: () => set({ diffSearchSignal: true }), clearDiffSearch: () => set({ diffSearchSignal: false }), openIgnoreDialog: (initial) => set({ ignoreDraft: initial }), diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index b6c1da7..a184f22 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -904,6 +904,7 @@ flex: 1; } .graph-search { + position: relative; display: flex; align-items: center; gap: 6px; @@ -971,6 +972,87 @@ opacity: 0.4; cursor: default; } +/* The "search all history" button pulses while the backend search runs. */ +.search-nav.busy { + opacity: 0.6; + animation: search-busy 0.9s ease-in-out infinite; +} +@keyframes search-busy { + 50% { + opacity: 1; + } +} +@media (prefers-reduced-motion: reduce) { + .search-nav.busy { + animation: none; + } +} +/* Full-history search results dropdown, anchored under the search pill. */ +.search-results { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 30; + max-height: 320px; + overflow-y: auto; + padding: 4px; + background: var(--bg-panel); + border-radius: 6px; + box-shadow: + 0 0 0 0.5px var(--border) inset, + var(--shadow-3); +} +.search-result { + display: flex; + flex-direction: column; + gap: 1px; + padding: 5px 8px; + border-radius: 4px; + cursor: pointer; +} +.search-result.empty { + color: var(--text-muted); + font-size: var(--type-ui-sm); + cursor: default; +} +.search-result.focused { + background: var(--bg-hover); +} +.search-result .sr-subject { + color: var(--text); + font-size: var(--type-ui-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.search-result .sr-meta { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: var(--type-ui-xs); +} +.search-result .sr-author { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 45%; +} +.search-result .sr-hash { + font-family: var(--mono); + flex-shrink: 0; +} +.search-result .sr-date { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} +/* Marks a result older than the loaded graph window (opens detail only). */ +.search-result .sr-out { + margin-left: auto; + color: var(--accent); + font-weight: 700; +} .seg { display: flex; background: var(--bg-elev); diff --git a/ui/src/views/CommitDetail.tsx b/ui/src/views/CommitDetail.tsx index 74cdbde..f7b0a6f 100644 --- a/ui/src/views/CommitDetail.tsx +++ b/ui/src/views/CommitDetail.tsx @@ -34,6 +34,7 @@ export function CommitDetail({ const diffs = useRepo((s) => s.selectedCommitDiffs); const loading = useRepo((s) => s.selectedCommitDiffsLoading); const commits = useRepo((s) => s.commits); + const searchResults = useRepo((s) => s.commitSearchResults); const stashes = useRepo((s) => s.stashes); const selectCommit = useRepo((s) => s.selectCommit); const checkoutCommit = useRepo((s) => s.checkoutCommit); @@ -51,9 +52,15 @@ export function CommitDetail({ () => stashes.find((s) => s.oid === selectedCommit) ?? null, [stashes, selectedCommit], ); + // Prefer the loaded graph window; fall back to a full-history search result + // (so a commit the search surfaced from beyond the window still renders its + // header here — its diff loads by oid regardless), then a stash node. const commit = useMemo( - () => commits.find((c) => c.hash === selectedCommit) ?? (stash ? stashCommit(stash) : null), - [commits, selectedCommit, stash], + () => + commits.find((c) => c.hash === selectedCommit) ?? + searchResults.find((c) => c.hash === selectedCommit) ?? + (stash ? stashCommit(stash) : null), + [commits, searchResults, selectedCommit, stash], ); const [selectedFile, setSelectedFile] = useState(null); diff --git a/ui/src/views/Commits.tsx b/ui/src/views/Commits.tsx index f089c0c..2aeb36d 100644 --- a/ui/src/views/Commits.tsx +++ b/ui/src/views/Commits.tsx @@ -77,6 +77,7 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } const returnToFile = useRepo((s) => s.returnToFile); // One-shot signal from the command palette's "Search commits…" action. const commitSearchFocus = useRepo((s) => s.commitSearchFocus); + const commitSearchModeSignal = useRepo((s) => s.commitSearchMode); const clearCommitSearchFocus = useRepo((s) => s.clearCommitSearchFocus); // Review baseline — when pinned, the toolbar offers one-click selection of // every commit since it (the agent session), and the palette's "Select @@ -87,6 +88,11 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } const clearSelectSinceBaseline = useRepo((s) => s.clearSelectSinceBaseline); const setView = useRepo((s) => s.setView); const selectFile = useRepo((s) => s.selectFile); + // Full-history search (message / author / diff content): the backend reach + // beyond the loaded window. Results live in the store so the detail panel can + // render a surfaced commit that isn't a loaded graph row. + const commitSearchResults = useRepo((s) => s.commitSearchResults); + const searchLog = useRepo((s) => s.searchLog); // Right-click (or Menu / Shift+F10) on a commit row opens this — the same // actions as the detail panel, reachable straight from the graph. @@ -242,6 +248,12 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } const [query, setQuery] = useState(''); const [searchMode, setSearchMode] = useState('message'); const searchInputRef = useRef(null); + // Full-history results dropdown state. + const [searchOpen, setSearchOpen] = useState(false); + const [searching, setSearching] = useState(false); + const [resultFocus, setResultFocus] = useState(0); + const searchWrapRef = useRef(null); + const focusedResultRef = useRef(null); // Inject stash nodes inline: each stash becomes a synthetic row right above // the commit it was taken on, so it visibly hangs off that point. The merged @@ -335,6 +347,9 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } return commits.filter((c) => commitMatches(c, q, searchMode)).map((c) => c.hash); }, [commits, query, searchMode]); const matchSet = useMemo(() => new Set(matches), [matches]); + // Hashes in the loaded graph window — lets a search result mark whether + // clicking it can scroll the graph or only open the detail panel. + const loadedSet = useMemo(() => new Set(commits.map((c) => c.hash)), [commits]); // The "current" match is derived from the focused row, so the counter and // ‹/› stepping can't drift out of sync with a separate index. const matchPos = focusedCommit ? matches.indexOf(focusedCommit) : -1; @@ -358,21 +373,93 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } const clearSearch = useCallback(() => { setQuery(''); + setSearchOpen(false); + // Leave `commitSearchResults` in the store: they're invisible once the + // dropdown is closed, keep any open detail panel valid (a panel may show an + // out-of-window result), and reset on tab switch. searchInputRef.current?.focus(); }, []); + // Escalate to a backend full-history search. The loaded-window highlight + // stays for instant feedback; this reaches commits past the window and — in + // Content mode — searches the diffs the client never loads. Explicit (a + // button / ⌘↵ / ↵ in Content), never per-keystroke: `git log -G` over full + // history can be slow on a big repo. + const runHistorySearch = useCallback(async () => { + const q = query.trim(); + if (!q || searchMode === 'hash') return; + setSearching(true); + setSearchOpen(true); + try { + await searchLog(q, searchMode); + setResultFocus(0); + } catch (e) { + onToast(`Search failed: ${errMessage(e)}`); + setSearchOpen(false); + } finally { + setSearching(false); + // The button click moves focus off the field; restore it so ↑/↓/↵ drive + // the dropdown. + searchInputRef.current?.focus(); + } + }, [query, searchMode, searchLog, onToast]); + + // Open a search result: scroll + focus it in the graph when it's a loaded + // row, and open the detail panel either way (its diff loads by oid, and the + // store keeps the result so the panel can render an out-of-window commit). + const openResult = useCallback( + (c: Commit) => { + setSearchOpen(false); + if (loadedSet.has(c.hash)) { + setFocusedCommit(c.hash); + setMulti(new Set([c.hash])); + anchorRef.current = c.hash; + graphMainRef.current?.focus(); + } + void selectCommit(c.hash); + }, + [loadedSet, selectCommit], + ); + const onSearchKeyDown = useCallback( (e: KeyboardEvent) => { + // While the results dropdown is open, the arrow keys drive it. + if (searchOpen && commitSearchResults.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setResultFocus((i) => Math.min(commitSearchResults.length - 1, i + 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setResultFocus((i) => Math.max(0, i - 1)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + const c = commitSearchResults[resultFocus]; + if (c) openResult(c); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setSearchOpen(false); + return; + } + } if (e.key === 'Enter') { e.preventDefault(); - stepMatch(e.shiftKey ? -1 : 1); + // ⌘↵ — or plain ↵ in Content mode, which has no in-window matches — + // runs the full-history search; otherwise ↵ steps the loaded matches. + if (e.metaKey || e.ctrlKey || searchMode === 'content') void runHistorySearch(); + else stepMatch(e.shiftKey ? -1 : 1); } else if (e.key === 'Escape') { e.preventDefault(); if (query) clearSearch(); else searchInputRef.current?.blur(); } }, - [stepMatch, query, clearSearch], + [searchOpen, commitSearchResults, resultFocus, openResult, searchMode, runHistorySearch, stepMatch, query, clearSearch], ); const openModeMenu = useCallback( @@ -382,12 +469,15 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } icon: searchMode === m ? 'check' : undefined, onSelect: () => { setSearchMode(m); + // Old results belong to the previous field — close the dropdown so a + // mode switch doesn't show stale hits until the next search. + setSearchOpen(false); // ContextMenu restores focus to its opener (the mode button) on // close; defer past that so the field gets focus for typing. requestAnimationFrame(() => searchInputRef.current?.focus()); }, }); - setMenu({ x, y, items: [opt('message'), opt('author'), opt('hash')] }); + setMenu({ x, y, items: [opt('message'), opt('author'), opt('hash'), opt('content')] }); }, [searchMode], ); @@ -572,14 +662,33 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } return () => window.removeEventListener('keydown', onKey); }, []); + // Close the results dropdown on an outside click. Escape is handled by the + // input's own keydown (which also clears the query), so this watches the + // pointer only. + useEffect(() => { + if (!searchOpen) return; + const onDown = (e: MouseEvent) => { + if (!searchWrapRef.current?.contains(e.target as Node)) setSearchOpen(false); + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [searchOpen]); + + // Keep the keyboard-focused result scrolled into view in the dropdown. + useEffect(() => { + if (searchOpen) focusedResultRef.current?.scrollIntoView({ block: 'nearest' }); + }, [resultFocus, searchOpen]); + // The command palette's "Search commits…" sets a one-shot store flag; consume // it once the graph is mounted (handles the palette switching the view in). useEffect(() => { if (!commitSearchFocus) return; + // "Search file contents…" carries a target field so it lands in Content mode. + if (commitSearchModeSignal) setSearchMode(commitSearchModeSignal); searchInputRef.current?.focus(); searchInputRef.current?.select(); clearCommitSearchFocus(); - }, [commitSearchFocus, clearCommitSearchFocus]); + }, [commitSearchFocus, commitSearchModeSignal, clearCommitSearchFocus]); // The palette's "Select commits since baseline" sets a one-shot store flag; // wait for the log to load before consuming so a view switch (which renders @@ -634,7 +743,7 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } > -
+
- + {/* In-window highlight nav — meaningless for Content (no loaded + rows match), so it's hidden there. */} + {searchMode !== 'content' && ( + <> + + {matches.length === 0 + ? 'No results' + : matchPos >= 0 + ? `${matchPos + 1}/${matches.length}` + : `${matches.length} found`} + + + + + )} + {/* Escalate to a backend full-history search. Hash stays + loaded-window only (no backend prefix search). */} + {searchMode !== 'hash' && ( + + )}
@@ -845,13 +1039,19 @@ export function Commits({ onCreateTag, onInteractiveRebase, onResetTo, onToast } ); } -/** Which field the commit search matches against. */ -type SearchMode = 'message' | 'author' | 'hash'; +/** + * Which field the commit search matches against. `message` / `author` / `hash` + * highlight in place over the loaded window (client-side); `content` has no + * client-side equivalent (the diffs aren't loaded) and is backend-only — + * pressing ↵ runs the full-history `git log -G` pickaxe. + */ +type SearchMode = 'message' | 'author' | 'hash' | 'content'; const MODE_LABEL: Record = { message: 'Message', author: 'Author', hash: 'Hash', + content: 'Content', }; /** @@ -870,6 +1070,9 @@ function commitMatches(c: Commit, q: string, mode: SearchMode): boolean { return c.author_name.toLowerCase().includes(q) || c.author_email.toLowerCase().includes(q); case 'hash': return c.hash.toLowerCase().startsWith(q); + case 'content': + // No client-side equivalent — content search runs against the backend. + return false; } }