From d1efd4a0a27805fd46cd5fe4a37dfd218acab187 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Wed, 22 Jul 2026 03:35:50 +0800 Subject: [PATCH 01/79] feat(search): search inside document contents The sidebar search only ever filtered filenames, titles and tags. The existing search_docs_core walks the workspace status folders, so it finds nothing in an arbitrary opened folder and could not be reused. Add a content search built on the scan traversal: - Extract collect_markdown_entries from run_scan so the file tree and content search share one definition of which files a folder contains. - Add core::search: rayon-parallel scan per query, matching through the regex crate with escaped terms so input stays literal and case folding does not shift the offsets used to build snippets. - Return pre-segmented snippets rather than offsets. Rust byte offsets and JavaScript UTF-16 indices disagree on any document containing an accent or an emoji, so no index crosses the process boundary. - Share the field weights between the in-app and MCP scorers so the two rank the same corpus identically. - Cancel superseded queries with a generation counter: a Tauri command cannot be aborted once running, so a stale search bails out instead of competing for the disk. Also lands the DOM range engine that will back find-in-document. --- src-tauri/Cargo.lock | 13 +- src-tauri/core/Cargo.toml | 1 + src-tauri/core/src/lib.rs | 2 + src-tauri/core/src/read.rs | 33 +- src-tauri/core/src/scan.rs | 88 +++-- src-tauri/core/src/score.rs | 91 +++++ src-tauri/core/src/search.rs | 583 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/tauri_api/mod.rs | 44 ++- src/hooks/useContentSearch.test.ts | 166 ++++++++ src/hooks/useContentSearch.ts | 73 ++++ src/lib/contentSearch.ts | 50 +++ src/lib/findMatches.test.ts | 188 ++++++++++ src/lib/findMatches.ts | 147 ++++++++ 14 files changed, 1416 insertions(+), 65 deletions(-) create mode 100644 src-tauri/core/src/score.rs create mode 100644 src-tauri/core/src/search.rs create mode 100644 src/hooks/useContentSearch.test.ts create mode 100644 src/hooks/useContentSearch.ts create mode 100644 src/lib/contentSearch.ts create mode 100644 src/lib/findMatches.test.ts create mode 100644 src/lib/findMatches.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 72e7b51..e4e00ec 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -834,6 +834,7 @@ version = "0.6.0" dependencies = [ "chrono", "rayon", + "regex", "schemars 1.2.1", "serde", "serde_json", @@ -3081,9 +3082,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3093,9 +3094,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3104,9 +3105,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" diff --git a/src-tauri/core/Cargo.toml b/src-tauri/core/Cargo.toml index 08eac84..8729808 100644 --- a/src-tauri/core/Cargo.toml +++ b/src-tauri/core/Cargo.toml @@ -12,6 +12,7 @@ tokio = { version = "1.52.2", features = ["process", "time", "io-util", "macros" serde_json = "1.0.150" chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } schemars = { version = "1.2.1", optional = true } +regex = "1.13.1" [dev-dependencies] tokio = { version = "1.52.2", features = ["rt", "rt-multi-thread", "macros"] } diff --git a/src-tauri/core/src/lib.rs b/src-tauri/core/src/lib.rs index 6952e29..10660b9 100644 --- a/src-tauri/core/src/lib.rs +++ b/src-tauri/core/src/lib.rs @@ -8,6 +8,8 @@ pub mod path_guard; pub mod read; pub mod rename; pub mod scan; +mod score; +pub mod search; pub mod slug; pub mod tasks; pub mod update; diff --git a/src-tauri/core/src/read.rs b/src-tauri/core/src/read.rs index 12d29c1..ecd6a9f 100644 --- a/src-tauri/core/src/read.rs +++ b/src-tauri/core/src/read.rs @@ -4,6 +4,7 @@ use serde::Serialize; use crate::error::{CoreError, ErrorCode}; use crate::frontmatter::{parse_doc_meta, split_frontmatter}; +use crate::score::{combine_terms, FieldHits}; use crate::write::DocStatus; /// ~25k tokens at ~4 chars/token; MCP responses stay under this. @@ -180,11 +181,6 @@ pub fn list_docs_core(root: &Path, filters: &DocFilters<'_>) -> Result = tags.iter().map(|t| t.to_lowercase()).collect(); - let mut total = 0u32; - for term in query_lower.split_whitespace() { - let mut term_score = 0u32; - if title_lower.as_deref().is_some_and(|t| t.contains(term)) { - term_score += SCORE_TITLE; - } - if tags_lower.iter().any(|t| t == term) { - term_score += SCORE_TAG; - } - if slug_lower.contains(term) { - term_score += SCORE_SLUG; - } - if body_lower.contains(term) { - term_score += SCORE_CONTENT; - } - if term_score == 0 { - return 0; - } - total += term_score; - } - total + combine_terms(query_lower.split_whitespace().map(|term| FieldHits { + title: title_lower.as_deref().is_some_and(|t| t.contains(term)), + tag: tags_lower.iter().any(|t| t == term), + slug: slug_lower.contains(term), + content: body_lower.contains(term), + })) } fn content_snippet(content: &str, query_lower: &str) -> Option { diff --git a/src-tauri/core/src/scan.rs b/src-tauri/core/src/scan.rs index a25c986..ed5fa9d 100644 --- a/src-tauri/core/src/scan.rs +++ b/src-tauri/core/src/scan.rs @@ -83,7 +83,14 @@ fn extract_first_heading(content: &str) -> Option { None } -fn parse_meta(content: &str) -> (Option, Vec) { +pub(crate) fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .to_string() +} + +pub(crate) fn parse_meta(content: &str) -> (Option, Vec) { let (fm, _) = split_frontmatter(content); let meta = fm.map(parse_doc_meta).unwrap_or_default(); let title = meta.title.or_else(|| extract_first_heading(content)); @@ -149,20 +156,20 @@ fn read_partial(path: &Path) -> std::io::Result { Ok(String::from_utf8_lossy(&buf).into_owned()) } -pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result { - let root_path = Path::new(&path); - if !root_path.exists() { - return Err(format!("Path does not exist: {}", path)); - } - - let dirs_visited = Arc::new(AtomicU64::new(0)); - let last_emit = Arc::new(Mutex::new(Instant::now())); +pub(crate) struct MarkdownWalk { + pub entries: Vec, + pub truncated: bool, + pub skipped: usize, +} +/// The one traversal every library feature goes through, so the file tree and +/// content search can never disagree about which files a folder contains. +pub(crate) fn collect_markdown_entries(root: &Path, mut on_dir: impl FnMut(&Path)) -> MarkdownWalk { let mut entries: Vec = Vec::new(); let mut truncated = false; - let skipped = AtomicUsize::new(0); + let mut skipped = 0usize; - let walker = WalkDir::new(root_path) + let walker = WalkDir::new(root) .follow_links(true) .into_iter() .filter_entry(|e| { @@ -183,22 +190,12 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result { - skipped.fetch_add(1, Ordering::Relaxed); + skipped += 1; continue; } }; if entry.file_type().is_dir() { - dirs_visited.fetch_add(1, Ordering::Relaxed); - maybe_emit_walk_progress( - progress, - &path, - entry.path(), - root_path, - &dirs_visited, - 0, - None, - &last_emit, - ); + on_dir(entry.path()); continue; } @@ -206,14 +203,13 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result MAX_FILE_BYTES { - skipped.fetch_add(1, Ordering::Relaxed); + skipped += 1; continue; } } @@ -225,6 +221,39 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result Result { + let root_path = Path::new(&path); + if !root_path.exists() { + return Err(format!("Path does not exist: {}", path)); + } + + let dirs_visited = Arc::new(AtomicU64::new(0)); + let last_emit = Arc::new(Mutex::new(Instant::now())); + + let walk = collect_markdown_entries(root_path, |dir| { + dirs_visited.fetch_add(1, Ordering::Relaxed); + maybe_emit_walk_progress( + progress, + &path, + dir, + root_path, + &dirs_visited, + 0, + None, + &last_emit, + ); + }); + let entries = walk.entries; + let truncated = walk.truncated; + let skipped = AtomicUsize::new(walk.skipped); + let total_to_read = entries.len() as u64; let files_processed = Arc::new(AtomicU64::new(0)); @@ -248,12 +277,7 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result u32 { + let mut total = 0; + if self.title { + total += SCORE_TITLE; + } + if self.tag { + total += SCORE_TAG; + } + if self.slug { + total += SCORE_SLUG; + } + if self.content { + total += SCORE_CONTENT; + } + total + } +} + +/// Every term must hit at least one field (AND); the document score is the sum +/// of the per-term scores. Shared so the in-app search and the MCP search rank +/// the same corpus identically. +pub(crate) fn combine_terms(per_term: impl IntoIterator) -> u32 { + let mut total = 0; + for hits in per_term { + let score = hits.score(); + if score == 0 { + return 0; + } + total += score; + } + total +} + +#[cfg(test)] +mod tests { + use super::*; + + const EVERY_FIELD: FieldHits = FieldHits { + title: true, + tag: true, + slug: true, + content: true, + }; + + #[test] + fn sums_every_hit_field() { + assert_eq!( + EVERY_FIELD.score(), + SCORE_TITLE + SCORE_TAG + SCORE_SLUG + SCORE_CONTENT + ); + } + + #[test] + fn a_term_matching_nothing_zeroes_the_document() { + let score = combine_terms([EVERY_FIELD, FieldHits::default()]); + assert_eq!(score, 0); + } + + #[test] + fn no_terms_scores_zero() { + assert_eq!(combine_terms([]), 0); + } + + #[test] + fn title_outranks_content() { + let title_only = FieldHits { + title: true, + ..FieldHits::default() + }; + let content_only = FieldHits { + content: true, + ..FieldHits::default() + }; + assert!(title_only.score() > content_only.score()); + } +} diff --git a/src-tauri/core/src/search.rs b/src-tauri/core/src/search.rs new file mode 100644 index 0000000..a7b8076 --- /dev/null +++ b/src-tauri/core/src/search.rs @@ -0,0 +1,583 @@ +use std::ops::Range; +use std::path::Path; + +use rayon::prelude::*; +use regex::{Regex, RegexBuilder}; +use serde::Serialize; + +use crate::error::{CoreError, ErrorCode}; +use crate::frontmatter::split_frontmatter; +use crate::scan::{collect_markdown_entries, parse_meta, relative_path}; +use crate::score::{combine_terms, FieldHits}; + +/// How many matching lines are returned per file. A single huge document must +/// not crowd every other result out of the list. +const MAX_LINES_PER_FILE: usize = 5; +/// Context kept before the first match on a line, in characters. +const SNIPPET_LEAD_CHARS: usize = 40; +/// Total snippet width, in characters. +const SNIPPET_WIDTH_CHARS: usize = 240; + +/// A run of snippet text, already split so the caller never does index +/// arithmetic. Rust byte offsets and JavaScript UTF-16 indices disagree the +/// moment a document contains an emoji or an accent, so offsets must not cross +/// the process boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetSegment { + pub text: String, + pub is_match: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LineMatch { + /// 1-based, counted from the start of the file including any frontmatter. + pub line: u32, + pub segments: Vec, + pub leading_ellipsis: bool, + pub trailing_ellipsis: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentHit { + pub path: String, + pub rel_path: String, + pub score: u32, + pub lines: Vec, + /// Total matching lines in the file, which may exceed `lines.len()`. + pub matched_lines: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentSearchResult { + pub hits: Vec, + /// True when a newer query superseded this one, so the hits are partial. + pub aborted: bool, + pub truncated: bool, +} + +impl ContentSearchResult { + pub fn empty() -> Self { + Self { + hits: Vec::new(), + aborted: false, + truncated: false, + } + } +} + +/// Lets the caller stop an in-flight search. Tauri commands cannot be +/// cancelled, so a superseded query has to bail out cooperatively. +pub trait SearchAbort: Sync { + fn is_aborted(&self) -> bool; +} + +pub struct NeverAborts; + +impl SearchAbort for NeverAborts { + fn is_aborted(&self) -> bool { + false + } +} + +pub struct ContentQuery { + terms: Vec, +} + +impl ContentQuery { + /// Returns `None` for a query with no searchable terms. + pub fn parse(query: &str, case_sensitive: bool) -> Option { + let terms: Vec = query + .split_whitespace() + .filter_map(|term| build_term(term, case_sensitive)) + .collect(); + if terms.is_empty() { + return None; + } + Some(Self { terms }) + } +} + +// The pattern is escaped, so the input is matched literally and a stray "(" in +// the search box cannot become a regex. Case folding is left to the regex +// engine because lowercasing the haystack is not length-preserving, which would +// misalign every offset used to build snippets. +fn build_term(term: &str, case_sensitive: bool) -> Option { + RegexBuilder::new(®ex::escape(term)) + .case_insensitive(!case_sensitive) + .build() + .ok() +} + +pub fn search_content( + root: &Path, + query: &ContentQuery, + abort: &dyn SearchAbort, +) -> Result { + if !root.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + format!("folder {} is missing", root.display()), + ) + .with_recovery("reopen the folder to rescan it")); + } + + let walk = collect_markdown_entries(root, |_| {}); + let mut hits: Vec = walk + .entries + .par_iter() + .filter_map(|entry| { + if abort.is_aborted() { + return None; + } + search_file(root, entry.path(), &query.terms) + }) + .collect(); + + hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.rel_path.cmp(&b.rel_path)) + }); + + Ok(ContentSearchResult { + hits, + aborted: abort.is_aborted(), + truncated: walk.truncated, + }) +} + +fn search_file(root: &Path, path: &Path, terms: &[Regex]) -> Option { + // A file deleted or made unreadable between the walk and the read is simply + // not a result; the next scan reconciles the tree. + let content = std::fs::read_to_string(path).ok()?; + let (_, body) = split_frontmatter(&content); + let (title, tags) = parse_meta(&content); + let slug = path.file_stem()?.to_string_lossy().to_string(); + + let score = combine_terms( + terms + .iter() + .map(|term| field_hits(term, title.as_deref(), &tags, &slug, body)), + ); + if score == 0 { + return None; + } + + let (lines, matched_lines) = matching_lines(body, first_body_line(&content, body), terms); + + Some(ContentHit { + path: path.to_string_lossy().to_string(), + rel_path: relative_path(root, path), + score, + lines, + matched_lines, + }) +} + +fn field_hits( + term: &Regex, + title: Option<&str>, + tags: &[String], + slug: &str, + body: &str, +) -> FieldHits { + FieldHits { + title: title.is_some_and(|t| term.is_match(t)), + tag: tags.iter().any(|tag| term.is_match(tag)), + slug: term.is_match(slug), + content: term.is_match(body), + } +} + +/// 1-based file line on which the body starts, so reported line numbers point +/// at the real file rather than at the post-frontmatter offset. `body` is a +/// subslice of `content`, so the length gap is exactly the frontmatter block. +fn first_body_line(content: &str, body: &str) -> u32 { + let consumed = content.len() - body.len(); + content[..consumed].matches('\n').count() as u32 + 1 +} + +fn matching_lines(body: &str, first_line: u32, terms: &[Regex]) -> (Vec, u32) { + let mut lines = Vec::new(); + let mut matched = 0u32; + for (offset, line) in body.lines().enumerate() { + let ranges = merge_overlapping(term_ranges(line, terms)); + if ranges.is_empty() { + continue; + } + matched += 1; + if lines.len() < MAX_LINES_PER_FILE { + lines.push(build_line_match(first_line + offset as u32, line, &ranges)); + } + } + (lines, matched) +} + +fn term_ranges(line: &str, terms: &[Regex]) -> Vec> { + let mut ranges: Vec> = terms + .iter() + .flat_map(|term| term.find_iter(line).map(|m| m.range())) + .collect(); + ranges.sort_by_key(|r| (r.start, r.end)); + ranges +} + +fn merge_overlapping(ranges: Vec>) -> Vec> { + let mut merged: Vec> = Vec::with_capacity(ranges.len()); + for range in ranges { + match merged.last_mut() { + Some(last) if range.start <= last.end => last.end = last.end.max(range.end), + _ => merged.push(range), + } + } + merged +} + +fn build_line_match(line: u32, text: &str, ranges: &[Range]) -> LineMatch { + let first_match = ranges.first().map(|r| r.start).unwrap_or(0); + let window = snippet_window(text, first_match); + LineMatch { + line, + segments: build_segments(text, &window, ranges), + leading_ellipsis: window.start > 0, + trailing_ellipsis: window.end < text.len(), + } +} + +fn snippet_window(text: &str, first_match: usize) -> Range { + let start = back_off_chars(text, first_match, SNIPPET_LEAD_CHARS); + let end = forward_chars(text, start, SNIPPET_WIDTH_CHARS); + start..end +} + +fn back_off_chars(text: &str, from: usize, chars: usize) -> usize { + text[..from] + .char_indices() + .rev() + .take(chars) + .last() + .map(|(i, _)| i) + .unwrap_or(from) +} + +fn forward_chars(text: &str, from: usize, chars: usize) -> usize { + text[from..] + .char_indices() + .nth(chars) + .map(|(i, _)| from + i) + .unwrap_or(text.len()) +} + +fn build_segments( + text: &str, + window: &Range, + ranges: &[Range], +) -> Vec { + let mut segments = Vec::new(); + let mut cursor = window.start; + for range in ranges { + if range.start < cursor || range.end > window.end { + continue; + } + push_segment(&mut segments, &text[cursor..range.start], false); + push_segment(&mut segments, &text[range.start..range.end], true); + cursor = range.end; + } + push_segment(&mut segments, &text[cursor..window.end], false); + segments +} + +fn push_segment(segments: &mut Vec, text: &str, is_match: bool) { + if text.is_empty() { + return; + } + segments.push(SnippetSegment { + text: text.to_string(), + is_match, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace::test_dir; + + struct AlwaysAborts; + + impl SearchAbort for AlwaysAborts { + fn is_aborted(&self) -> bool { + true + } + } + + fn write(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + fn search(root: &Path, query: &str) -> Vec { + let parsed = ContentQuery::parse(query, false).expect("query has terms"); + search_content(root, &parsed, &NeverAborts).unwrap().hits + } + + fn matched_text(hit: &ContentHit) -> Vec { + hit.lines + .iter() + .flat_map(|l| l.segments.iter()) + .filter(|s| s.is_match) + .map(|s| s.text.clone()) + .collect() + } + + #[test] + fn finds_a_term_only_present_in_the_body() { + let dir = test_dir("search_body"); + write( + &dir, + "notes.md", + "# Unrelated Title\n\nthe coturn relay flag\n", + ); + + let hits = search(&dir, "coturn"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "notes.md"); + assert_eq!(matched_text(&hits[0]), ["coturn"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn finds_a_term_beyond_the_partial_read_window() { + let dir = test_dir("search_deep"); + let filler = "lorem ipsum dolor sit amet\n".repeat(2000); + write( + &dir, + "long.md", + &format!("# Long\n\n{filler}\nneedle here\n"), + ); + + let hits = search(&dir, "needle"); + assert_eq!( + hits.len(), + 1, + "content past the 16 KiB scan window is searched" + ); + assert!( + filler.len() > 16 * 1024, + "fixture exceeds the scan read window" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn every_term_must_match_somewhere() { + let dir = test_dir("search_and"); + write(&dir, "both.md", "alpha and beta\n"); + write(&dir, "one.md", "alpha only\n"); + + let hits = search(&dir, "alpha beta"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "both.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn title_match_outranks_body_match() { + let dir = test_dir("search_rank"); + write( + &dir, + "body.md", + "# Something Else\n\nmentions gateway once\n", + ); + write( + &dir, + "titled.md", + "---\ntitle: Gateway\n---\n\nunrelated prose\n", + ); + + let hits = search(&dir, "gateway"); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].rel_path, "titled.md"); + assert!(hits[0].score > hits[1].score); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn reports_file_line_numbers_past_frontmatter() { + let dir = test_dir("search_lines"); + write( + &dir, + "fm.md", + "---\ntitle: X\ntags: [a]\n---\n\nfirst\ntarget line\n", + ); + + let hits = search(&dir, "target"); + assert_eq!(hits[0].lines[0].line, 7); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn frontmatter_only_match_does_not_count_as_content() { + let dir = test_dir("search_fm_only"); + write(&dir, "tagged.md", "---\ntags: [infra]\n---\n\nbody text\n"); + + let hits = search(&dir, "infra"); + assert_eq!(hits.len(), 1, "the tag still matches"); + assert!(hits[0].lines.is_empty(), "but no body line is reported"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn query_metacharacters_are_matched_literally() { + let dir = test_dir("search_meta"); + write(&dir, "regex.md", "a.b literal\n"); + write(&dir, "other.md", "axb should not match\n"); + + let hits = search(&dir, "a.b"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "regex.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn case_insensitive_by_default_and_case_sensitive_on_request() { + let dir = test_dir("search_case"); + write(&dir, "case.md", "Gateway rules\n"); + + assert_eq!(search(&dir, "gateway").len(), 1); + + let sensitive = ContentQuery::parse("gateway", true).unwrap(); + let hits = search_content(&dir, &sensitive, &NeverAborts).unwrap().hits; + assert!(hits.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn segments_survive_multibyte_content() { + let dir = test_dir("search_utf8"); + write(&dir, "emoji.md", "café ☕ needle 😀 tail\n"); + + let hits = search(&dir, "needle"); + let joined: String = hits[0].lines[0] + .segments + .iter() + .map(|s| s.text.as_str()) + .collect(); + assert!(joined.contains("café ☕")); + assert!(joined.contains('😀')); + assert_eq!(matched_text(&hits[0]), ["needle"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn overlapping_term_matches_merge_into_one_segment() { + let dir = test_dir("search_overlap"); + write(&dir, "overlap.md", "foobar\n"); + + let parsed = ContentQuery::parse("foo oob", false).unwrap(); + let hits = search_content(&dir, &parsed, &NeverAborts).unwrap().hits; + assert_eq!(matched_text(&hits[0]), ["foob"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn caps_reported_lines_but_counts_them_all() { + let dir = test_dir("search_cap"); + write(&dir, "many.md", &"needle\n".repeat(MAX_LINES_PER_FILE + 4)); + + let hits = search(&dir, "needle"); + assert_eq!(hits[0].lines.len(), MAX_LINES_PER_FILE); + assert_eq!(hits[0].matched_lines as usize, MAX_LINES_PER_FILE + 4); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn long_lines_are_windowed_with_ellipses() { + let dir = test_dir("search_window"); + let pad = "x".repeat(400); + write(&dir, "long_line.md", &format!("{pad} needle {pad}\n")); + + let hits = search(&dir, "needle"); + let line = &hits[0].lines[0]; + assert!(line.leading_ellipsis); + assert!(line.trailing_ellipsis); + let width: usize = line.segments.iter().map(|s| s.text.chars().count()).sum(); + assert!(width <= SNIPPET_WIDTH_CHARS, "snippet stays bounded"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn no_matches_returns_no_hits() { + let dir = test_dir("search_none"); + write(&dir, "a.md", "nothing relevant\n"); + + assert!(search(&dir, "absent").is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn blank_query_has_no_terms() { + assert!(ContentQuery::parse(" ", false).is_none()); + assert!(ContentQuery::parse("", false).is_none()); + } + + #[test] + fn abort_stops_the_search_and_is_reported() { + let dir = test_dir("search_abort"); + write(&dir, "a.md", "needle\n"); + + let parsed = ContentQuery::parse("needle", false).unwrap(); + let result = search_content(&dir, &parsed, &AlwaysAborts).unwrap(); + assert!(result.aborted); + assert!(result.hits.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn missing_folder_is_an_error() { + let dir = test_dir("search_missing"); + let _ = std::fs::remove_dir_all(&dir); + let parsed = ContentQuery::parse("x", false).unwrap(); + assert!(search_content(&dir, &parsed, &NeverAborts).is_err()); + } + + #[test] + fn skips_non_utf8_files_without_failing_the_search() { + let dir = test_dir("search_binary"); + write(&dir, "good.md", "needle\n"); + std::fs::write(dir.join("bad.md"), [0xff, 0xfe, 0x00, 0x6e]).unwrap(); + + let hits = search(&dir, "needle"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "good.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn query_longer_than_the_file_matches_nothing() { + let dir = test_dir("search_long_query"); + write(&dir, "tiny.md", "hi\n"); + + assert!(search(&dir, "a query far longer than the document").is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn honours_the_scan_skip_rules() { + let dir = test_dir("search_skips"); + write(&dir, "visible.md", "needle\n"); + write(&dir, "node_modules/hidden.md", "needle\n"); + write(&dir, ".hidden/secret.md", "needle\n"); + + let hits = search(&dir, "needle"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "visible.md"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b725ec..6d00310 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,8 +32,10 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(OpenedPaths::default()) + .manage(tauri_api::SearchGeneration::default()) .invoke_handler(tauri::generate_handler![ tauri_api::scan_markdown, + tauri_api::search_content, tauri_api::convert_workspace, tauri_api::detect_agent_clients, tauri_api::connect_agent_client, diff --git a/src-tauri/src/tauri_api/mod.rs b/src-tauri/src/tauri_api/mod.rs index 3c16e42..61b6dab 100644 --- a/src-tauri/src/tauri_api/mod.rs +++ b/src-tauri/src/tauri_api/mod.rs @@ -1,11 +1,16 @@ use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use tauri::path::BaseDirectory; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, State}; use crate::agents::{self, AgentClient, ClientId}; use docsreader_core::git::{git_show_head_core, git_status_core, GitStatus}; use docsreader_core::scan::{run_scan, ScanProgress, ScanProgressSink, ScanResult}; +use docsreader_core::search::{ + search_content as search_content_core, ContentQuery, ContentSearchResult, SearchAbort, +}; use docsreader_core::tasks::{list_tasks_core, set_task_status_core, TaskSummary}; use docsreader_core::workspace::init::{convert_workspace_core, InitializedWorkspace}; use docsreader_core::workspace::registry::{ @@ -33,6 +38,43 @@ pub async fn scan_markdown(app: AppHandle, path: String) -> Result); + +struct NewerQueryWins { + generation: u64, + latest: Arc, +} + +impl SearchAbort for NewerQueryWins { + fn is_aborted(&self) -> bool { + self.latest.load(Ordering::Relaxed) != self.generation + } +} + +#[tauri::command] +pub async fn search_content( + state: State<'_, SearchGeneration>, + path: String, + query: String, +) -> Result { + let latest = state.0.clone(); + let generation = latest.fetch_add(1, Ordering::SeqCst) + 1; + let abort = NewerQueryWins { generation, latest }; + + tauri::async_runtime::spawn_blocking(move || { + let Some(parsed) = ContentQuery::parse(&query, false) else { + return Ok(ContentSearchResult::empty()); + }; + search_content_core(Path::new(&path), &parsed, &abort).map_err(|e| e.message) + }) + .await + .map_err(|e| format!("search task panicked: {e}"))? +} + #[tauri::command] pub async fn convert_workspace( app: AppHandle, diff --git a/src/hooks/useContentSearch.test.ts b/src/hooks/useContentSearch.test.ts new file mode 100644 index 0000000..f1af817 --- /dev/null +++ b/src/hooks/useContentSearch.test.ts @@ -0,0 +1,166 @@ +import { act, renderHook } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { useContentSearch } from "./useContentSearch"; +import { searchContent, type ContentHit, type ContentSearchResult } from "@/lib/contentSearch"; + +vi.mock("@/lib/contentSearch", async () => { + const actual = await vi.importActual( + "@/lib/contentSearch" + ); + return { ...actual, searchContent: vi.fn() }; +}); + +const mockedSearch = vi.mocked(searchContent); + +function hit(relPath: string): ContentHit { + return { + path: `/lib/${relPath}`, + relPath, + score: 1, + lines: [ + { + line: 1, + segments: [{ text: "needle", isMatch: true }], + leadingEllipsis: false, + trailingEllipsis: false, + }, + ], + matchedLines: 1, + }; +} + +function result(hits: ContentHit[], overrides: Partial = {}) { + return { hits, aborted: false, truncated: false, ...overrides }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + + +async function settle(ms = 300) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +describe("useContentSearch", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockedSearch.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns hits for a query", async () => { + mockedSearch.mockResolvedValue(result([hit("a.md")])); + const { result: state } = renderHook(() => useContentSearch("/lib", "needle")); + + await settle(); + + expect(state.current.hits).toHaveLength(1); + expect(state.current.hits[0].relPath).toBe("a.md"); + expect(state.current.searching).toBe(false); + }); + + it("debounces so a typed word issues one search", async () => { + mockedSearch.mockResolvedValue(result([])); + const { rerender } = renderHook(({ q }) => useContentSearch("/lib", q), { + initialProps: { q: "n" }, + }); + + rerender({ q: "ne" }); + rerender({ q: "nee" }); + rerender({ q: "needle" }); + await settle(); + + expect(mockedSearch).toHaveBeenCalledTimes(1); + expect(mockedSearch).toHaveBeenCalledWith("/lib", "needle"); + }); + + it("does not let a slow earlier search overwrite newer results", async () => { + const slow = deferred(); + const fast = deferred(); + mockedSearch.mockReturnValueOnce(slow.promise).mockReturnValueOnce(fast.promise); + + const { rerender, result: state } = renderHook(({ q }) => useContentSearch("/lib", q), { + initialProps: { q: "old" }, + }); + await settle(); + + rerender({ q: "new" }); + await settle(); + + fast.resolve(result([hit("new.md")])); + await settle(0); + expect(state.current.hits).toHaveLength(1); + + slow.resolve(result([hit("stale.md")])); + await settle(50); + + expect(state.current.hits).toHaveLength(1); + expect(state.current.hits[0].relPath).toBe("new.md"); + }); + + it("discards a result the backend marked aborted", async () => { + mockedSearch.mockResolvedValue(result([hit("partial.md")], { aborted: true })); + const { result: state } = renderHook(() => useContentSearch("/lib", "needle")); + + await settle(); + + expect(state.current.hits).toHaveLength(0); + }); + + it("clears results and searches nothing for a blank query", async () => { + const { result: state } = renderHook(() => useContentSearch("/lib", " ")); + + await settle(); + + expect(mockedSearch).not.toHaveBeenCalled(); + expect(state.current.hits).toHaveLength(0); + expect(state.current.searching).toBe(false); + }); + + it("searches nothing without a folder", async () => { + renderHook(() => useContentSearch(undefined, "needle")); + + await settle(); + + expect(mockedSearch).not.toHaveBeenCalled(); + }); + + it("searches nothing while disabled", async () => { + renderHook(() => useContentSearch("/lib", "needle", false)); + + await settle(); + + expect(mockedSearch).not.toHaveBeenCalled(); + }); + + it("surfaces a failure message without dropping into a stuck searching state", async () => { + mockedSearch.mockRejectedValue(new Error("This folder could not be searched.")); + const { result: state } = renderHook(() => useContentSearch("/lib", "needle")); + + await settle(); + + expect(state.current.error).toBe("This folder could not be searched."); + expect(state.current.searching).toBe(false); + expect(state.current.hits).toHaveLength(0); + }); + + it("reports a truncated corpus", async () => { + mockedSearch.mockResolvedValue(result([hit("a.md")], { truncated: true })); + const { result: state } = renderHook(() => useContentSearch("/lib", "needle")); + + await settle(); + + expect(state.current.truncated).toBe(true); + }); +}); diff --git a/src/hooks/useContentSearch.ts b/src/hooks/useContentSearch.ts new file mode 100644 index 0000000..96e6812 --- /dev/null +++ b/src/hooks/useContentSearch.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from "react"; + +import { searchContent, type ContentHit } from "@/lib/contentSearch"; + +// Long enough that a typed word issues one search rather than one per letter, +// short enough that results feel attached to the keystroke. +const SEARCH_DEBOUNCE_MS = 200; + +export interface ContentSearchState { + hits: ContentHit[]; + searching: boolean; + error: string | undefined; + truncated: boolean; +} + +const IDLE: ContentSearchState = { + hits: [], + searching: false, + error: undefined, + truncated: false, +}; + +export function useContentSearch( + root: string | undefined, + query: string, + enabled = true +): ContentSearchState { + const [state, setState] = useState(IDLE); + // Every request carries a sequence number. A slow earlier search that lands + // after a newer one must not overwrite the newer results. + const latestRequest = useRef(0); + + useEffect(() => { + const trimmed = query.trim(); + if (!enabled || !root || !trimmed) { + latestRequest.current += 1; + setState(IDLE); + return; + } + + const request = ++latestRequest.current; + const isStale = () => latestRequest.current !== request; + + setState((prev) => ({ ...prev, searching: true, error: undefined })); + + const timer = setTimeout(() => { + void (async () => { + try { + const result = await searchContent(root, trimmed); + if (isStale() || result.aborted) return; + setState({ + hits: result.hits, + searching: false, + error: undefined, + truncated: result.truncated, + }); + } catch (e) { + if (isStale()) return; + setState({ + hits: [], + searching: false, + error: e instanceof Error ? e.message : String(e), + truncated: false, + }); + } + })(); + }, SEARCH_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [root, query, enabled]); + + return state; +} diff --git a/src/lib/contentSearch.ts b/src/lib/contentSearch.ts new file mode 100644 index 0000000..5911ade --- /dev/null +++ b/src/lib/contentSearch.ts @@ -0,0 +1,50 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface SnippetSegment { + text: string; + isMatch: boolean; +} + +export interface LineMatch { + line: number; + segments: SnippetSegment[]; + leadingEllipsis: boolean; + trailingEllipsis: boolean; +} + +export interface ContentHit { + path: string; + relPath: string; + score: number; + lines: LineMatch[]; + matchedLines: number; +} + +export interface ContentSearchResult { + hits: ContentHit[]; + aborted: boolean; + truncated: boolean; +} + +const SEARCH_FAILED_MESSAGE = + "This folder could not be searched. It may have been moved, or it may be on a drive that is no longer available."; + +export const EMPTY_CONTENT_SEARCH: ContentSearchResult = { + hits: [], + aborted: false, + truncated: false, +}; + +export async function searchContent( + root: string, + query: string +): Promise { + if (!query.trim()) return EMPTY_CONTENT_SEARCH; + try { + return await invoke("search_content", { path: root, query }); + } catch { + // The backend detail is not useful to a reader; surfacing the folder being + // unreadable is. + throw new Error(SEARCH_FAILED_MESSAGE); + } +} diff --git a/src/lib/findMatches.test.ts b/src/lib/findMatches.test.ts new file mode 100644 index 0000000..67285de --- /dev/null +++ b/src/lib/findMatches.test.ts @@ -0,0 +1,188 @@ +import { findRanges } from "./findMatches"; + +function mount(html: string): HTMLElement { + const host = document.createElement("div"); + host.innerHTML = html; + return host; +} + +function texts(ranges: Range[]): string[] { + return ranges.map((range) => range.toString()); +} + +describe("findRanges", () => { + it("finds a single match inside one text node", () => { + const root = mount("

the quick brown fox

"); + + const ranges = findRanges(root, "quick"); + + expect(texts(ranges)).toEqual(["quick"]); + expect(ranges[0].startContainer).toBe(ranges[0].endContainer); + }); + + it("returns matches in document order", () => { + const root = mount("

alpha

beta

alpha again

"); + + const ranges = findRanges(root, "alpha"); + + expect(texts(ranges)).toEqual(["alpha", "alpha"]); + expect(ranges[0].startContainer.textContent).toBe("alpha"); + expect(ranges[1].startContainer.textContent).toBe("alpha again"); + }); + + it("matches across two text nodes", () => { + const root = mount("

concat

"); + + const ranges = findRanges(root, "oncat"); + + expect(texts(ranges)).toEqual(["oncat"]); + expect(ranges[0].startContainer).not.toBe(ranges[0].endContainer); + }); + + it("matches across three or more text nodes, as Shiki token spans produce", () => { + const root = mount( + "
const value = 1
", + ); + + const ranges = findRanges(root, "const value ="); + + expect(texts(ranges)).toEqual(["const value ="]); + expect(ranges[0].startContainer).not.toBe(ranges[0].endContainer); + expect(ranges[0].startContainer.textContent).toBe("const"); + expect(ranges[0].endContainer.textContent).toBe(" = 1"); + }); + + it("is case-insensitive by default", () => { + const root = mount("

Fox fox FOX

"); + + expect(texts(findRanges(root, "fox"))).toEqual(["Fox", "fox", "FOX"]); + }); + + it("respects caseSensitive", () => { + const root = mount("

Fox fox FOX

"); + + expect(texts(findRanges(root, "fox", { caseSensitive: true }))).toEqual(["fox"]); + }); + + it("matches whole words only when wholeWord is set", () => { + const root = mount("

cat catalog concat cat.

"); + + expect(texts(findRanges(root, "cat", { wholeWord: true }))).toEqual(["cat", "cat"]); + expect(findRanges(root, "cat")).toHaveLength(4); + }); + + it("treats non-ASCII letters as word characters for wholeWord", () => { + const root = mount("

café cafés

"); + + expect(texts(findRanges(root, "café", { wholeWord: true }))).toEqual(["café"]); + }); + + it("returns nothing for empty or whitespace-only queries", () => { + const root = mount("

anything at all

"); + + expect(findRanges(root, "")).toEqual([]); + expect(findRanges(root, " \n\t ")).toEqual([]); + }); + + it("returns nothing when the query is absent", () => { + const root = mount("

anything at all

"); + + expect(findRanges(root, "zebra")).toEqual([]); + }); + + it("treats regex metacharacters literally", () => { + const root = mount("

a.b axb c++ cxx (x) [y]

"); + + expect(texts(findRanges(root, "a.b"))).toEqual(["a.b"]); + expect(texts(findRanges(root, "c++"))).toEqual(["c++"]); + expect(texts(findRanges(root, "(x)"))).toEqual(["(x)"]); + expect(texts(findRanges(root, "[y]"))).toEqual(["[y]"]); + }); + + it("skips script and style subtrees", () => { + const root = mount( + "

target

", + ); + + const ranges = findRanges(root, "target"); + + expect(ranges).toHaveLength(1); + expect(ranges[0].startContainer.parentElement?.tagName).toBe("P"); + }); + + it("skips aria-hidden subtrees", () => { + const root = mount('

ghost

'); + + expect(findRanges(root, "ghost")).toHaveLength(1); + }); + + it("skips the katex-mathml mirror so the raw TeX annotation is not matched", () => { + const root = mount( + '' + + 'E = mc^2' + + "

mc^2 in prose

", + ); + + const ranges = findRanges(root, "mc^2"); + + expect(ranges).toHaveLength(1); + expect(ranges[0].startContainer.parentElement?.tagName).toBe("P"); + }); + + it("returns nothing when the root itself is excluded", () => { + const root = mount("

visible

"); + root.setAttribute("aria-hidden", "true"); + + expect(findRanges(root, "visible")).toEqual([]); + }); + + it("handles astral-plane characters without splitting surrogate pairs", () => { + const root = mount("

hello 😀 world 😀

"); + + const ranges = findRanges(root, "😀"); + + expect(texts(ranges)).toEqual(["😀", "😀"]); + expect(ranges[0].startOffset).toBe(6); + expect(ranges[0].endOffset).toBe(8); + }); + + it("finds astral content spanning text nodes", () => { + const root = mount("

a😀b

"); + + const ranges = findRanges(root, "😀b"); + + expect(texts(ranges)).toEqual(["😀b"]); + expect(ranges[0].startContainer).not.toBe(ranges[0].endContainer); + }); + + it("stays correct where toLowerCase is not length-preserving", () => { + expect("İ".toLowerCase()).toHaveLength(2); + expect("İ").toHaveLength(1); + + const root = mount("

İstanbul

"); + const ranges = findRanges(root, "İstanbul"); + + expect(texts(ranges)).toEqual(["İstanbul"]); + expect(ranges[0].endOffset).toBe(8); + }); + + it("returns non-overlapping occurrences", () => { + const root = mount("

aaaa

"); + + const ranges = findRanges(root, "aa"); + + expect(texts(ranges)).toEqual(["aa", "aa"]); + expect(ranges[0].startOffset).toBe(0); + expect(ranges[1].startOffset).toBe(2); + }); + + it("ignores empty text nodes when building offsets", () => { + const root = document.createElement("div"); + root.appendChild(document.createTextNode("")); + root.appendChild(document.createTextNode("needle")); + + const ranges = findRanges(root, "needle"); + + expect(texts(ranges)).toEqual(["needle"]); + }); +}); diff --git a/src/lib/findMatches.ts b/src/lib/findMatches.ts new file mode 100644 index 0000000..c6915e2 --- /dev/null +++ b/src/lib/findMatches.ts @@ -0,0 +1,147 @@ +export interface FindOptions { + caseSensitive?: boolean; + wholeWord?: boolean; +} + +interface TextChunk { + node: Text; + start: number; + end: number; +} + +interface FlatText { + text: string; + chunks: TextChunk[]; +} + +interface MatchSpan { + start: number; + end: number; +} + +const EXCLUDED_TAGS = ["SCRIPT", "STYLE"] as const; + +// rehype-katex renders every formula twice: a visually hidden MathML mirror +// (span.katex-mathml, carrying the raw TeX in ) and the visible +// .katex-html. Walking the mirror yields phantom matches with zero-size rects. +const MATHML_MIRROR_CLASS = "katex-mathml"; + +function isExcludedElement(element: Element): boolean { + if (EXCLUDED_TAGS.some((tag) => tag === element.tagName)) return true; + if (element.getAttribute("aria-hidden") === "true") return true; + return element.classList.contains(MATHML_MIRROR_CLASS); +} + +function filterNode(node: Node): number { + if (!(node instanceof Element)) return NodeFilter.FILTER_ACCEPT; + if (isExcludedElement(node)) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_SKIP; +} + +function documentOf(node: Node): Document | null { + return node instanceof Document ? node : node.ownerDocument; +} + +function flatten(root: Node): FlatText { + const empty: FlatText = { text: "", chunks: [] }; + const doc = documentOf(root); + if (!doc) return empty; + if (root instanceof Element && isExcludedElement(root)) return empty; + + // SHOW_ELEMENT is required alongside SHOW_TEXT: the filter is only consulted + // for nodes the whatToShow mask selects, so a text-only walker can never see + // (and therefore never FILTER_REJECT) an excluded subtree's root. + const walker = doc.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + filterNode, + ); + + const chunks: TextChunk[] = []; + let text = ""; + + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + if (!(node instanceof Text)) continue; + if (node.data.length === 0) continue; + chunks.push({ node, start: text.length, end: text.length + node.data.length }); + text += node.data; + } + + return { text, chunks }; +} + +function escapeRegExp(source: string): string { + return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function resolveOptions(options: FindOptions): Required { + return { + caseSensitive: options.caseSensitive ?? false, + wholeWord: options.wholeWord ?? false, + }; +} + +// \b is ASCII-only, so word edges are expressed as Unicode-aware lookarounds. +// Limitation: only letters, digits and _ count as word characters, so a query +// that itself starts or ends with punctuation can never satisfy the boundary. +function buildMatcher(query: string, options: Required): RegExp { + const escaped = escapeRegExp(query); + const pattern = options.wholeWord + ? `(? createRange(doc, chunks, span)); +} From e2538b4fd691f8702354daa2c38a87d24d9d7787 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Wed, 22 Jul 2026 05:37:22 +0800 Subject: [PATCH 02/79] feat(search): show content matches in the sidebar Typing a query now searches document contents alongside names, titles and tags, and the sidebar shows a ranked result list with the matched line and its surrounding context. - A non-empty query replaces the lens body with results, so a file that matches by both name and content is listed once rather than in two places. Clearing the query restores the lens. - Name matches render immediately from the in-memory list and are re-ranked in place when the scored content hits land, so the list fills in instead of flashing empty. - Snippets render from pre-split segments as text nodes, so no markup from a document can reach the DOM. - The footer match count and the search placeholder now reflect that contents are searched. Note: typing a query while the tasks lens is open now shows search results rather than being silently ignored. --- src/App.tsx | 11 ++ src/components/explorer/ExplorerSidebar.tsx | 25 ++- src/components/explorer/SearchInput.tsx | 2 +- .../explorer/SearchResults.test.tsx | 162 ++++++++++++++++++ src/components/explorer/SearchResults.tsx | 152 ++++++++++++++++ src/components/explorer/SearchSnippet.tsx | 35 ++++ src/lib/searchEntries.test.ts | 91 ++++++++++ src/lib/searchEntries.ts | 55 ++++++ 8 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 src/components/explorer/SearchResults.test.tsx create mode 100644 src/components/explorer/SearchResults.tsx create mode 100644 src/components/explorer/SearchSnippet.tsx create mode 100644 src/lib/searchEntries.test.ts create mode 100644 src/lib/searchEntries.ts diff --git a/src/App.tsx b/src/App.tsx index 34b3024..83fa9e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,8 @@ import { UpdateToast } from "@/components/document/UpdateToast"; const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog")); import { useLibrary } from "@/hooks/useLibrary"; +import { useContentSearch } from "@/hooks/useContentSearch"; +import { mergeSearchEntries } from "@/lib/searchEntries"; import { useConvertPrompt } from "@/hooks/useConvertPrompt"; import { usePanes } from "@/hooks/usePanes"; import type { SplitMode } from "@/lib/storage"; @@ -261,6 +263,11 @@ function App() { }; }, [quickOpenMounted]); const filteredFiles = useFilteredFiles(allFiles, search); + const contentSearch = useContentSearch(library.activeRoot, search); + const searchEntries = useMemo( + () => mergeSearchEntries(filteredFiles, contentSearch.hits), + [filteredFiles, contentSearch.hits] + ); const tree = useMemo(() => { if (!library.activeRoot) return undefined; return buildTree(library.activeRoot, filteredFiles); @@ -572,6 +579,10 @@ function App() { onLensChange={handleLensChange} search={search} onSearchChange={setSearch} + searchEntries={searchEntries} + searchingContents={contentSearch.searching} + searchError={contentSearch.error} + searchTruncated={contentSearch.truncated} filteredFiles={filteredFiles} pinnedFiles={pinnedFiles} tree={tree} diff --git a/src/components/explorer/ExplorerSidebar.tsx b/src/components/explorer/ExplorerSidebar.tsx index 76bde5c..c18a4d2 100644 --- a/src/components/explorer/ExplorerSidebar.tsx +++ b/src/components/explorer/ExplorerSidebar.tsx @@ -18,7 +18,9 @@ import { PinnedList } from "./PinnedList"; import { RecentList } from "./RecentList"; import { ScanProgressView } from "./ScanProgressView"; import { SearchInput } from "./SearchInput"; +import { SearchResults } from "./SearchResults"; import { TagsList } from "./TagsList"; +import type { SearchEntry } from "@/lib/searchEntries"; import { WorkspaceSwitcher } from "./WorkspaceSwitcher"; import { TasksBoard } from "@/components/tasks/TasksBoard"; @@ -40,6 +42,10 @@ interface Props { // search search: string; onSearchChange: (value: string) => void; + searchEntries: SearchEntry[]; + searchingContents: boolean; + searchError: string | undefined; + searchTruncated: boolean; // files filteredFiles: MarkdownFile[]; @@ -82,6 +88,10 @@ export function ExplorerSidebar({ onLensChange, search, onSearchChange, + searchEntries, + searchingContents, + searchError, + searchTruncated, filteredFiles, pinnedFiles, tree, @@ -147,6 +157,19 @@ export function ExplorerSidebar({ progress={activeScan.progress} startedAt={activeScan.startedAt} /> + ) : search.trim() ? ( + ) : ( diff --git a/src/components/explorer/SearchInput.tsx b/src/components/explorer/SearchInput.tsx index c306e3a..8a8d21b 100644 --- a/src/components/explorer/SearchInput.tsx +++ b/src/components/explorer/SearchInput.tsx @@ -10,7 +10,7 @@ interface Props { export function SearchInput({ value, onChange, - placeholder = "Search files, titles, tags...", + placeholder = "Search names, tags, and contents...", }: Props) { return (
diff --git a/src/components/explorer/SearchResults.test.tsx b/src/components/explorer/SearchResults.test.tsx new file mode 100644 index 0000000..7c11345 --- /dev/null +++ b/src/components/explorer/SearchResults.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +import { SearchResults } from "./SearchResults"; +import type { SearchEntry } from "@/lib/searchEntries"; + +vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: vi.fn() })); + +const handlers = { + onSelect: vi.fn(), + onOpenInNewTab: vi.fn(), + onTogglePin: vi.fn(), +}; + +function entry(overrides: Partial = {}): SearchEntry { + return { + path: "/ws/notes/alpha.md", + relPath: "notes/alpha.md", + title: "Alpha Guide", + score: 4, + lines: [ + { + line: 12, + segments: [ + { text: "the ", isMatch: false }, + { text: "coturn", isMatch: true }, + { text: " relay", isMatch: false }, + ], + leadingEllipsis: true, + trailingEllipsis: true, + }, + ], + matchedLines: 1, + ...overrides, + }; +} + +function renderResults(props: Partial> = {}) { + return render( + false} + {...handlers} + {...props} + /> + ); +} + +describe("SearchResults", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows the document title and its matched snippet", () => { + renderResults(); + + expect(screen.getByText("Alpha Guide")).toBeInTheDocument(); + expect(screen.getByText("coturn")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + }); + + it("marks the matched text so it stands out from its context", () => { + renderResults(); + + const marked = screen.getByText("coturn"); + expect(marked.tagName).toBe("MARK"); + }); + + it("renders snippet text without injecting markup", () => { + renderResults({ + entries: [ + entry({ + lines: [ + { + line: 1, + segments: [{ text: "", isMatch: false }], + leadingEllipsis: false, + trailingEllipsis: false, + }, + ], + }), + ], + }); + + expect(screen.getByText("")).toBeInTheDocument(); + expect(document.querySelector("img")).toBeNull(); + }); + + it("opens the document when a result is clicked", async () => { + const user = userEvent.setup(); + renderResults(); + + await user.click(screen.getByText("Alpha Guide")); + + expect(handlers.onSelect).toHaveBeenCalledWith("/ws/notes/alpha.md"); + }); + + it("falls back to the file name when the document has no title", () => { + renderResults({ entries: [entry({ title: undefined })] }); + + expect(screen.getByText("alpha.md")).toBeInTheDocument(); + }); + + it("reports matched lines beyond the shown ones", () => { + renderResults({ entries: [entry({ matchedLines: 4 })] }); + + expect(screen.getByText("3 more lines")).toBeInTheDocument(); + }); + + it("uses the singular form for a single extra line", () => { + renderResults({ entries: [entry({ matchedLines: 2 })] }); + + expect(screen.getByText("1 more line")).toBeInTheDocument(); + }); + + it("shows a name-only match with no snippet", () => { + renderResults({ entries: [entry({ lines: [], matchedLines: 0, score: 0 })] }); + + expect(screen.getByText("Alpha Guide")).toBeInTheDocument(); + expect(screen.queryByText("coturn")).not.toBeInTheDocument(); + }); + + it("says nothing matched once the search settles", () => { + renderResults({ entries: [], searching: false }); + + expect(screen.getByText("No matches")).toBeInTheDocument(); + }); + + it("says it is still searching before results arrive", () => { + renderResults({ entries: [], searching: true }); + + expect(screen.getByText("Searching…")).toBeInTheDocument(); + expect(screen.queryByText("No matches")).not.toBeInTheDocument(); + }); + + it("keeps showing name matches while contents are still being searched", () => { + renderResults({ searching: true }); + + expect(screen.getByText("Alpha Guide")).toBeInTheDocument(); + expect(screen.getByText("Searching contents…")).toBeInTheDocument(); + }); + + it("surfaces a failure without any technical detail", () => { + renderResults({ error: "This folder could not be searched." }); + + expect(screen.getByText("Search unavailable")).toBeInTheDocument(); + expect(screen.getByText("This folder could not be searched.")).toBeInTheDocument(); + }); + + it("warns when the folder is too large to search completely", () => { + renderResults({ truncated: true }); + + expect( + screen.getByText("This folder is too large to search completely.") + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/explorer/SearchResults.tsx b/src/components/explorer/SearchResults.tsx new file mode 100644 index 0000000..d5bac2b --- /dev/null +++ b/src/components/explorer/SearchResults.tsx @@ -0,0 +1,152 @@ +import { FileText } from "lucide-react"; + +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; +import { cn } from "@/lib/utils"; +import type { SearchEntry } from "@/lib/searchEntries"; +import { basename } from "@/lib/path"; +import { EntryContextMenu } from "./EntryContextMenu"; +import { SearchSnippet } from "./SearchSnippet"; +import { SIDEBAR_ROW, sidebarRowState, fileOpenHandlers } from "./sidebarRow"; + +interface Props { + entries: SearchEntry[]; + searching: boolean; + error: string | undefined; + truncated: boolean; + selectedPath: string | undefined; + onSelect: (path: string) => void; + onOpenInNewTab: (path: string) => void; + onOpenInOtherPane?: (path: string) => void; + isPinned: (path: string) => boolean; + onTogglePin: (path: string) => void; +} + +export function SearchResults({ + entries, + searching, + error, + truncated, + selectedPath, + onSelect, + onOpenInNewTab, + onOpenInOtherPane, + isPinned, + onTogglePin, +}: Props) { + if (error) { + return ( + + + Search unavailable + {error} + + + ); + } + + if (entries.length === 0) { + return ( + + + {searching ? "Searching…" : "No matches"} + {!searching && ( + + Nothing matched in file names, titles, tags, or document contents. + + )} + + + ); + } + + return ( +
+
    + {entries.map((entry) => ( + + ))} +
+ {searching && ( + + Searching contents… + + )} + {truncated && ( + + This folder is too large to search completely. + + )} +
+ ); +} + +interface RowProps { + entry: SearchEntry; + selected: boolean; + onSelect: (path: string) => void; + onOpenInNewTab: (path: string) => void; + onOpenInOtherPane?: (path: string) => void; + pinned: boolean; + onTogglePin: (path: string) => void; +} + +function SearchResultRow({ + entry, + selected, + onSelect, + onOpenInNewTab, + onOpenInOtherPane, + pinned, + onTogglePin, +}: RowProps) { + const remaining = entry.matchedLines - entry.lines.length; + + return ( +
  • + + + +
  • + ); +} diff --git a/src/components/explorer/SearchSnippet.tsx b/src/components/explorer/SearchSnippet.tsx new file mode 100644 index 0000000..990534e --- /dev/null +++ b/src/components/explorer/SearchSnippet.tsx @@ -0,0 +1,35 @@ +import type { LineMatch } from "@/lib/contentSearch"; + +interface Props { + match: LineMatch; +} + +/** + * Renders a matched line from pre-split segments. The backend does the + * splitting because Rust byte offsets and JavaScript UTF-16 indices disagree on + * any document containing an accent or an emoji, and because rendering text + * nodes keeps the snippet free of injected markup. + */ +export function SearchSnippet({ match }: Props) { + return ( +
    + {match.line} +

    + {match.leadingEllipsis && "…"} + {match.segments.map((segment, index) => + segment.isMatch ? ( + + {segment.text} + + ) : ( + {segment.text} + ) + )} + {match.trailingEllipsis && "…"} +

    +
    + ); +} diff --git a/src/lib/searchEntries.test.ts b/src/lib/searchEntries.test.ts new file mode 100644 index 0000000..0e31563 --- /dev/null +++ b/src/lib/searchEntries.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; + +import { mergeSearchEntries } from "./searchEntries"; +import type { ContentHit } from "@/lib/contentSearch"; +import type { MarkdownFile } from "@/lib/scan"; + +function file(relPath: string, title?: string): MarkdownFile { + return { + path: `/lib/${relPath}`, + name: relPath.split("/").pop() ?? relPath, + relPath, + title, + tags: [], + size: 0, + }; +} + +function hit(relPath: string, score: number, lineCount = 1): ContentHit { + return { + path: `/lib/${relPath}`, + relPath, + score, + lines: Array.from({ length: lineCount }, (_, i) => ({ + line: i + 1, + segments: [{ text: "needle", isMatch: true }], + leadingEllipsis: false, + trailingEllipsis: false, + })), + matchedLines: lineCount, + }; +} + +describe("mergeSearchEntries", () => { + it("shows name matches before any content hits arrive", () => { + const entries = mergeSearchEntries([file("b.md"), file("a.md")], []); + + expect(entries.map((e) => e.relPath)).toEqual(["a.md", "b.md"]); + expect(entries.every((e) => e.lines.length === 0)).toBe(true); + }); + + it("ranks content hits above unscored name matches", () => { + const entries = mergeSearchEntries([file("a.md"), file("z.md")], [hit("z.md", 5)]); + + expect(entries.map((e) => e.relPath)).toEqual(["z.md", "a.md"]); + }); + + it("orders by score descending", () => { + const entries = mergeSearchEntries([], [hit("low.md", 1), hit("high.md", 9)]); + + expect(entries.map((e) => e.relPath)).toEqual(["high.md", "low.md"]); + }); + + it("breaks score ties by path so ordering is stable", () => { + const entries = mergeSearchEntries([], [hit("b.md", 3), hit("a.md", 3)]); + + expect(entries.map((e) => e.relPath)).toEqual(["a.md", "b.md"]); + }); + + it("does not list a file twice when it matches by both name and content", () => { + const entries = mergeSearchEntries([file("a.md")], [hit("a.md", 4)]); + + expect(entries).toHaveLength(1); + expect(entries[0].score).toBe(4); + expect(entries[0].lines).toHaveLength(1); + }); + + it("keeps the scanned title on a content hit", () => { + const entries = mergeSearchEntries([file("a.md", "Alpha Guide")], [hit("a.md", 4)]); + + expect(entries[0].title).toBe("Alpha Guide"); + }); + + it("includes a content hit for a file missing from the scan", () => { + const entries = mergeSearchEntries([], [hit("fresh.md", 2)]); + + expect(entries).toHaveLength(1); + expect(entries[0].relPath).toBe("fresh.md"); + expect(entries[0].title).toBeUndefined(); + }); + + it("carries the uncapped matched-line count", () => { + const hits = [hit("many.md", 3, 5)]; + hits[0].matchedLines = 12; + + expect(mergeSearchEntries([], hits)[0].matchedLines).toBe(12); + }); + + it("returns nothing for no matches", () => { + expect(mergeSearchEntries([], [])).toEqual([]); + }); +}); diff --git a/src/lib/searchEntries.ts b/src/lib/searchEntries.ts new file mode 100644 index 0000000..00fbed7 --- /dev/null +++ b/src/lib/searchEntries.ts @@ -0,0 +1,55 @@ +import type { ContentHit, LineMatch } from "@/lib/contentSearch"; +import type { MarkdownFile } from "@/lib/scan"; + +export interface SearchEntry { + path: string; + relPath: string; + title?: string; + score: number; + lines: LineMatch[]; + /** Matching lines in the file, which may exceed `lines.length`. */ + matchedLines: number; +} + +/** + * Merges the instant filename/title/tag matches with the ranked content hits + * that arrive a moment later. Name matches render immediately at score 0 and + * are re-ranked in place once the scored hits land, so the list fills in rather + * than flashing empty. + */ +export function mergeSearchEntries( + files: MarkdownFile[], + hits: ContentHit[] +): SearchEntry[] { + const titles = new Map(files.map((file) => [file.path, file.title])); + const entries = new Map(); + + for (const file of files) { + entries.set(file.path, { + path: file.path, + relPath: file.relPath, + title: file.title, + score: 0, + lines: [], + matchedLines: 0, + }); + } + + for (const hit of hits) { + entries.set(hit.path, { + path: hit.path, + relPath: hit.relPath, + title: titles.get(hit.path), + score: hit.score, + lines: hit.lines, + matchedLines: hit.matchedLines, + }); + } + + return [...entries.values()].sort(compareEntries); +} + +function compareEntries(a: SearchEntry, b: SearchEntry): number { + if (a.score !== b.score) return b.score - a.score; + return a.relPath.localeCompare(b.relPath); +} From a89b88a80cfc1339658f0466133c5bbe79a493eb Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Wed, 22 Jul 2026 11:16:45 +0800 Subject: [PATCH 03/79] feat(find): find within the open document Cmd+F opens a find bar scoped to the focused pane: all matches are highlighted, Enter and Shift+Enter step through them, the focused match is centred, and the count reads "3 of 17". The rendered document is React-managed DOM, so nothing may mutate it. A rehype pass injecting would also re-run the whole unified pipeline, including Shiki's WASM tokenizer, on every keystroke. Matches are therefore modelled as DOM Ranges and painted outside the document tree: - On webviews with the CSS Custom Highlight API, ranges are handed to the engine and nothing is created at all. - Older webviews get positioned rects in a container that is a sibling of the document. macOS 11 tops out at Safari 16.6.1 and can never have the Highlight API, so the fallback is required rather than optional. Both paths share one match engine and one stylesheet, so they look the same and only the paint step differs. Live ranges collapse silently when React replaces nodes, so a MutationObserver rebuilds them, ignoring the overlay's own writes to avoid a paint loop. --- src/components/document/FindBar.test.tsx | 113 ++++++++++ src/components/document/FindBar.tsx | 89 ++++++++ src/components/document/PaneView.tsx | 1 + src/components/document/TabScrollPane.tsx | 38 +++- src/hooks/useFindInDocument.test.ts | 166 ++++++++++++++ src/hooks/useFindInDocument.ts | 132 +++++++++++ src/index.css | 33 ++- src/lib/findHighlight.test.ts | 263 ++++++++++++++++++++++ src/lib/findHighlight.ts | 184 +++++++++++++++ 9 files changed, 1017 insertions(+), 2 deletions(-) create mode 100644 src/components/document/FindBar.test.tsx create mode 100644 src/components/document/FindBar.tsx create mode 100644 src/hooks/useFindInDocument.test.ts create mode 100644 src/hooks/useFindInDocument.ts create mode 100644 src/lib/findHighlight.test.ts create mode 100644 src/lib/findHighlight.ts diff --git a/src/components/document/FindBar.test.tsx b/src/components/document/FindBar.test.tsx new file mode 100644 index 0000000..113ed73 --- /dev/null +++ b/src/components/document/FindBar.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +import { FindBar } from "./FindBar"; +import type { FindInDocument } from "@/hooks/useFindInDocument"; + +const actions = { + setQuery: vi.fn(), + next: vi.fn(), + previous: vi.fn(), + show: vi.fn(), + hide: vi.fn(), +}; + +function find(overrides: Partial = {}): FindInDocument { + return { + open: true, + query: "needle", + matchCount: 17, + currentIndex: 2, + ...actions, + ...overrides, + }; +} + +describe("FindBar", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows the position within the matches", () => { + render(); + + expect(screen.getByText("3 of 17")).toBeInTheDocument(); + }); + + it("says when nothing matched", () => { + render(); + + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("shows no count before anything is typed", () => { + render(); + + expect(screen.queryByText("No results")).not.toBeInTheDocument(); + }); + + it("moves to the next match", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Next match")); + + expect(actions.next).toHaveBeenCalled(); + }); + + it("moves to the previous match", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Previous match")); + + expect(actions.previous).toHaveBeenCalled(); + }); + + it("disables navigation when there is nothing to step through", () => { + render(); + + expect(screen.getByLabelText("Next match")).toBeDisabled(); + expect(screen.getByLabelText("Previous match")).toBeDisabled(); + }); + + it("advances on Enter and steps back on Shift+Enter", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Find in document"); + + await user.click(input); + await user.keyboard("{Enter}"); + expect(actions.next).toHaveBeenCalledTimes(1); + + await user.keyboard("{Shift>}{Enter}{/Shift}"); + expect(actions.previous).toHaveBeenCalledTimes(1); + }); + + it("closes on Escape", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Find in document")); + await user.keyboard("{Escape}"); + + expect(actions.hide).toHaveBeenCalled(); + }); + + it("closes from the close button", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText("Close find")); + + expect(actions.hide).toHaveBeenCalled(); + }); + + it("reports typing to the caller", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Find in document"), "a"); + + expect(actions.setQuery).toHaveBeenCalledWith("a"); + }); +}); diff --git a/src/components/document/FindBar.tsx b/src/components/document/FindBar.tsx new file mode 100644 index 0000000..33ffc87 --- /dev/null +++ b/src/components/document/FindBar.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef } from "react"; +import { ChevronDown, ChevronUp, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { FindInDocument } from "@/hooks/useFindInDocument"; + +interface Props { + find: FindInDocument; +} + +export function FindBar({ find }: Props) { + const input = useRef(null); + + useEffect(() => { + input.current?.select(); + }, []); + + const hasQuery = find.query.trim().length > 0; + + return ( +
    + find.setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) find.previous(); + else find.next(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + find.hide(); + } + }} + placeholder="Find in document" + aria-label="Find in document" + className="h-7 w-48 border-0 shadow-none focus-visible:ring-0" + /> + + {matchLabel(hasQuery, find.matchCount, find.currentIndex)} + + + + +
    + ); +} + +function matchLabel(hasQuery: boolean, matchCount: number, currentIndex: number): string { + if (!hasQuery) return ""; + if (matchCount === 0) return "No results"; + return `${currentIndex + 1} of ${matchCount}`; +} diff --git a/src/components/document/PaneView.tsx b/src/components/document/PaneView.tsx index 7f3b93f..623219e 100644 --- a/src/components/document/PaneView.tsx +++ b/src/components/document/PaneView.tsx @@ -81,6 +81,7 @@ export function PaneView({ onScrollChange={pane.setScrollTop} onNavigate={pane.openInActive} onActiveRefChange={onActiveScrollElChange} + paneFocused={!splitActive || isActivePane} onAcceptPending={pane.acceptPending} onDismissPending={pane.dismissPending} onDiffViewModeChange={onDiffViewModeChange} diff --git a/src/components/document/TabScrollPane.tsx b/src/components/document/TabScrollPane.tsx index b9a38ad..fefd7e7 100644 --- a/src/components/document/TabScrollPane.tsx +++ b/src/components/document/TabScrollPane.tsx @@ -1,11 +1,16 @@ -import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import type { MarkdownFile } from "@/lib/scan"; import type { ViewSettings } from "@/lib/storage"; import type { Tab } from "@/hooks/useTabs"; import { parseFrontmatter } from "@/lib/scan"; +import { useFindInDocument } from "@/hooks/useFindInDocument"; +import { matchShortcut, parseShortcut } from "@/lib/shortcuts"; import { DocumentView } from "./DocumentView"; import { ExternalChangeBanner } from "./ExternalChangeBanner"; +import { FindBar } from "./FindBar"; + +const FIND_SHORTCUT = parseShortcut("Mod+F"); interface Props { tab: Tab; @@ -17,6 +22,8 @@ interface Props { onScrollChange: (path: string, value: number) => void; onNavigate: (path: string) => void; onActiveRefChange?: (el: HTMLElement | null) => void; + /** False when a split is showing and the other pane holds focus. */ + paneFocused: boolean; onAcceptPending: (id: string) => void; onDismissPending: (id: string) => void; onDiffViewModeChange: (mode: ViewSettings["diffViewMode"]) => void; @@ -37,6 +44,7 @@ export function TabScrollPane({ onScrollChange, onNavigate, onActiveRefChange, + paneFocused, onAcceptPending, onDismissPending, onDiffViewModeChange, @@ -52,6 +60,25 @@ export function TabScrollPane({ ); const ref = useRef(null); const restoredRef = useRef(false); + const [scrollEl, setScrollEl] = useState(null); + + useEffect(() => setScrollEl(ref.current), []); + + // Find applies to the rendered view only; the editor brings its own. + const findable = active && paneFocused && tab.draft === undefined; + const find = useFindInDocument(findable ? scrollEl : null, findable); + const showFind = find.show; + + useEffect(() => { + if (!findable || !FIND_SHORTCUT) return; + const onKey = (e: KeyboardEvent) => { + if (!matchShortcut(e, FIND_SHORTCUT)) return; + e.preventDefault(); + showFind(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [findable, showFind]); useEffect(() => { restoredRef.current = false; @@ -120,6 +147,15 @@ export function TabScrollPane({ onScrollChange(tab.path, e.currentTarget.scrollTop); }} > + {/* Sticky with no height so the bar stays pinned while the document + scrolls beneath it without displacing the content. */} + {find.open && ( +
    +
    + +
    +
    + )} {tab.pendingContent && pendingBody !== undefined && ( + ({ top: 0, left: 0, width: 400, height: 300 }) as DOMRect; + scroller.scrollTo = vi.fn(); + document.body.appendChild(scroller); + return scroller; +} + +// jsdom has no layout, so every range measures zero. Give ranges a size where +// the assertion depends on it. +function stubRangeRects() { + Range.prototype.getBoundingClientRect = () => + ({ top: 500, left: 0, width: 20, height: 10 }) as DOMRect; + Range.prototype.getClientRects = () => + [{ top: 500, left: 0, width: 20, height: 10 }] as unknown as DOMRectList; +} + +describe("useFindInDocument", () => { + let scroller: HTMLElement; + + beforeEach(() => { + stubRangeRects(); + scroller = makeScroller("

    alpha needle beta

    another needle here

    "); + }); + + afterEach(() => { + scroller.remove(); + vi.restoreAllMocks(); + }); + + it("starts closed and finds nothing", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + + expect(result.current.open).toBe(false); + expect(result.current.matchCount).toBe(0); + expect(result.current.currentIndex).toBe(-1); + }); + + it("counts matches once opened and queried", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + + expect(result.current.matchCount).toBe(2); + expect(result.current.currentIndex).toBe(0); + }); + + it("reports no matches for a query that is absent", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + + act(() => result.current.show()); + act(() => result.current.setQuery("absent")); + + expect(result.current.matchCount).toBe(0); + expect(result.current.currentIndex).toBe(-1); + }); + + it("wraps forward past the last match", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + + act(() => result.current.next()); + expect(result.current.currentIndex).toBe(1); + + act(() => result.current.next()); + expect(result.current.currentIndex).toBe(0); + }); + + it("wraps backward past the first match", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + + act(() => result.current.previous()); + + expect(result.current.currentIndex).toBe(1); + }); + + it("does not step when there is nothing to step through", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + act(() => result.current.show()); + act(() => result.current.setQuery("absent")); + + act(() => result.current.next()); + + expect(result.current.currentIndex).toBe(-1); + }); + + it("scrolls the focused match into view", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + + expect(scroller.scrollTo).toHaveBeenCalled(); + }); + + it("clears the query and matches when hidden", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + + act(() => result.current.hide()); + + expect(result.current.open).toBe(false); + expect(result.current.query).toBe(""); + expect(result.current.matchCount).toBe(0); + }); + + it("leaves the document untouched while painting", () => { + const before = scroller.querySelector("p")?.outerHTML; + const { result } = renderHook(() => useFindInDocument(scroller, true)); + + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + + expect(scroller.querySelector("p")?.outerHTML).toBe(before); + }); + + it("closes when the pane loses focus", () => { + const { result, rerender } = renderHook( + ({ enabled }) => useFindInDocument(scroller, enabled), + { initialProps: { enabled: true } } + ); + act(() => result.current.show()); + expect(result.current.open).toBe(true); + + rerender({ enabled: false }); + + expect(result.current.open).toBe(false); + }); + + it("finds nothing without a scroller", () => { + const { result } = renderHook(() => useFindInDocument(null, true)); + + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + + expect(result.current.matchCount).toBe(0); + }); + + it("recounts when the document content changes", async () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + act(() => result.current.show()); + act(() => result.current.setQuery("needle")); + expect(result.current.matchCount).toBe(2); + + await act(async () => { + const extra = document.createElement("p"); + extra.textContent = "a third needle"; + scroller.appendChild(extra); + await Promise.resolve(); + }); + + expect(result.current.matchCount).toBe(3); + }); +}); diff --git a/src/hooks/useFindInDocument.ts b/src/hooks/useFindInDocument.ts new file mode 100644 index 0000000..499df98 --- /dev/null +++ b/src/hooks/useFindInDocument.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { findRanges } from "@/lib/findMatches"; +import { + createHighlightPainter, + FIND_OVERLAY_ATTR, + type HighlightPainter, +} from "@/lib/findHighlight"; + +export interface FindInDocument { + open: boolean; + query: string; + matchCount: number; + /** 0-based position of the focused match, or -1 when there are none. */ + currentIndex: number; + setQuery: (query: string) => void; + next: () => void; + previous: () => void; + show: () => void; + hide: () => void; +} + +export function useFindInDocument( + scroller: HTMLElement | null, + enabled: boolean +): FindInDocument { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [matchCount, setMatchCount] = useState(0); + const [currentIndex, setCurrentIndex] = useState(-1); + + const ranges = useRef([]); + const painter = useRef(undefined); + + useEffect(() => { + if (!scroller) return; + painter.current = createHighlightPainter(scroller); + return () => { + painter.current?.destroy(); + painter.current = undefined; + }; + }, [scroller]); + + const recompute = useCallback(() => { + if (!scroller || !open || !query.trim()) { + ranges.current = []; + setMatchCount(0); + setCurrentIndex(-1); + return; + } + ranges.current = findRanges(scroller, query); + setMatchCount(ranges.current.length); + setCurrentIndex(ranges.current.length > 0 ? 0 : -1); + }, [scroller, open, query]); + + useEffect(() => { + recompute(); + }, [recompute]); + + // Live ranges do not error when React replaces the nodes they point into, + // they silently collapse. Rebuilding on any document mutation is the only + // reliable signal, since every render path here can swap the subtree. + useEffect(() => { + if (!scroller || !open) return; + const observer = new MutationObserver((records) => { + if (records.every(isOwnPaint)) return; + recompute(); + }); + observer.observe(scroller, { childList: true, subtree: true, characterData: true }); + return () => observer.disconnect(); + }, [scroller, open, recompute]); + + useEffect(() => { + if (!painter.current) return; + painter.current.paint(ranges.current, currentIndex); + }, [matchCount, currentIndex]); + + useEffect(() => { + if (!scroller) return; + const range = ranges.current[currentIndex]; + if (range) scrollRangeIntoView(scroller, range); + }, [scroller, currentIndex]); + + useEffect(() => { + if (enabled) return; + setOpen(false); + }, [enabled]); + + const step = useCallback((delta: number) => { + setCurrentIndex((index) => { + const count = ranges.current.length; + if (count === 0) return -1; + return (index + delta + count) % count; + }); + }, []); + + const hide = useCallback(() => { + setOpen(false); + setQuery(""); + ranges.current = []; + setMatchCount(0); + setCurrentIndex(-1); + painter.current?.clear(); + }, []); + + return { + open, + query, + matchCount, + currentIndex, + setQuery, + next: useCallback(() => step(1), [step]), + previous: useCallback(() => step(-1), [step]), + show: useCallback(() => setOpen(true), []), + hide, + }; +} + +function isOwnPaint(record: MutationRecord): boolean { + return ( + record.target instanceof Element && + record.target.closest(`[${FIND_OVERLAY_ATTR}]`) !== null + ); +} + +function scrollRangeIntoView(scroller: HTMLElement, range: Range): void { + const target = range.getBoundingClientRect(); + if (target.width === 0 && target.height === 0) return; + const view = scroller.getBoundingClientRect(); + const top = target.top - view.top + scroller.scrollTop - view.height / 2; + scroller.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); +} diff --git a/src/index.css b/src/index.css index 4cc6aca..24118ad 100644 --- a/src/index.css +++ b/src/index.css @@ -198,4 +198,35 @@ } .prose li::marker { color: var(--muted-foreground); -} \ No newline at end of file +} +/* Find-in-document. Two painters render the same matches: the CSS Custom + Highlight API where the webview supports it, and positioned rects on older + macOS webviews. Keep the two visually identical. */ +::highlight(docsreader-find-all) { + background-color: color-mix(in oklch, var(--primary) 28%, transparent); + color: var(--foreground); +} + +::highlight(docsreader-find-current) { + background-color: color-mix(in oklch, var(--primary) 60%, transparent); + color: var(--foreground); +} + +.find-overlay { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 5; +} + +.find-overlay-mark { + position: absolute; + border-radius: var(--radius-sm); + background-color: color-mix(in oklch, var(--primary) 28%, transparent); +} + +.find-overlay-mark.is-current { + background-color: color-mix(in oklch, var(--primary) 60%, transparent); + outline: 1px solid var(--primary); +} diff --git a/src/lib/findHighlight.test.ts b/src/lib/findHighlight.test.ts new file mode 100644 index 0000000..9152e0a --- /dev/null +++ b/src/lib/findHighlight.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { + createHighlightPainter, + supportsHighlightApi, + FIND_OVERLAY_ATTR, + HIGHLIGHT_ALL, + HIGHLIGHT_CURRENT, + MAX_PAINTED_RANGES, +} from "./findHighlight"; + +interface FakeHighlight { + priority: number; + ranges: Range[]; +} + +const globals = globalThis as Record; + +function installHighlightApi() { + const registry = new Map(); + globals.Highlight = class { + priority = 0; + ranges: Range[]; + constructor(...ranges: Range[]) { + this.ranges = ranges; + } + }; + globals.CSS = { highlights: registry }; + return registry; +} + +function removeHighlightApi() { + delete globals.Highlight; + globals.CSS = {}; +} + +function rect(top: number, left: number, width = 10, height = 4): DOMRect { + return { top, left, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) }; +} + +function fakeRange(rects: DOMRect[]): Range { + const range: Partial = { getClientRects: () => rects as unknown as DOMRectList }; + return range as Range; +} + +function makeScroller(): HTMLElement { + const scroller = document.createElement("div"); + scroller.getBoundingClientRect = () => rect(100, 50, 500, 400); + Object.defineProperty(scroller, "scrollTop", { value: 20, writable: true }); + Object.defineProperty(scroller, "scrollLeft", { value: 5, writable: true }); + document.body.appendChild(scroller); + return scroller; +} + +function overlayMarks(scroller: HTMLElement): Element[] { + return [...scroller.querySelectorAll(".find-overlay-mark")]; +} + +describe("supportsHighlightApi", () => { + afterEach(() => removeHighlightApi()); + + it("is true when the webview exposes the registry and constructor", () => { + installHighlightApi(); + expect(supportsHighlightApi()).toBe(true); + }); + + it("is false on a webview without the API", () => { + removeHighlightApi(); + expect(supportsHighlightApi()).toBe(false); + }); +}); + +describe("registry painter", () => { + let registry: Map; + let scroller: HTMLElement; + + beforeEach(() => { + registry = installHighlightApi(); + scroller = makeScroller(); + }); + + afterEach(() => { + removeHighlightApi(); + scroller.remove(); + }); + + it("is chosen when the API is available", () => { + createHighlightPainter(scroller).paint([fakeRange([rect(0, 0)])], 0); + expect(registry.size).toBeGreaterThan(0); + expect(overlayMarks(scroller)).toHaveLength(0); + }); + + it("registers the current match above the others", () => { + const painter = createHighlightPainter(scroller); + painter.paint([fakeRange([rect(0, 0)]), fakeRange([rect(9, 0)])], 1); + + const all = registry.get(HIGHLIGHT_ALL); + const current = registry.get(HIGHLIGHT_CURRENT); + expect(all).toBeDefined(); + expect(current).toBeDefined(); + expect(current!.priority).toBeGreaterThan(all!.priority); + expect(current!.ranges).toHaveLength(1); + }); + + it("leaves the document untouched", () => { + const doc = document.createElement("article"); + doc.innerHTML = "

    hello

    "; + scroller.appendChild(doc); + const before = doc.innerHTML; + + createHighlightPainter(scroller).paint([fakeRange([rect(0, 0)])], 0); + + expect(doc.innerHTML).toBe(before); + }); + + it("removes both registrations on destroy", () => { + const painter = createHighlightPainter(scroller); + painter.paint([fakeRange([rect(0, 0)]), fakeRange([rect(9, 0)])], 0); + painter.destroy(); + + expect(registry.has(HIGHLIGHT_ALL)).toBe(false); + expect(registry.has(HIGHLIGHT_CURRENT)).toBe(false); + }); + + it("stays reusable after clear", () => { + const painter = createHighlightPainter(scroller); + painter.paint([fakeRange([rect(0, 0)])], 0); + painter.clear(); + expect(registry.size).toBe(0); + + painter.paint([fakeRange([rect(0, 0)])], 0); + expect(registry.size).toBeGreaterThan(0); + }); + + it("paints nothing for no matches", () => { + createHighlightPainter(scroller).paint([], -1); + expect(registry.size).toBe(0); + }); +}); + +describe("overlay painter", () => { + let scroller: HTMLElement; + + beforeEach(() => { + removeHighlightApi(); + scroller = makeScroller(); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + scroller.remove(); + }); + + it("is chosen when the API is missing", () => { + createHighlightPainter(scroller).paint([fakeRange([rect(0, 0)])], 0); + expect(overlayMarks(scroller)).toHaveLength(1); + }); + + it("paints one element per client rect so wrapped matches are covered", () => { + createHighlightPainter(scroller).paint([fakeRange([rect(0, 0), rect(20, 0)])], 0); + + expect(overlayMarks(scroller)).toHaveLength(2); + }); + + it("converts viewport rects into scroller content coordinates", () => { + createHighlightPainter(scroller).paint([fakeRange([rect(140, 80)])], 0); + + const mark = overlayMarks(scroller)[0]; + expect(mark).toBeInstanceOf(HTMLElement); + const style = (mark as HTMLElement).style; + // top: 140 - 100 + scrollTop 20, left: 80 - 50 + scrollLeft 5 + expect(style.top).toBe("60px"); + expect(style.left).toBe("35px"); + }); + + it("distinguishes the current match", () => { + createHighlightPainter(scroller).paint( + [fakeRange([rect(0, 0)]), fakeRange([rect(20, 0)])], + 1 + ); + + const current = scroller.querySelectorAll(".find-overlay-mark.is-current"); + expect(current).toHaveLength(1); + }); + + it("caps painted matches but always paints the current one", () => { + const ranges = Array.from({ length: MAX_PAINTED_RANGES + 50 }, (_, i) => + fakeRange([rect(i, 0)]) + ); + const currentIndex = ranges.length - 1; + + createHighlightPainter(scroller).paint(ranges, currentIndex); + + expect(overlayMarks(scroller)).toHaveLength(MAX_PAINTED_RANGES + 1); + expect(scroller.querySelectorAll(".find-overlay-mark.is-current")).toHaveLength(1); + }); + + it("marks every element it owns so consumers can ignore its writes", () => { + createHighlightPainter(scroller).paint([fakeRange([rect(0, 0), rect(9, 0)])], 0); + + const container = scroller.querySelector(`[${FIND_OVERLAY_ATTR}]`); + expect(container).not.toBeNull(); + for (const mark of overlayMarks(scroller)) { + expect(mark.closest(`[${FIND_OVERLAY_ATTR}]`)).not.toBeNull(); + } + }); + + it("leaves the document untouched", () => { + const doc = document.createElement("article"); + doc.innerHTML = "

    hello

    "; + scroller.appendChild(doc); + const before = doc.innerHTML; + + createHighlightPainter(scroller).paint([fakeRange([rect(0, 0)])], 0); + + expect(doc.innerHTML).toBe(before); + }); + + it("repaints on scroll", () => { + const painter = createHighlightPainter(scroller); + painter.paint([fakeRange([rect(0, 0)])], 0); + expect(overlayMarks(scroller)).toHaveLength(1); + + scroller.dispatchEvent(new Event("scroll")); + + expect(overlayMarks(scroller)).toHaveLength(1); + painter.destroy(); + }); + + it("removes its container on destroy", () => { + const painter = createHighlightPainter(scroller); + painter.paint([fakeRange([rect(0, 0)])], 0); + painter.destroy(); + + expect(scroller.querySelector(`[${FIND_OVERLAY_ATTR}]`)).toBeNull(); + }); + + it("stays reusable after clear", () => { + const painter = createHighlightPainter(scroller); + painter.paint([fakeRange([rect(0, 0)])], 0); + painter.clear(); + expect(overlayMarks(scroller)).toHaveLength(0); + + painter.paint([fakeRange([rect(0, 0)])], 0); + expect(overlayMarks(scroller)).toHaveLength(1); + }); + + it("does not throw when the current index is out of range", () => { + const painter = createHighlightPainter(scroller); + expect(() => painter.paint([fakeRange([rect(0, 0)])], 9)).not.toThrow(); + expect(overlayMarks(scroller)).toHaveLength(1); + }); + + it("paints nothing for no matches", () => { + createHighlightPainter(scroller).paint([], -1); + expect(overlayMarks(scroller)).toHaveLength(0); + }); +}); diff --git a/src/lib/findHighlight.ts b/src/lib/findHighlight.ts new file mode 100644 index 0000000..9ce9eaa --- /dev/null +++ b/src/lib/findHighlight.ts @@ -0,0 +1,184 @@ +export interface HighlightPainter { + paint(ranges: Range[], currentIndex: number): void; + clear(): void; + destroy(): void; +} + +/** + * Marks every element the overlay adapter owns. Consumers watching the + * scroller for React re-renders filter these out, otherwise the painter's own + * writes would retrigger the recompute that produced them. + */ +export const FIND_OVERLAY_ATTR = "data-find-overlay"; + +export const HIGHLIGHT_ALL = "docsreader-find-all"; +export const HIGHLIGHT_CURRENT = "docsreader-find-current"; + +/** + * Painting every rect of a large result set costs more than it helps; beyond + * this many the remaining matches stay navigable but unpainted. + */ +export const MAX_PAINTED_RANGES = 300; + +interface HighlightRegistry { + set(name: string, highlight: HighlightLike): void; + delete(name: string): void; +} + +interface HighlightLike { + priority: number; +} + +interface HighlightConstructor { + new (...ranges: Range[]): HighlightLike; +} + +interface HighlightCapableCss { + highlights: HighlightRegistry; +} + +function highlightRegistry(): HighlightRegistry | undefined { + if (typeof CSS === "undefined") return undefined; + const candidate: unknown = CSS; + if (typeof candidate !== "object" || candidate === null) return undefined; + if (!("highlights" in candidate)) return undefined; + const registry = (candidate as HighlightCapableCss).highlights; + if (typeof registry?.set !== "function") return undefined; + return registry; +} + +function highlightConstructor(): HighlightConstructor | undefined { + const scope: Record = globalThis as Record; + const ctor = scope.Highlight; + return typeof ctor === "function" ? (ctor as HighlightConstructor) : undefined; +} + +export function supportsHighlightApi(): boolean { + return highlightRegistry() !== undefined && highlightConstructor() !== undefined; +} + +export function createHighlightPainter(scroller: HTMLElement): HighlightPainter { + const registry = highlightRegistry(); + const Highlight = highlightConstructor(); + if (registry && Highlight) { + return createRegistryPainter(registry, Highlight); + } + return createOverlayPainter(scroller); +} + +function splitCurrent( + ranges: Range[], + currentIndex: number +): { current: Range[]; rest: Range[] } { + const current = ranges[currentIndex]; + if (!current) return { current: [], rest: ranges }; + return { current: [current], rest: ranges.filter((_, i) => i !== currentIndex) }; +} + +// Zero DOM mutation: the ranges are handed to the engine, which paints them as +// an overlay outside the document tree, so React never sees a change. +function createRegistryPainter( + registry: HighlightRegistry, + Highlight: HighlightConstructor +): HighlightPainter { + const clear = () => { + registry.delete(HIGHLIGHT_ALL); + registry.delete(HIGHLIGHT_CURRENT); + }; + + return { + paint(ranges, currentIndex) { + clear(); + if (ranges.length === 0) return; + const { current, rest } = splitCurrent(ranges, currentIndex); + if (rest.length > 0) { + registry.set(HIGHLIGHT_ALL, new Highlight(...rest)); + } + if (current.length > 0) { + const highlight = new Highlight(...current); + // Ties break by registration recency, which is too implicit to rely on. + highlight.priority = 1; + registry.set(HIGHLIGHT_CURRENT, highlight); + } + }, + clear, + destroy: clear, + }; +} + +function createOverlayPainter(scroller: HTMLElement): HighlightPainter { + const container = scroller.ownerDocument.createElement("div"); + container.setAttribute(FIND_OVERLAY_ATTR, "true"); + container.className = "find-overlay"; + scroller.appendChild(container); + + let painted: Range[] = []; + let paintedCurrent = -1; + let frame = 0; + + const draw = () => { + container.replaceChildren(); + if (painted.length === 0) return; + // Absolutely positioned children of a scrolling container are placed + // against its padding box, so they scroll with the content rather than + // sticking to the viewport. + const origin = scroller.getBoundingClientRect(); + const { current, rest } = splitCurrent(painted, paintedCurrent); + for (const range of rest.slice(0, MAX_PAINTED_RANGES)) { + appendRects(container, scroller, origin, range, false); + } + for (const range of current) { + appendRects(container, scroller, origin, range, true); + } + }; + + const scheduleDraw = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + draw(); + }); + }; + + scroller.addEventListener("scroll", scheduleDraw, { passive: true }); + const resizeObserver = new ResizeObserver(scheduleDraw); + resizeObserver.observe(scroller); + + return { + paint(ranges, currentIndex) { + painted = ranges; + paintedCurrent = currentIndex; + draw(); + }, + clear() { + painted = []; + paintedCurrent = -1; + container.replaceChildren(); + }, + destroy() { + if (frame) cancelAnimationFrame(frame); + scroller.removeEventListener("scroll", scheduleDraw); + resizeObserver.disconnect(); + container.remove(); + }, + }; +} + +function appendRects( + container: HTMLElement, + scroller: HTMLElement, + origin: DOMRect, + range: Range, + isCurrent: boolean +): void { + for (const rect of Array.from(range.getClientRects())) { + const mark = container.ownerDocument.createElement("div"); + mark.setAttribute(FIND_OVERLAY_ATTR, "true"); + mark.className = isCurrent ? "find-overlay-mark is-current" : "find-overlay-mark"; + mark.style.top = `${rect.top - origin.top + scroller.scrollTop}px`; + mark.style.left = `${rect.left - origin.left + scroller.scrollLeft}px`; + mark.style.width = `${rect.width}px`; + mark.style.height = `${rect.height}px`; + container.appendChild(mark); + } +} From 307c7c31dbc590cf005a06da05ba552c37290de2 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Wed, 22 Jul 2026 11:17:36 +0800 Subject: [PATCH 04/79] docs: record search and find-in-document --- README.md | 1 + docs/FEATURES.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5521464..ce5274f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Needs `docsreader-mcp` on your PATH and `jq`. Full setup in the [plugin README]( | **Interactive checklists** | Toggle any checkbox from the rendered view; the change writes back to the file | | **Five lenses** | Tree, Recent, Tags, Pinned, and a Tasks kanban board over one library | | **Split view** | Two docs side-by-side or stacked, each with its own tabs and scroll | +| **Full-text search** | Search names, tags, and document contents; Cmd+F finds within the open doc | | **Open with** | Double-click a `.md` in Finder or "Open With DocsReader" to jump straight to a file or folder | | **Task board** | To Do / In Progress / Done with drag-to-advance and acceptance-criteria progress, consistent with the MCP | | **Agent-aware** | Open docs reload live as agents write; on-disk changes surface a diff; git status shows in the tree | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a9c7183..cc44f19 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -26,7 +26,8 @@ The full feature list for DocsReader. The [README](../README.md#features) shows - **Backlinks:** the sidebar lists every doc that links to the one you are reading, grouped by folder - **Tabs:** many docs open at once; scroll position remembered per tab - **Split view:** read two docs side-by-side or stacked; each pane keeps its own tabs, scroll, and external-change banner. Toggle from the header, drag the splitter to resize, or use Cmd+\ (horizontal), Cmd+Shift+\ (vertical), Cmd+1 / Cmd+2 to focus a pane. "Open in other pane" lives in the file context menu. -- **Search:** filename, path, frontmatter title, or tag +- **Search:** filename, path, frontmatter title, tag, or the text inside the documents themselves. Results are ranked and show the matching line in context +- **Find in document:** Cmd+F highlights every match in the open doc, with next/previous and a running count - **Sticky favorites:** pin individual files to the top of any workspace - **Clutter rules:** glob patterns silently exclude files and folders from the explorer From a2502e2e54b116e5f3599002a83811b36acfc32b Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Wed, 22 Jul 2026 11:38:39 +0800 Subject: [PATCH 05/79] fix(find): repaint when the ranges change, not the match count Paint was keyed on matchCount and currentIndex, but the ranges live in a ref and so cannot drive an effect. Editing a query into one that matched in the same number of places left the previous ranges on screen: typing "board" kept painting the "bo" ranges, because both match five times. Found by running the app; the unit tests missed it because each one moved between queries with different match counts, so the effect always happened to re-run. A revision counter now signals every recompute, and two regression tests cover a query that lengthens and one that shortens without changing the count. --- src/hooks/useFindInDocument.test.ts | 41 +++++++++++++++++++++++++++++ src/hooks/useFindInDocument.ts | 8 +++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/hooks/useFindInDocument.test.ts b/src/hooks/useFindInDocument.test.ts index 12686ca..6c3534c 100644 --- a/src/hooks/useFindInDocument.test.ts +++ b/src/hooks/useFindInDocument.test.ts @@ -2,6 +2,22 @@ import { act, renderHook } from "@testing-library/react"; import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; import { useFindInDocument } from "./useFindInDocument"; +import { createHighlightPainter } from "@/lib/findHighlight"; + +vi.mock("@/lib/findHighlight", async () => { + const actual = + await vi.importActual("@/lib/findHighlight"); + return { ...actual, createHighlightPainter: vi.fn() }; +}); + +const painted: string[][] = []; +const painter = { + paint: vi.fn((ranges: Range[]) => { + painted.push(ranges.map((r) => r.toString())); + }), + clear: vi.fn(), + destroy: vi.fn(), +}; function makeScroller(html: string): HTMLElement { const scroller = document.createElement("div"); @@ -27,6 +43,9 @@ describe("useFindInDocument", () => { beforeEach(() => { stubRangeRects(); + painted.length = 0; + painter.paint.mockClear(); + vi.mocked(createHighlightPainter).mockReturnValue(painter); scroller = makeScroller("

    alpha needle beta

    another needle here

    "); }); @@ -148,6 +167,28 @@ describe("useFindInDocument", () => { expect(result.current.matchCount).toBe(0); }); + it("repaints a lengthened query even when the match count is unchanged", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + act(() => result.current.show()); + + act(() => result.current.setQuery("need")); + act(() => result.current.setQuery("needle")); + + // Both queries match the same two places, so a repaint keyed on the count + // alone would leave the shorter ranges on screen. + expect(painted[painted.length - 1]).toEqual(["needle", "needle"]); + }); + + it("repaints when the query narrows to the same count", () => { + const { result } = renderHook(() => useFindInDocument(scroller, true)); + act(() => result.current.show()); + + act(() => result.current.setQuery("needle")); + act(() => result.current.setQuery("needl")); + + expect(painted[painted.length - 1]).toEqual(["needl", "needl"]); + }); + it("recounts when the document content changes", async () => { const { result } = renderHook(() => useFindInDocument(scroller, true)); act(() => result.current.show()); diff --git a/src/hooks/useFindInDocument.ts b/src/hooks/useFindInDocument.ts index 499df98..93e6512 100644 --- a/src/hooks/useFindInDocument.ts +++ b/src/hooks/useFindInDocument.ts @@ -28,6 +28,10 @@ export function useFindInDocument( const [query, setQuery] = useState(""); const [matchCount, setMatchCount] = useState(0); const [currentIndex, setCurrentIndex] = useState(-1); + // The ranges live in a ref, so nothing about them can drive an effect. This + // counter is the repaint signal: without it, editing a query that happens to + // keep the same match count would leave the previous ranges on screen. + const [revision, setRevision] = useState(0); const ranges = useRef([]); const painter = useRef(undefined); @@ -46,11 +50,13 @@ export function useFindInDocument( ranges.current = []; setMatchCount(0); setCurrentIndex(-1); + setRevision((r) => r + 1); return; } ranges.current = findRanges(scroller, query); setMatchCount(ranges.current.length); setCurrentIndex(ranges.current.length > 0 ? 0 : -1); + setRevision((r) => r + 1); }, [scroller, open, query]); useEffect(() => { @@ -73,7 +79,7 @@ export function useFindInDocument( useEffect(() => { if (!painter.current) return; painter.current.paint(ranges.current, currentIndex); - }, [matchCount, currentIndex]); + }, [revision, currentIndex]); useEffect(() => { if (!scroller) return; From d92d7c648d13655ed221488013763774614f63d3 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Wed, 22 Jul 2026 11:46:12 +0800 Subject: [PATCH 06/79] feat(search): restrict search to names, contents, or tags Adds a scope to the core query so a caller can decide which fields count. Defined once in Rust and mirrored once in TypeScript, since the pair is a closed set that would otherwise drift. The names scope matches the whole workspace-relative path, so a folder name is searchable the way a file name is. --- src-tauri/core/src/search.rs | 188 ++++++++++++++++++++++++++++----- src-tauri/src/tauri_api/mod.rs | 4 +- src/hooks/useContentSearch.ts | 9 +- src/lib/contentSearch.ts | 17 ++- 4 files changed, 182 insertions(+), 36 deletions(-) diff --git a/src-tauri/core/src/search.rs b/src-tauri/core/src/search.rs index a7b8076..5be5987 100644 --- a/src-tauri/core/src/search.rs +++ b/src-tauri/core/src/search.rs @@ -3,7 +3,7 @@ use std::path::Path; use rayon::prelude::*; use regex::{Regex, RegexBuilder}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::error::{CoreError, ErrorCode}; use crate::frontmatter::split_frontmatter; @@ -83,13 +83,40 @@ impl SearchAbort for NeverAborts { } } +/// Which fields a query is allowed to match. Mirrored in TypeScript as +/// SEARCH_SCOPES; keep the two in step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SearchScope { + #[default] + All, + Names, + Content, + Tags, +} + +impl SearchScope { + fn matches_name(self) -> bool { + matches!(self, Self::All | Self::Names) + } + + fn matches_content(self) -> bool { + matches!(self, Self::All | Self::Content) + } + + fn matches_tag(self) -> bool { + matches!(self, Self::All | Self::Tags) + } +} + pub struct ContentQuery { terms: Vec, + scope: SearchScope, } impl ContentQuery { /// Returns `None` for a query with no searchable terms. - pub fn parse(query: &str, case_sensitive: bool) -> Option { + pub fn parse(query: &str, case_sensitive: bool, scope: SearchScope) -> Option { let terms: Vec = query .split_whitespace() .filter_map(|term| build_term(term, case_sensitive)) @@ -97,7 +124,7 @@ impl ContentQuery { if terms.is_empty() { return None; } - Some(Self { terms }) + Some(Self { terms, scope }) } } @@ -133,7 +160,7 @@ pub fn search_content( if abort.is_aborted() { return None; } - search_file(root, entry.path(), &query.terms) + search_file(root, entry.path(), query) }) .collect(); @@ -150,46 +177,60 @@ pub fn search_content( }) } -fn search_file(root: &Path, path: &Path, terms: &[Regex]) -> Option { +struct DocFields<'a> { + title: Option<&'a str>, + tags: &'a [String], + /// File stem plus the workspace-relative path, so a query can match a + /// folder name the way it matches a file name. + name: &'a str, + body: &'a str, +} + +fn search_file(root: &Path, path: &Path, query: &ContentQuery) -> Option { // A file deleted or made unreadable between the walk and the read is simply // not a result; the next scan reconciles the tree. let content = std::fs::read_to_string(path).ok()?; let (_, body) = split_frontmatter(&content); let (title, tags) = parse_meta(&content); - let slug = path.file_stem()?.to_string_lossy().to_string(); - + let rel_path = relative_path(root, path); + + let fields = DocFields { + title: title.as_deref(), + tags: &tags, + name: &rel_path, + body, + }; let score = combine_terms( - terms + query + .terms .iter() - .map(|term| field_hits(term, title.as_deref(), &tags, &slug, body)), + .map(|term| field_hits(term, &fields, query.scope)), ); if score == 0 { return None; } - let (lines, matched_lines) = matching_lines(body, first_body_line(&content, body), terms); + let (lines, matched_lines) = if query.scope.matches_content() { + matching_lines(body, first_body_line(&content, body), &query.terms) + } else { + (Vec::new(), 0) + }; Some(ContentHit { path: path.to_string_lossy().to_string(), - rel_path: relative_path(root, path), + rel_path, score, lines, matched_lines, }) } -fn field_hits( - term: &Regex, - title: Option<&str>, - tags: &[String], - slug: &str, - body: &str, -) -> FieldHits { +fn field_hits(term: &Regex, fields: &DocFields<'_>, scope: SearchScope) -> FieldHits { FieldHits { - title: title.is_some_and(|t| term.is_match(t)), - tag: tags.iter().any(|tag| term.is_match(tag)), - slug: term.is_match(slug), - content: term.is_match(body), + title: scope.matches_name() && fields.title.is_some_and(|t| term.is_match(t)), + tag: scope.matches_tag() && fields.tags.iter().any(|tag| term.is_match(tag)), + slug: scope.matches_name() && term.is_match(fields.name), + content: scope.matches_content() && term.is_match(fields.body), } } @@ -323,7 +364,11 @@ mod tests { } fn search(root: &Path, query: &str) -> Vec { - let parsed = ContentQuery::parse(query, false).expect("query has terms"); + search_scoped(root, query, SearchScope::All) + } + + fn search_scoped(root: &Path, query: &str, scope: SearchScope) -> Vec { + let parsed = ContentQuery::parse(query, false, scope).expect("query has terms"); search_content(root, &parsed, &NeverAborts).unwrap().hits } @@ -452,7 +497,7 @@ mod tests { assert_eq!(search(&dir, "gateway").len(), 1); - let sensitive = ContentQuery::parse("gateway", true).unwrap(); + let sensitive = ContentQuery::parse("gateway", true, SearchScope::All).unwrap(); let hits = search_content(&dir, &sensitive, &NeverAborts).unwrap().hits; assert!(hits.is_empty()); let _ = std::fs::remove_dir_all(&dir); @@ -480,7 +525,7 @@ mod tests { let dir = test_dir("search_overlap"); write(&dir, "overlap.md", "foobar\n"); - let parsed = ContentQuery::parse("foo oob", false).unwrap(); + let parsed = ContentQuery::parse("foo oob", false, SearchScope::All).unwrap(); let hits = search_content(&dir, &parsed, &NeverAborts).unwrap().hits; assert_eq!(matched_text(&hits[0]), ["foob"]); let _ = std::fs::remove_dir_all(&dir); @@ -512,6 +557,91 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + fn seed_scoped(tag: &str) -> std::path::PathBuf { + let dir = test_dir(tag); + write( + &dir, + "gateway-notes.md", + "---\ntags: [infra]\n---\n\nplain prose\n", + ); + write( + &dir, + "other.md", + "---\ntitle: Unrelated\n---\n\nthe gateway is here\n", + ); + write( + &dir, + "tagged.md", + "---\ntags: [gateway]\n---\n\nplain prose\n", + ); + dir + } + + #[test] + fn names_scope_matches_the_file_name_only() { + let dir = seed_scoped("scope_names"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Names); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "gateway-notes.md"); + assert!( + hits[0].lines.is_empty(), + "no body snippet outside the content scope" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn content_scope_matches_the_body_only() { + let dir = seed_scoped("scope_content"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Content); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "other.md"); + assert_eq!(hits[0].lines.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn tags_scope_matches_the_tags_only() { + let dir = seed_scoped("scope_tags"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Tags); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "tagged.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn all_scope_matches_every_field() { + let dir = seed_scoped("scope_all"); + + let hits = search_scoped(&dir, "gateway", SearchScope::All); + + assert_eq!(hits.len(), 3); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn names_scope_matches_a_folder_in_the_path() { + let dir = test_dir("scope_folder"); + write(&dir, "gateway/inner.md", "unrelated prose\n"); + + let hits = search_scoped(&dir, "gateway", SearchScope::Names); + + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].rel_path, "gateway/inner.md"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scope_defaults_to_searching_everything() { + assert_eq!(SearchScope::default(), SearchScope::All); + } + #[test] fn no_matches_returns_no_hits() { let dir = test_dir("search_none"); @@ -523,8 +653,8 @@ mod tests { #[test] fn blank_query_has_no_terms() { - assert!(ContentQuery::parse(" ", false).is_none()); - assert!(ContentQuery::parse("", false).is_none()); + assert!(ContentQuery::parse(" ", false, SearchScope::All).is_none()); + assert!(ContentQuery::parse("", false, SearchScope::All).is_none()); } #[test] @@ -532,7 +662,7 @@ mod tests { let dir = test_dir("search_abort"); write(&dir, "a.md", "needle\n"); - let parsed = ContentQuery::parse("needle", false).unwrap(); + let parsed = ContentQuery::parse("needle", false, SearchScope::All).unwrap(); let result = search_content(&dir, &parsed, &AlwaysAborts).unwrap(); assert!(result.aborted); assert!(result.hits.is_empty()); @@ -543,7 +673,7 @@ mod tests { fn missing_folder_is_an_error() { let dir = test_dir("search_missing"); let _ = std::fs::remove_dir_all(&dir); - let parsed = ContentQuery::parse("x", false).unwrap(); + let parsed = ContentQuery::parse("x", false, SearchScope::All).unwrap(); assert!(search_content(&dir, &parsed, &NeverAborts).is_err()); } diff --git a/src-tauri/src/tauri_api/mod.rs b/src-tauri/src/tauri_api/mod.rs index 61b6dab..dab999d 100644 --- a/src-tauri/src/tauri_api/mod.rs +++ b/src-tauri/src/tauri_api/mod.rs @@ -10,6 +10,7 @@ use docsreader_core::git::{git_show_head_core, git_status_core, GitStatus}; use docsreader_core::scan::{run_scan, ScanProgress, ScanProgressSink, ScanResult}; use docsreader_core::search::{ search_content as search_content_core, ContentQuery, ContentSearchResult, SearchAbort, + SearchScope, }; use docsreader_core::tasks::{list_tasks_core, set_task_status_core, TaskSummary}; use docsreader_core::workspace::init::{convert_workspace_core, InitializedWorkspace}; @@ -60,13 +61,14 @@ pub async fn search_content( state: State<'_, SearchGeneration>, path: String, query: String, + scope: Option, ) -> Result { let latest = state.0.clone(); let generation = latest.fetch_add(1, Ordering::SeqCst) + 1; let abort = NewerQueryWins { generation, latest }; tauri::async_runtime::spawn_blocking(move || { - let Some(parsed) = ContentQuery::parse(&query, false) else { + let Some(parsed) = ContentQuery::parse(&query, false, scope.unwrap_or_default()) else { return Ok(ContentSearchResult::empty()); }; search_content_core(Path::new(&path), &parsed, &abort).map_err(|e| e.message) diff --git a/src/hooks/useContentSearch.ts b/src/hooks/useContentSearch.ts index 96e6812..622eb99 100644 --- a/src/hooks/useContentSearch.ts +++ b/src/hooks/useContentSearch.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import { searchContent, type ContentHit } from "@/lib/contentSearch"; +import { searchContent, type ContentHit, type SearchScope } from "@/lib/contentSearch"; // Long enough that a typed word issues one search rather than one per letter, // short enough that results feel attached to the keystroke. @@ -23,7 +23,8 @@ const IDLE: ContentSearchState = { export function useContentSearch( root: string | undefined, query: string, - enabled = true + enabled = true, + scope: SearchScope = "all" ): ContentSearchState { const [state, setState] = useState(IDLE); // Every request carries a sequence number. A slow earlier search that lands @@ -46,7 +47,7 @@ export function useContentSearch( const timer = setTimeout(() => { void (async () => { try { - const result = await searchContent(root, trimmed); + const result = await searchContent(root, trimmed, scope); if (isStale() || result.aborted) return; setState({ hits: result.hits, @@ -67,7 +68,7 @@ export function useContentSearch( }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [root, query, enabled]); + }, [root, query, enabled, scope]); return state; } diff --git a/src/lib/contentSearch.ts b/src/lib/contentSearch.ts index 5911ade..9e67ea9 100644 --- a/src/lib/contentSearch.ts +++ b/src/lib/contentSearch.ts @@ -1,5 +1,17 @@ import { invoke } from "@tauri-apps/api/core"; +/// Mirrors SearchScope in src-tauri/core/src/search.rs; keep the two in step. +export const SEARCH_SCOPES = ["all", "names", "content", "tags"] as const; + +export type SearchScope = (typeof SEARCH_SCOPES)[number]; + +export const SEARCH_SCOPE_LABELS: Record = { + all: "All", + names: "Names", + content: "Contents", + tags: "Tags", +}; + export interface SnippetSegment { text: string; isMatch: boolean; @@ -37,11 +49,12 @@ export const EMPTY_CONTENT_SEARCH: ContentSearchResult = { export async function searchContent( root: string, - query: string + query: string, + scope: SearchScope = "all" ): Promise { if (!query.trim()) return EMPTY_CONTENT_SEARCH; try { - return await invoke("search_content", { path: root, query }); + return await invoke("search_content", { path: root, query, scope }); } catch { // The backend detail is not useful to a reader; surfacing the folder being // unreadable is. From 7adddcd60cf325d4f6b7d0e8a8c2a45eb5fb70c0 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Wed, 22 Jul 2026 11:59:30 +0800 Subject: [PATCH 07/79] feat(search): add a workspace search lens Search results now live in their own lens rather than hijacking whichever lens was open, and follow the shape editors have settled on: results grouped into the files that contain the term, each with its match count, expandable to every matched line. - Shift+Cmd+F opens the lens and focuses the query, alongside Cmd+F for the open document. The two chords never contend. - A scope row narrows the search to names, contents or tags, backed by the core scope rather than filtered client-side. - Quick Open stays a separate file-name-only surface across every open workspace, which is the split editors already train people on. Typing a query no longer replaces the tasks board or the file tree. --- src/App.tsx | 32 +++- src/components/explorer/ExplorerSidebar.tsx | 12 +- src/components/explorer/LensTabs.tsx | 1 + src/components/explorer/SearchInput.tsx | 1 + src/components/explorer/SearchResultGroup.tsx | 97 +++++++++++ .../explorer/SearchResults.test.tsx | 52 +++++- src/components/explorer/SearchResults.tsx | 162 ++++++++++-------- src/components/explorer/SearchScopeTabs.tsx | 33 ++++ src/hooks/useContentSearch.test.ts | 11 +- src/lib/storage.ts | 2 +- 10 files changed, 316 insertions(+), 87 deletions(-) create mode 100644 src/components/explorer/SearchResultGroup.tsx create mode 100644 src/components/explorer/SearchScopeTabs.tsx diff --git a/src/App.tsx b/src/App.tsx index 83fa9e8..101df64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ const SettingsDialog = lazy(() => import("@/components/settings/SettingsDialog") import { useLibrary } from "@/hooks/useLibrary"; import { useContentSearch } from "@/hooks/useContentSearch"; import { mergeSearchEntries } from "@/lib/searchEntries"; +import type { SearchScope } from "@/lib/contentSearch"; import { useConvertPrompt } from "@/hooks/useConvertPrompt"; import { usePanes } from "@/hooks/usePanes"; import type { SplitMode } from "@/lib/storage"; @@ -263,7 +264,14 @@ function App() { }; }, [quickOpenMounted]); const filteredFiles = useFilteredFiles(allFiles, search); - const contentSearch = useContentSearch(library.activeRoot, search); + const [searchScope, setSearchScope] = useState("all"); + const searchLensActive = viewSettings.settings.sidebarLens === "search"; + const contentSearch = useContentSearch( + library.activeRoot, + search, + searchLensActive, + searchScope + ); const searchEntries = useMemo( () => mergeSearchEntries(filteredFiles, contentSearch.hits), [filteredFiles, contentSearch.hits] @@ -340,6 +348,26 @@ function App() { [viewSettings] ); + // Mirrors the split every editor uses: Cmd+F searches the open document, + // Shift+Cmd+F searches the whole workspace. Find-in-document owns Cmd+F in + // TabScrollPane, so the two never contend for the same chord. + const workspaceSearchShortcut = useMemo(() => parseShortcut("Mod+Shift+F"), []); + const setSidebarOpen = sidebar.setOpen; + useEffect(() => { + if (!workspaceSearchShortcut) return; + const onKey = (e: KeyboardEvent) => { + if (!matchShortcut(e, workspaceSearchShortcut)) return; + e.preventDefault(); + setSidebarOpen(true); + handleLensChange("search"); + requestAnimationFrame(() => { + document.querySelector("[data-search-input]")?.focus(); + }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [workspaceSearchShortcut, setSidebarOpen, handleLensChange]); + const activeGitStatus = library.activeScan?.gitStatus; const gitStatusByPath = useMemo(() => { if (!activeGitStatus) return undefined; @@ -580,6 +608,8 @@ function App() { search={search} onSearchChange={setSearch} searchEntries={searchEntries} + searchScope={searchScope} + onSearchScopeChange={setSearchScope} searchingContents={contentSearch.searching} searchError={contentSearch.error} searchTruncated={contentSearch.truncated} diff --git a/src/components/explorer/ExplorerSidebar.tsx b/src/components/explorer/ExplorerSidebar.tsx index c18a4d2..971ee12 100644 --- a/src/components/explorer/ExplorerSidebar.tsx +++ b/src/components/explorer/ExplorerSidebar.tsx @@ -21,6 +21,7 @@ import { SearchInput } from "./SearchInput"; import { SearchResults } from "./SearchResults"; import { TagsList } from "./TagsList"; import type { SearchEntry } from "@/lib/searchEntries"; +import type { SearchScope } from "@/lib/contentSearch"; import { WorkspaceSwitcher } from "./WorkspaceSwitcher"; import { TasksBoard } from "@/components/tasks/TasksBoard"; @@ -43,6 +44,8 @@ interface Props { search: string; onSearchChange: (value: string) => void; searchEntries: SearchEntry[]; + searchScope: SearchScope; + onSearchScopeChange: (scope: SearchScope) => void; searchingContents: boolean; searchError: string | undefined; searchTruncated: boolean; @@ -89,6 +92,8 @@ export function ExplorerSidebar({ search, onSearchChange, searchEntries, + searchScope, + onSearchScopeChange, searchingContents, searchError, searchTruncated, @@ -157,9 +162,12 @@ export function ExplorerSidebar({ progress={activeScan.progress} startedAt={activeScan.startedAt} /> - ) : search.trim() ? ( + ) : lens === "search" ? ( diff --git a/src/components/explorer/LensTabs.tsx b/src/components/explorer/LensTabs.tsx index e612d74..ebb36bb 100644 --- a/src/components/explorer/LensTabs.tsx +++ b/src/components/explorer/LensTabs.tsx @@ -7,6 +7,7 @@ const LENS_LABELS: Record = { tags: "Tags", pinned: "Pinned", tasks: "Tasks", + search: "Search", }; interface Props { diff --git a/src/components/explorer/SearchInput.tsx b/src/components/explorer/SearchInput.tsx index 8a8d21b..9eef09f 100644 --- a/src/components/explorer/SearchInput.tsx +++ b/src/components/explorer/SearchInput.tsx @@ -16,6 +16,7 @@ export function SearchInput({
    onChange(e.target.value)} diff --git a/src/components/explorer/SearchResultGroup.tsx b/src/components/explorer/SearchResultGroup.tsx new file mode 100644 index 0000000..2954d39 --- /dev/null +++ b/src/components/explorer/SearchResultGroup.tsx @@ -0,0 +1,97 @@ +import { ChevronDown, ChevronRight, FileText } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { basename } from "@/lib/path"; +import type { SearchEntry } from "@/lib/searchEntries"; +import { EntryContextMenu } from "./EntryContextMenu"; +import { SearchSnippet } from "./SearchSnippet"; +import { SIDEBAR_ROW, sidebarRowState, fileOpenHandlers } from "./sidebarRow"; + +interface Props { + entry: SearchEntry; + expanded: boolean; + selected: boolean; + onToggle: (path: string) => void; + onSelect: (path: string) => void; + onOpenInNewTab: (path: string) => void; + onOpenInOtherPane?: (path: string) => void; + pinned: boolean; + onTogglePin: (path: string) => void; +} + +export function SearchResultGroup({ + entry, + expanded, + selected, + onToggle, + onSelect, + onOpenInNewTab, + onOpenInOtherPane, + pinned, + onTogglePin, +}: Props) { + const hasLines = entry.lines.length > 0; + const Chevron = expanded ? ChevronDown : ChevronRight; + + return ( +
  • + +
    + {hasLines ? ( + + ) : ( + + )} + + {entry.matchedLines > 0 && ( + + {entry.matchedLines} + + )} +
    +
    + + {expanded && hasLines && ( +
      + {entry.lines.map((line) => ( +
    • + +
    • + ))} + {entry.matchedLines > entry.lines.length && ( +
    • + {entry.matchedLines - entry.lines.length} more +
    • + )} +
    + )} +
  • + ); +} diff --git a/src/components/explorer/SearchResults.test.tsx b/src/components/explorer/SearchResults.test.tsx index 7c11345..b87a727 100644 --- a/src/components/explorer/SearchResults.test.tsx +++ b/src/components/explorer/SearchResults.test.tsx @@ -8,6 +8,7 @@ import type { SearchEntry } from "@/lib/searchEntries"; vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: vi.fn() })); const handlers = { + onScopeChange: vi.fn(), onSelect: vi.fn(), onOpenInNewTab: vi.fn(), onTogglePin: vi.fn(), @@ -39,7 +40,9 @@ function entry(overrides: Partial = {}): SearchEntry { function renderResults(props: Partial> = {}) { return render( { it("reports matched lines beyond the shown ones", () => { renderResults({ entries: [entry({ matchedLines: 4 })] }); - expect(screen.getByText("3 more lines")).toBeInTheDocument(); - }); - - it("uses the singular form for a single extra line", () => { - renderResults({ entries: [entry({ matchedLines: 2 })] }); - - expect(screen.getByText("1 more line")).toBeInTheDocument(); + expect(screen.getByText("3 more")).toBeInTheDocument(); }); it("shows a name-only match with no snippet", () => { @@ -131,6 +128,47 @@ describe("SearchResults", () => { expect(screen.getByText("No matches")).toBeInTheDocument(); }); + it("prompts before anything is typed", () => { + renderResults({ entries: [], query: "" }); + + expect(screen.getByText("Search this workspace")).toBeInTheDocument(); + }); + + it("groups a file with its match count", () => { + renderResults({ entries: [entry({ matchedLines: 4 })] }); + + expect(screen.getByText("4")).toBeInTheDocument(); + }); + + it("collapses and re-expands a file's matches", async () => { + const user = userEvent.setup(); + renderResults(); + expect(screen.getByText("coturn")).toBeInTheDocument(); + + await user.click(screen.getByLabelText("Collapse matches")); + expect(screen.queryByText("coturn")).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText("Expand matches")); + expect(screen.getByText("coturn")).toBeInTheDocument(); + }); + + it("switches scope", async () => { + const user = userEvent.setup(); + renderResults(); + + await user.click(screen.getByRole("tab", { name: "Tags" })); + + expect(handlers.onScopeChange).toHaveBeenCalledWith("tags"); + }); + + it("offers every scope", () => { + renderResults(); + + for (const label of ["All", "Names", "Contents", "Tags"]) { + expect(screen.getByRole("tab", { name: label })).toBeInTheDocument(); + } + }); + it("says it is still searching before results arrive", () => { renderResults({ entries: [], searching: true }); diff --git a/src/components/explorer/SearchResults.tsx b/src/components/explorer/SearchResults.tsx index d5bac2b..1824ef8 100644 --- a/src/components/explorer/SearchResults.tsx +++ b/src/components/explorer/SearchResults.tsx @@ -1,15 +1,16 @@ -import { FileText } from "lucide-react"; +import { useEffect, useState } from "react"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; -import { cn } from "@/lib/utils"; +import type { SearchScope } from "@/lib/contentSearch"; import type { SearchEntry } from "@/lib/searchEntries"; -import { basename } from "@/lib/path"; -import { EntryContextMenu } from "./EntryContextMenu"; -import { SearchSnippet } from "./SearchSnippet"; -import { SIDEBAR_ROW, sidebarRowState, fileOpenHandlers } from "./sidebarRow"; +import { SearchResultGroup } from "./SearchResultGroup"; +import { SearchScopeTabs } from "./SearchScopeTabs"; interface Props { + query: string; entries: SearchEntry[]; + scope: SearchScope; + onScopeChange: (scope: SearchScope) => void; searching: boolean; error: string | undefined; truncated: boolean; @@ -22,7 +23,10 @@ interface Props { } export function SearchResults({ + query, entries, + scope, + onScopeChange, searching, error, truncated, @@ -33,6 +37,65 @@ export function SearchResults({ isPinned, onTogglePin, }: Props) { + const [collapsed, setCollapsed] = useState>(new Set()); + + // Groups start open, matching how the results read as one list. A new query + // is a new result set, so previous collapse choices no longer apply. + useEffect(() => { + setCollapsed(new Set()); + }, [query, scope]); + + const toggle = (path: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + return ( +
    + + +
    + ); +} + +interface BodyProps extends Omit { + collapsed: ReadonlySet; + onToggle: (path: string) => void; +} + +function Body({ + query, + entries, + searching, + error, + truncated, + collapsed, + onToggle, + selectedPath, + onSelect, + onOpenInNewTab, + onOpenInOtherPane, + isPinned, + onTogglePin, +}: BodyProps) { if (error) { return ( @@ -44,29 +107,40 @@ export function SearchResults({ ); } + if (!query.trim()) { + return ( + + + Search this workspace + + Type above to search file names, tags, and the text inside your documents. + + + + ); + } + if (entries.length === 0) { return ( {searching ? "Searching…" : "No matches"} - {!searching && ( - - Nothing matched in file names, titles, tags, or document contents. - - )} + {!searching && Nothing matched {query}.} ); } return ( -
    +