From c3b7e34b223f0ada4b0f2e3a6af2ef436ba245a0 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:20:43 +0800 Subject: [PATCH] perf(vcs): filter the Git log with git log instead of a background search index (#1537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(vcs): filter the Git log with git log instead of a background search index The log built a persistent inverted index of every commit's message, authors and changed paths in the background, with a progress bar above the commit list, and walked trees itself to filter by path. Building it took minutes on large repositories, and filters were slow until it finished. - Text: plain text uses `git log --fixed-strings --grep` (with `--regexp-ignore-case` unless Match Case is on); text of 4+ hex digits also matches hash prefixes. Regex filters still match in-process. - Paths: `git log -- :(literal)` replaces the in-process path walk and its per-commit tree cache. It uses git's history simplification and, when the repository has a changed-path commit-graph, its Bloom filters. Glob characters in a path are literal. - Authors match against the commit metadata cache. - Removed the search tables, `index_log`, the `vcs_log_index` command and the indexing progress bar. The commit metadata cache stays. * docs(contributing): ask for screenshots only when a change alters what users see Removing a feature together with its UI, copy-only and non-visual changes now describe what was checked instead of attaching before/after screenshots. * fix(vcs): match case-insensitive non-ASCII Git log text in process git folds case only for ASCII under the C locale it runs with, so a case-insensitive filter like "ärger" missed "Ärger". Such text is now matched against commit messages directly. Path and text selection also ignore log.showSignature and log.follow from the user's config. --- .github/pull_request_template.md | 3 +- AGENTS.md | 3 +- CONTRIBUTING.md | 5 +- crates/ide-host/src/ops/vcs/commands.rs | 26 -- crates/ide-host/src/trust.rs | 1 - crates/ide-vcs/src/history.rs | 317 +++++++----------- crates/ide-vcs/src/lib.rs | 62 ---- crates/ide-vcs/src/log_index.rs | 161 +-------- crates/ide-vcs/src/repo.rs | 1 - crates/ide-vcs/tests/log_projection.rs | 30 +- src-tauri/src/commands/mod.rs | 1 - src-tauri/src/commands/vcs.rs | 10 - src/bindings.ts | 7 - src/features/vcs/GitLog.tsx | 2 - .../vcs/__tests__/logUiFeatures.test.tsx | 1 - src/features/vcs/logIndex.tsx | 110 ------ src/platform/locales/text/es.json | 3 - src/platform/locales/text/zh.json | 3 - 18 files changed, 149 insertions(+), 597 deletions(-) delete mode 100644 src/features/vcs/logIndex.tsx diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6ad43075..4fb67fbf 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,7 +9,8 @@ ## Review focus diff --git a/AGENTS.md b/AGENTS.md index 54cc95ae..50ad7deb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,8 @@ Visual style: dark, low-contrast greys, one cool accent Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR (`Closes #` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a -**How it was verified** section with commands run, results and (for UI) before/after screenshots. +**How it was verified** section with commands run, results and, when the change alters what users +see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes). Unclear product decisions → `needs-decision` and stop. ## Skills diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e02bd6b..2b454425 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,8 +25,9 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md). - Fill in the template. Keep the description short and in plain words. If it doesn't fit in a few sentences, the PR is probably too big. - **How it was verified** is required: the commands you ran and their results, - and what else the change could affect. UI changes need before/after screenshots; - multi-step interactions need a recording. + and what else the change could affect. Changes to what users see need before/after + screenshots; multi-step interactions need a recording. UI that goes away with a removed + feature, copy-only and non-visual changes don't; say what you checked instead. - If a change needs a product decision you can't make, ask in the issue and stop rather than guessing. diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index d619c0a0..9dc44f29 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -405,28 +405,6 @@ pub async fn vcs_log_details_batch( result } -pub async fn vcs_log_index( - host: &Host, - window: &str, - repo: Option, - request: Option, -) -> IdeResult { - let project = LogAccess::new(host, window, repo.as_deref()); - let cancel = - request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id)); - let result = blocking(move || { - project.with_log_repo_at(repo.as_deref(), |r| { - r.enable_log_index(&project.cache); - if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) } - }) - }) - .await; - if let Some(request) = request { - host.end_request(request); - } - result -} - pub async fn vcs_revision_children( host: &Host, window: &str, @@ -1222,10 +1200,6 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_log_details_batch", |c| async move { vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await }); - d.add( - "vcs_log_index", - |c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await }, - ); d.add("vcs_revision_children", |c| async move { vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await }); diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index c429bfaf..ca3194b5 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -316,7 +316,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "vcs_log_users" | "vcs_log_details" | "vcs_log_details_batch" - | "vcs_log_index" | "vcs_revision_children" | "vcs_revision_files" | "vcs_open_external_log" diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index be0da59b..14873f7b 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -9,44 +9,88 @@ use std::collections::{HashMap, HashSet}; /// Log "Text or hash" filter: the message (substring or regex), or a hash prefix. enum TextFilter { - Plain { needle: String, match_case: bool }, + /// Commits `git log --grep` selected, or whose id starts with `hash`. + Selected { + ids: HashSet, + hash: Option, + }, + /// Case-insensitive non-ASCII text: git folds case only for ASCII under the C locale. + Folded(String), Regex(regex_lite::Regex), } impl TextFilter { - fn new(text: &str, regex: bool, match_case: bool) -> IdeResult> { + fn new( + r: &Repo, + scopes: &[String], + q: &LogQuery, + text: &str, + cancel: &CancellationToken, + ) -> IdeResult> { let text = text.trim(); if text.is_empty() { return Ok(None); } - if regex { + if q.text_regex { let re = regex_lite::RegexBuilder::new(text) - .case_insensitive(!match_case) + .case_insensitive(!q.text_match_case) .multi_line(true) .build() .map_err(|e| IdeError::invalid(format!("invalid regular expression: {e}")))?; return Ok(Some(Self::Regex(re))); } - let needle = if match_case { text.to_string() } else { text.to_lowercase() }; - Ok(Some(Self::Plain { needle, match_case })) + if !q.text_match_case && !text.is_ascii() { + return Ok(Some(Self::Folded(text.to_lowercase()))); + } + let mut filter = vec!["--fixed-strings".to_string(), format!("--grep={text}")]; + if !q.text_match_case { + filter.push("--regexp-ignore-case".into()); + } + let ids = git_select(r, scopes, &filter, &[], q.first_parent, cancel)?; + let hash = (text.len() >= 4 && text.bytes().all(|b| b.is_ascii_hexdigit())).then(|| text.to_ascii_lowercase()); + Ok(Some(Self::Selected { ids, hash })) } - fn matches(&self, message: &str, id: &ObjectId) -> bool { - match self { - Self::Regex(re) => re.is_match(message), - Self::Plain { needle, match_case } => { - let hash = needle.len() >= 4 && needle.bytes().all(|b| b.is_ascii_hexdigit()); - if hash && id.to_string().starts_with(&needle.to_ascii_lowercase()) { - return true; - } - if *match_case { - message.contains(needle.as_str()) - } else { - message.to_lowercase().contains(needle.as_str()) - } + fn matches(&self, repo: &gix::Repository, index: &mut crate::log_index::LogIndex, id: ObjectId) -> IdeResult { + Ok(match self { + Self::Regex(re) => re.is_match(&index.message(repo, id)?), + Self::Folded(needle) => index.message(repo, id)?.to_lowercase().contains(needle.as_str()), + Self::Selected { ids, hash } => { + ids.contains(&id) || hash.as_ref().is_some_and(|h| id.to_string().starts_with(h.as_str())) } + }) + } +} + +/// Commits git selects from each scope with `filter`. Path limiting uses git's history +/// simplification and, when the repository has them, changed-path Bloom filters. +fn git_select( + r: &Repo, + scopes: &[String], + filter: &[String], + paths: &[String], + first_parent: bool, + cancel: &CancellationToken, +) -> IdeResult> { + let mut ids = HashSet::new(); + for scope in scopes { + let mut args = + vec!["log".to_string(), "--format=%H".into(), "--no-show-signature".into(), "--no-follow".into()]; + if first_parent { + args.push("--first-parent".into()); + } + args.extend(filter.iter().cloned()); + args.push("--end-of-options".into()); + args.extend(scope.split_whitespace().map(str::to_string)); + args.push("--".into()); + args.extend(paths.iter().map(|p| format!(":(literal){p}"))); + let output = + crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::>(), cancel)?; + for line in output.stdout_str().lines() { + ids.insert(ObjectId::from_hex(line.as_bytes()).map_err(gx)?); } } + Ok(ids) } pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult> { @@ -173,87 +217,71 @@ fn log_indexed( } else { None }; + let scopes = if let Some(range) = range { + vec![range.to_string()] + } else { + let mut scopes = ranges.iter().map(|s| s.to_string()).collect::>(); + if !tips.is_empty() { + scopes.push(tips.iter().map(ToString::to_string).collect::>().join(" ")); + } + scopes + }; let mut history_paths = HashMap::new(); let path_ids = if paths.is_empty() { None - } else { + } else if q.follow && paths.len() == 1 { let mut ids = HashSet::new(); - if q.follow && paths.len() == 1 { - let rel = &paths[0]; - let mut args = vec![ - "log".to_string(), - "--format=%x00%H".into(), - "--name-status".into(), - "-z".into(), - "--follow".into(), - "--topo-order".into(), - ]; - if q.first_parent { - args.push("--first-parent".into()); - } - args.push("--end-of-options".into()); - let scopes = if let Some(range) = range { - vec![range.to_string()] - } else { - let mut scopes = ranges.iter().map(|s| s.to_string()).collect::>(); - if !tips.is_empty() { - scopes.push(tips.iter().map(ToString::to_string).collect::>().join(" ")); - } - scopes - }; - for scope in scopes { - let mut args = args.clone(); - args.extend(scope.split_whitespace().map(str::to_string)); - args.extend(["--".into(), rel.clone()]); - let output = crate::cli::run_cancellable( - r.root(), - &args.iter().map(String::as_str).collect::>(), - cancel, - )?; - let text = output.stdout_str(); - let mut tokens = text.split('\0').peekable(); - let mut current_path = rel.clone(); - while let Some(token) = tokens.next() { - let token = token.trim_start_matches('\n'); - if let Ok(id) = ObjectId::from_hex(token.as_bytes()) { - ids.insert(id); - history_paths.insert(id, current_path.clone()); - } else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) { - if let (Some(old), Some(new)) = (tokens.next(), tokens.next()) - && new == current_path - { - current_path = old.to_string(); - } - } else if ["A", "D", "M", "T", "U"].contains(&token) { - tokens.next(); + let rel = &paths[0]; + let mut args = vec![ + "log".to_string(), + "--format=%x00%H".into(), + "--name-status".into(), + "-z".into(), + "--follow".into(), + "--topo-order".into(), + ]; + if q.first_parent { + args.push("--first-parent".into()); + } + args.push("--end-of-options".into()); + for scope in &scopes { + let mut args = args.clone(); + args.extend(scope.split_whitespace().map(str::to_string)); + args.extend(["--".into(), rel.clone()]); + let output = + crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::>(), cancel)?; + let text = output.stdout_str(); + let mut tokens = text.split('\0').peekable(); + let mut current_path = rel.clone(); + while let Some(token) = tokens.next() { + let token = token.trim_start_matches('\n'); + if let Ok(id) = ObjectId::from_hex(token.as_bytes()) { + ids.insert(id); + history_paths.insert(id, current_path.clone()); + } else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) { + if let (Some(old), Some(new)) = (tokens.next(), tokens.next()) + && new == current_path + { + current_path = old.to_string(); } + } else if ["A", "D", "M", "T", "U"].contains(&token) { + tokens.next(); } } - } else { - let starts = range_ids.as_ref().cloned().unwrap_or_else(|| tips.clone()); - ids.extend( - PathWalk::new(&repo, starts, paths.clone(), q.first_parent, cancel, index)? - .map(|item| item.map(|(id, _)| id)) - .collect::>>()?, - ); } Some(ids) + } else { + Some(git_select(r, &scopes, &[], &paths, q.first_parent, cancel)?) }; let authors: Vec<_> = q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect(); - let user_candidates = if q.authors_exact && !authors.is_empty() { index.user_candidates(&authors) } else { None }; - let path_candidates = if !q.follow && !paths.is_empty() { index.path_candidates(&paths) } else { None }; - let text_candidates = q.text.as_deref().filter(|_| !q.text_regex).and_then(|s| index.message_candidates(s)); let mut visibility = Visibility { speed_refs: &decorations, - text_candidates, - user_candidates, - path_candidates, query: q, path_ids, range_ids: range_ids.as_ref().map(|ids| ids.iter().copied().collect()), authors, - text: q.text.as_deref().map(|t| TextFilter::new(t, q.text_regex, q.text_match_case)).transpose()?.flatten(), + text: q.text.as_deref().map(|t| TextFilter::new(r, &scopes, q, t, cancel)).transpose()?.flatten(), matches: HashMap::new(), projected: HashMap::new(), cancel, @@ -584,9 +612,6 @@ struct Visibility<'a> { range_ids: Option>, authors: Vec, text: Option, - text_candidates: Option>, - user_candidates: Option>, - path_candidates: Option>, matches: HashMap, projected: HashMap>, cancel: &'a CancellationToken, @@ -602,10 +627,7 @@ impl Visibility<'_> { if let Some(shown) = self.matches.get(&id) { return Ok(*shown); } - let indexed = self.index.indexed(&id.to_string()); - let shown = if (indexed && self.user_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string()))) - || (indexed && self.path_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string()))) - || !repo.has_object(id) + let shown = if !repo.has_object(id) || self.path_ids.as_ref().is_some_and(|ids| !ids.contains(&id)) || self.range_ids.as_ref().is_some_and(|ids| !ids.contains(&id)) { @@ -648,15 +670,7 @@ impl Visibility<'_> { } })) && match &self.text { - Some(text) => { - self.text_candidates.as_ref().is_none_or(|ids| { - ids.contains(&id.to_string()) - || !self.index.indexed(&id.to_string()) - || id.to_string().starts_with( - self.query.text.as_deref().unwrap_or("").trim().to_ascii_lowercase().as_str(), - ) - }) && text.matches(&self.index.message(repo, id)?, &id) - } + Some(text) => text.matches(repo, self.index, id)?, None => true, } }; @@ -865,113 +879,6 @@ pub(crate) fn decorations(repo: &gix::Repository) -> IdeResult { - repo: &'r gix::Repository, - paths: Vec, - queue: std::collections::BinaryHeap<(i64, u64, ObjectId)>, - seen: HashSet, - index: &'r mut crate::log_index::LogIndex, - seq: u64, - first_parent: bool, - cancel: &'r CancellationToken, - shallow: HashSet, -} - -impl<'r> PathWalk<'r> { - fn new( - repo: &'r gix::Repository, - tips: Vec, - paths: Vec, - first_parent: bool, - cancel: &'r CancellationToken, - index: &'r mut crate::log_index::LogIndex, - ) -> IdeResult { - let mut walk = PathWalk { - repo, - paths, - queue: Default::default(), - seen: Default::default(), - index, - seq: 0, - first_parent, - cancel, - shallow: repo.shallow_commits().map_err(gx)?.map(|ids| ids.iter().copied().collect()).unwrap_or_default(), - }; - for tip in tips { - walk.push(tip)?; - } - Ok(walk) - } - - fn push(&mut self, id: ObjectId) -> IdeResult<()> { - if self.cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } - if !self.seen.insert(id) || !self.repo.has_object(id) { - return Ok(()); - } - let time = self.repo.find_commit(id).map_err(gx)?.time().map(|t| t.seconds).unwrap_or(0); - // Max-heap on time; among equal times, earlier insertion first. - self.seq += 1; - self.queue.push((time, u64::MAX - self.seq, id)); - Ok(()) - } - - fn step(&mut self) -> IdeResult)>> { - while let Some((_, _, id)) = self.queue.pop() { - let commit = self.repo.find_commit(id).map_err(gx)?; - let parents: Vec = commit.parent_ids().map(|p| p.detach()).collect(); - // Parents cut off by a shallow boundary are treated as absent. - let present: Vec = parents - .iter() - .copied() - .take(if self.shallow.contains(&id) { 0 } else { usize::MAX }) - .filter(|p| self.repo.has_object(p)) - .take(if self.first_parent { 1 } else { usize::MAX }) - .collect(); - let mine = self - .paths - .iter() - .map(|path| self.index.path_entry(self.repo, id, path)) - .collect::>>()?; - let mut same_parent = None; - for p in &present { - let theirs = self - .paths - .iter() - .map(|path| self.index.path_entry(self.repo, *p, path)) - .collect::>>()?; - if theirs == mine { - same_parent = Some(*p); - break; - } - } - match same_parent { - Some(p) => self.push(p)?, - None => { - for p in &present { - self.push(*p)?; - } - if !present.is_empty() || mine.iter().any(Option::is_some) { - return Ok(Some((id, parents))); - } - } - } - } - Ok(None) - } -} - -impl Iterator for PathWalk<'_> { - type Item = IdeResult<(ObjectId, Vec)>; - fn next(&mut self) -> Option { - self.step().transpose() - } -} - pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult> { let repo = r.gix()?; Ok(first_parent_changes(&repo, rev)?.files) diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 7adf881e..e21f689c 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -162,14 +162,6 @@ pub struct LogDetails { pub fingerprint: String, } -#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct LogIndexProgress { - pub indexed: u32, - pub total: u32, - pub running: bool, -} - /// Order of the Git Log (Graph Options › Sort). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -672,7 +664,6 @@ pub struct Repo { inner: std::sync::Mutex, ignored: std::sync::RwLock, log_index: std::sync::Mutex, - index_progress: std::sync::Mutex, log_signatures: std::sync::Mutex>, } @@ -844,59 +835,6 @@ impl Repo { .into_iter() .collect()) } - pub fn log_index_progress(&self) -> LogIndexProgress { - self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).clone() - } - - /// Incremental persistent inverted index; completed objects survive cancellation/restart. - pub fn index_log(&self, cancel: &CancellationToken) -> IdeResult { - { - let mut progress = self.index_progress.lock().unwrap_or_else(|p| p.into_inner()); - if progress.running { - return Ok(progress.clone()); - } - progress.running = true; - } - let result = (|| { - let repo = self.gix()?; - let out = cli::run_cancellable(self.root(), &["rev-list", "--all", "--topo-order"], cancel)?; - let ids: Vec<_> = out - .stdout_str() - .lines() - .map(|id| gix::ObjectId::from_hex(id.as_bytes()).map_err(repo::gx)) - .collect::>()?; - { - let mut p = self.index_progress.lock().unwrap_or_else(|p| p.into_inner()); - p.total = ids.len() as u32; - p.indexed = 0; - } - for (i, id) in ids.iter().enumerate() { - if cancel.is_cancelled() { - return Err(ide_core::IdeError::cancelled()); - } - let indexed = self.log_index.lock().unwrap_or_else(|p| p.into_inner()).indexed(&id.to_string()); - if !indexed { - let files = self.commit_files(&id.to_string())?; - let paths = - files.into_iter().flat_map(|f| std::iter::once(f.path).chain(f.old_path)).collect::>(); - let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner()); - let c = index.get(&repo, *id)?; - let message = index.message(&repo, *id)?; - index.index_commit(&c.id, &message, &c.author, &c.email, &paths); - if i % 100 == 0 { - index.flush(); - } - } - self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).indexed = i as u32 + 1; - } - Ok(()) - })(); - self.log_index.lock().unwrap_or_else(|p| p.into_inner()).flush(); - self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).running = false; - result?; - Ok(self.log_index_progress()) - } - pub fn log_details(&self, revision: &str) -> IdeResult { self.log_details_cancellable(revision, &CancellationToken::new()) } diff --git a/crates/ide-vcs/src/log_index.rs b/crates/ide-vcs/src/log_index.rs index c1d013aa..e837f360 100644 --- a/crates/ide-vcs/src/log_index.rs +++ b/crates/ide-vcs/src/log_index.rs @@ -6,7 +6,7 @@ use crate::repo::{gx, to_string}; use gix::ObjectId; use gix::bstr::ByteSlice; use ide_core::IdeResult; -use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition}; +use redb::{Database, ReadableDatabase, TableDefinition}; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex, OnceLock, Weak}; @@ -14,9 +14,6 @@ use std::time::SystemTime; const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2"); const MESSAGES: TableDefinition<&str, &str> = TableDefinition::new("messages-v1"); -const PATHS: TableDefinition<&str, &[u8]> = TableDefinition::new("paths-v2"); -const POSTINGS: MultimapTableDefinition<&str, &str> = MultimapTableDefinition::new("search-v1"); -const INDEXED: TableDefinition<&str, bool> = TableDefinition::new("indexed-v1"); pub(crate) struct IndexedView { pub commits: Vec, @@ -36,9 +33,6 @@ pub(crate) struct LogIndex { memory: HashMap>, dirty: Vec>, dirty_messages: Vec<(String, String)>, - dirty_search: Vec<(String, Vec)>, - path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>, - dirty_paths: Vec<(String, Option<(String, u16)>)>, pub head: Option<(ObjectId, Option)>, pub reachable: HashSet, pub views: HashMap>, @@ -92,91 +86,6 @@ impl LogIndex { } Ok(ids) } - pub fn indexed(&self, id: &str) -> bool { - self.db - .as_ref() - .and_then(|db| { - let txn = db.begin_read().ok()?; - let table = txn.open_table(INDEXED).ok()?; - Some(table.get(id).ok()??.value()) - }) - .unwrap_or(false) - } - - pub fn index_commit(&mut self, id: &str, message: &str, author: &str, email: &str, paths: &[String]) { - let mut keys: HashSet = Self::grams(message).into_iter().map(|s| format!("m:{s}")).collect(); - for user in [author, email, email.split('@').next().unwrap_or(email)] { - keys.insert(format!("u:{}", normalize_user(user))); - } - for path in paths { - keys.insert(format!("p:{path}")); - } - self.dirty_search.push((id.into(), keys.into_iter().collect())); - } - - fn grams(text: &str) -> Vec { - let chars: Vec<_> = text.to_lowercase().chars().collect(); - chars.windows(3).map(|s| s.iter().collect()).collect::>().into_iter().collect() - } - - /// Missing/unindexed commits are always checked directly by the caller. - pub fn message_candidates(&self, text: &str) -> Option> { - let grams = Self::grams(text); - if grams.is_empty() { - return None; - } - let db = self.db.as_ref()?; - let txn = db.begin_read().ok()?; - let table = txn.open_multimap_table(POSTINGS).ok()?; - let mut candidates: Option> = None; - for gram in grams { - let values = table.get(format!("m:{gram}").as_str()).ok()?; - let ids: HashSet<_> = values.filter_map(Result::ok).map(|s| s.value().to_string()).collect(); - candidates = Some(match candidates { - Some(old) => old.intersection(&ids).cloned().collect(), - None => ids, - }); - if candidates.as_ref().is_some_and(HashSet::is_empty) { - break; - } - } - Some(candidates.unwrap_or_default()) - } - - pub fn user_candidates(&self, authors: &[String]) -> Option> { - let txn = self.db.as_ref()?.begin_read().ok()?; - let table = txn.open_multimap_table(POSTINGS).ok()?; - let mut ids = HashSet::new(); - for author in authors { - let name = author.split('<').next().unwrap_or(author).trim(); - let email = author.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(author); - for alias in [name, email, email.split('@').next().unwrap_or(email)] { - for value in table.get(format!("u:{}", normalize_user(alias)).as_str()).ok()?.filter_map(Result::ok) { - ids.insert(value.value().to_string()); - } - } - } - Some(ids) - } - - pub fn path_candidates(&self, paths: &[String]) -> Option> { - let txn = self.db.as_ref()?.begin_read().ok()?; - let table = txn.open_multimap_table(POSTINGS).ok()?; - let mut ids = HashSet::new(); - for path in paths { - for value in table.get(format!("p:{path}").as_str()).ok()?.filter_map(Result::ok) { - ids.insert(value.value().to_string()); - } - let prefix = format!("p:{path}/"); - let end = format!("{prefix}{}", char::MAX); - for row in table.range(prefix.as_str()..end.as_str()).ok()?.filter_map(Result::ok) { - for value in row.1.filter_map(Result::ok) { - ids.insert(value.value().to_string()); - } - } - } - Some(ids) - } pub fn open(&mut self, dir: &Path) { if self.opened { return; @@ -272,11 +181,7 @@ impl LogIndex { } pub fn flush(&mut self) { - if self.dirty.is_empty() - && self.dirty_paths.is_empty() - && self.dirty_messages.is_empty() - && self.dirty_search.is_empty() - { + if self.dirty.is_empty() && self.dirty_messages.is_empty() { return; } let result = (|| -> Result<(), Box> { @@ -291,29 +196,12 @@ impl LogIndex { table.insert(commit.id.as_str(), bytes.as_slice())?; } } - { - let mut table = txn.open_table(PATHS)?; - for (key, value) in &self.dirty_paths { - let bytes = serde_json::to_vec(value)?; - table.insert(key.as_str(), bytes.as_slice())?; - } - } { let mut table = txn.open_table(MESSAGES)?; for (id, message) in &self.dirty_messages { table.insert(id.as_str(), message.as_str())?; } } - { - let mut postings = txn.open_multimap_table(POSTINGS)?; - let mut indexed = txn.open_table(INDEXED)?; - for (id, keys) in &self.dirty_search { - for key in keys { - postings.insert(key.as_str(), id.as_str())?; - } - indexed.insert(id.as_str(), true)?; - } - } txn.commit()?; Ok(()) })(); @@ -321,52 +209,7 @@ impl LogIndex { tracing::warn!("Git log cache write failed: {error}"); } self.dirty_messages.clear(); - self.dirty_search.clear(); self.dirty.clear(); - self.dirty_paths.clear(); - } - - pub fn path_entry( - &mut self, - repo: &gix::Repository, - id: ObjectId, - path: &str, - ) -> IdeResult> { - let memory_key = (id, path.to_string()); - if let Some(value) = self.path_memory.get(&memory_key) { - return Ok(*value); - } - let key = format!("{id}\0{path}"); - let cached = self.db.as_ref().and_then(|db| { - let txn = db.begin_read().ok()?; - let table = txn.open_table(PATHS).ok()?; - let value = table.get(key.as_str()).ok()??; - serde_json::from_slice::>(value.value()).ok() - }); - let cached = match cached { - Some(None) => Some(None), - Some(Some((hex, mode))) => ObjectId::from_hex(hex.as_bytes()).ok().map(|id| Some((id, mode))), - None => None, - }; - let value = if let Some(value) = cached { - value - } else { - let tree = repo.find_commit(id).map_err(gx)?.tree().map_err(gx)?; - let value = tree - .lookup_entry_by_path(Path::new(path)) - .map_err(gx)? - .map(|entry| (entry.object_id(), entry.mode().value())); - if self.db.is_some() { - self.dirty_paths.push((key, value.map(|(id, mode)| (id.to_string(), mode)))); - } - value - }; - if self.path_memory.len() >= 4096 { - self.flush(); - self.path_memory.clear(); - } - self.path_memory.insert(memory_key, value); - Ok(value) } } diff --git a/crates/ide-vcs/src/repo.rs b/crates/ide-vcs/src/repo.rs index 6ad81787..4f6cc2ef 100644 --- a/crates/ide-vcs/src/repo.rs +++ b/crates/ide-vcs/src/repo.rs @@ -65,7 +65,6 @@ pub(crate) fn discover(path: &Path) -> IdeResult> { inner: std::sync::Mutex::new(cache), ignored: Default::default(), log_index: Default::default(), - index_progress: Default::default(), log_signatures: Default::default(), })) } diff --git a/crates/ide-vcs/tests/log_projection.rs b/crates/ide-vcs/tests/log_projection.rs index 10c9c61a..a10d7dd6 100644 --- a/crates/ide-vcs/tests/log_projection.rs +++ b/crates/ide-vcs/tests/log_projection.rs @@ -99,8 +99,6 @@ fn lazy_summaries_seek_and_index_survive_refresh_and_restart() { let cancel = ide_core::CancellationToken::new(); cancel.cancel(); assert!(repo.log_cancellable(&query, &cancel).is_err()); - let progress = repo.index_log(&ide_core::CancellationToken::new()).unwrap(); - assert_eq!((progress.indexed, progress.total, progress.running), (70, 70, false)); assert_eq!(repo.log(&LogQuery { text: Some("needle body 5".into()), ..query.clone() }).unwrap().len(), 5); let new = commit(dir, "b", "new head"); let refreshed = repo.log(&LogQuery { retain_until: Some(ids[0].clone()), ..query.clone() }).unwrap(); @@ -436,3 +434,31 @@ fn multiple_paths_are_simplified_together_at_merges() { assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2); } } + +#[test] +fn paths_are_literal_and_text_matches_bodies_or_hash_prefixes() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let star = commit(dir, "[a]", "star\n\nBody Needle"); + let plain = commit(dir, "a", "plain"); + let repo = Repo::discover(dir).unwrap().unwrap(); + let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::>(); + assert_eq!(ids(LogQuery { path: Some("[a]".into()), ..Default::default() }), vec![star.clone()]); + assert_eq!(ids(LogQuery { text: Some("body needle".into()), ..Default::default() }), vec![star.clone()]); + assert!(ids(LogQuery { text: Some("body needle".into()), text_match_case: true, ..Default::default() }).is_empty()); + assert_eq!(ids(LogQuery { text: Some(plain[..7].to_uppercase()), ..Default::default() }), vec![plain]); +} + +#[test] +fn case_insensitive_text_folds_non_ascii() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let hit = commit(dir, "a", "Ärger ПРИВЕТ"); + commit(dir, "a", "other"); + let repo = Repo::discover(dir).unwrap().unwrap(); + let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::>(); + assert_eq!(ids(LogQuery { text: Some("ärger привет".into()), ..Default::default() }), vec![hit.clone()]); + assert!(ids(LogQuery { text: Some("ärger".into()), text_match_case: true, ..Default::default() }).is_empty()); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 31722bf4..8a33b8ff 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -262,7 +262,6 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_open_external_log, vcs::vcs_log_details, vcs::vcs_log_details_batch, - vcs::vcs_log_index, vcs::vcs_revision_children, vcs::vcs_revision_files, vcs::vcs_commit_files, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 8db05d08..ccb6c016 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -142,16 +142,6 @@ pub async fn vcs_log_details_batch( } #[tauri::command] #[specta::specta] -pub async fn vcs_log_index( - window: Window, - state: State<'_, AppState>, - repo: Option, - request: Option, -) -> IdeResult { - ide_host::ops::vcs::vcs_log_index(&state.host, window.label(), repo, request).await -} -#[tauri::command] -#[specta::specta] pub async fn vcs_revision_children( window: Window, state: State<'_, AppState>, diff --git a/src/bindings.ts b/src/bindings.ts index 5bb05494..1fb74b18 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -496,7 +496,6 @@ export const commands = { vcsOpenExternalLog: (path: string) => typedError(__TAURI_INVOKE("vcs_open_external_log", { path })), vcsLogDetails: (repo: string | null, revision: string) => typedError(__TAURI_INVOKE("vcs_log_details", { repo, revision })), vcsLogDetailsBatch: (repo: string | null, revisions: string[], request: RequestId) => typedError(__TAURI_INVOKE("vcs_log_details_batch", { repo, revisions, request })), - vcsLogIndex: (repo: string | null, request: number | null) => typedError(__TAURI_INVOKE("vcs_log_index", { repo, request })), vcsRevisionChildren: (repo: string | null, revision: string, directory: string) => typedError(__TAURI_INVOKE("vcs_revision_children", { repo, revision, directory })), vcsRevisionFiles: (repo: string | null, revision: string) => typedError(__TAURI_INVOKE("vcs_revision_files", { repo, revision })), vcsCommitFiles: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_commit_files", { repo, commit })), @@ -4987,12 +4986,6 @@ export type LogDetails = { fingerprint: string, }; -export type LogIndexProgress = { - indexed: number, - total: number, - running: boolean, -}; - export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLocation = { diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 51bcb64a..be7b59f9 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -49,7 +49,6 @@ import { import { hasPrimaryModifier } from "@/platform/keymap"; import { laneColor, reachableFrom, type GraphRow, type GraphSegment } from "./graph"; import { nextSpeedMatch } from "./logSearch"; -import { LogIndexStatus } from "./logIndex"; import { presentLogGraph } from "./logPresentation"; import { closeLogTab, @@ -328,7 +327,6 @@ export function LogView() {
-
diff --git a/src/features/vcs/__tests__/logUiFeatures.test.tsx b/src/features/vcs/__tests__/logUiFeatures.test.tsx index 378055ff..f6bc94d3 100644 --- a/src/features/vcs/__tests__/logUiFeatures.test.tsx +++ b/src/features/vcs/__tests__/logUiFeatures.test.tsx @@ -23,7 +23,6 @@ vi.mock("@/platform/ipc", async (original) => { status: "ok", data: await Promise.all(ids.map(async () => (await api.details()).data)), }), - vcsLogIndex: () => ok({ indexed: 0, total: 0, running: false }), vcsDiffRevisions: (...args: unknown[]) => api.diff(...args), }, }; diff --git a/src/features/vcs/logIndex.tsx b/src/features/vcs/logIndex.tsx deleted file mode 100644 index 33a0a73f..00000000 --- a/src/features/vcs/logIndex.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect } from "react"; -import { create } from "zustand"; -import type { LogIndexProgress } from "@/bindings"; -import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc"; -import { tx } from "@/platform/i18n"; -import { useVcs } from "./store"; -import { IconButton } from "@/ui"; - -interface Job { - version?: number; - restart?: boolean; - users: number; - request: RequestId | null; - paused: boolean; - progress: LogIndexProgress; - timer?: ReturnType; -} -const jobs = new Map(); -const revision = create<{ value: number }>(() => ({ value: 0 })); -const notify = () => revision.setState((s) => ({ value: s.value + 1 })); -function start(repo: string, job: Job) { - if (job.paused) return; - if (job.request != null) { - job.restart = true; - return; - } - const request = nextRequestId(); - job.request = request; - job.progress = { ...job.progress, running: true }; - job.timer = setInterval(() => { - void call(commands.vcsLogIndex(repo, null)) - .then((progress) => { - if (job.request === request) { - job.progress = progress; - notify(); - } - }) - .catch(() => undefined); - }, 750); - void call(commands.vcsLogIndex(repo, request)) - .then( - (progress) => { - job.progress = progress; - }, - () => { - job.progress = { ...job.progress, running: false }; - }, - ) - .finally(() => { - if (job.request !== request) return; - job.request = null; - clearInterval(job.timer); - if (job.restart && job.users && !job.paused) { - job.restart = false; - start(repo, job); - } - notify(); - }); - notify(); -} - -/** Visible log scopes share one background job per repository. */ -export function LogIndexStatus({ repositories }: { repositories: readonly string[] }) { - revision((s) => s.value); - const version = useVcs((s) => s.version); - useEffect(() => { - for (const repo of repositories) { - const job: Job = jobs.get(repo) ?? { - users: 0, - request: null, - paused: false, - progress: { indexed: 0, total: 0, running: false }, - }; - jobs.set(repo, job); - const first = job.users++ === 0; - if (first || job.version !== version) { - job.version = version; - start(repo, job); - } - } - return () => { - for (const repo of repositories) { - const job = jobs.get(repo)!; - if (--job.users === 0) { - clearInterval(job.timer); - if (job.request != null) void commands.cancelRequest(job.request).catch(() => undefined); - } - } - }; - }, [repositories, version]); - return repositories.map((repo) => { - const job = jobs.get(repo); - if (!job || (!job.paused && !job.progress.running)) return null; - return ( -
- {tx`Indexing Git log: ${job.progress.indexed} / ${job.progress.total}`} - { - job.paused = !job.paused; - if (job.paused && job.request != null) void commands.cancelRequest(job.request).catch(() => undefined); - else start(repo, job); - notify(); - }} - /> -
- ); - }); -} diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index 32f01037..52e968ef 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -6164,9 +6164,6 @@ "Shallow history boundary": "Límite de historial superficial (shallow)", "All Parents": "Todos los padres", "Show Commit Bookmark": "Mostrar marcador de confirmación", - "Indexing Git log: {indexed} / {total}": "Indexando registro de Git: {indexed} / {total}", - "Resume Indexing": "Reanudar indexación", - "Pause Indexing": "Pausar indexación", "Incompatible": "Incompatible", "Needs plugin API {engine}. Update OpenIDEA to install it.": "Requiere la API de plugins {engine}. Actualice OpenIDEA para instalarlo.", "Icon Themes": "Temas de iconos", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4dbecdbe..dc700728 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6200,9 +6200,6 @@ "Shallow history boundary": "浅克隆历史边界", "All Parents": "所有父提交", "Show Commit Bookmark": "显示提交书签", - "Indexing Git log: {indexed} / {total}": "正在索引 Git 日志:{indexed} / {total}", - "Resume Indexing": "继续索引", - "Pause Indexing": "暂停索引", "Incompatible": "不兼容", "Needs plugin API {engine}. Update OpenIDEA to install it.": "需要插件 API {engine}。请更新 OpenIDEA 后再安装。", "Icon Themes": "图标主题",