perf(vcs): filter the Git log with git log instead of a background search index (#1537)

* 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)<paths>` 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.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-05 19:20:43 +08:00
1 parent 91d6c7de57
commit c3b7e34b22
18 files changed
+149 -597

No files matched your search

+2 -1
View File
@@ -9,7 +9,8 @@
<!-- Required. Commands you actually ran and their results; what else this could
affect and how you checked it still works.
UI changes: before/after screenshots. Multi-step interactions: a recording.
Visible UI changes: before/after screenshots (not for removed UI or copy-only
changes). Multi-step interactions: a recording.
Backend changes: logs or command output. -->
## Review focus
+2 -1
View File
@@ -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 #<n>` 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
+3 -2
View File
@@ -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.
-26
View File
@@ -405,28 +405,6 @@ pub async fn vcs_log_details_batch(
result
}
pub async fn vcs_log_index(
host: &Host,
window: &str,
repo: Option<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
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
});
-1
View File
@@ -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"
+112 -205
View File
@@ -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<ObjectId>,
hash: Option<String>,
},
/// 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<Option<Self>> {
fn new(
r: &Repo,
scopes: &[String],
q: &LogQuery,
text: &str,
cancel: &CancellationToken,
) -> IdeResult<Option<Self>> {
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<bool> {
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<HashSet<ObjectId>> {
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::<Vec<_>>(), 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<Vec<Commit>> {
@@ -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::<Vec<_>>();
if !tips.is_empty() {
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().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::<Vec<_>>();
if !tips.is_empty() {
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().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::<Vec<_>>(),
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::<Vec<_>>(), 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::<IdeResult<HashSet<_>>>()?,
);
}
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<HashSet<ObjectId>>,
authors: Vec<String>,
text: Option<TextFilter>,
text_candidates: Option<HashSet<String>>,
user_candidates: Option<HashSet<String>>,
path_candidates: Option<HashSet<String>>,
matches: HashMap<ObjectId, bool>,
projected: HashMap<ObjectId, Vec<ObjectId>>,
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<HashMap<ObjectId,
Ok(map)
}
/// Commits touching a path, newest first, with git's default history simplification:
/// a commit is shown when its entry for the path differs from every parent, and at a
/// merge that kept one parent's version only that parent's line of history is followed.
struct PathWalk<'r> {
repo: &'r gix::Repository,
paths: Vec<String>,
queue: std::collections::BinaryHeap<(i64, u64, ObjectId)>,
seen: HashSet<ObjectId>,
index: &'r mut crate::log_index::LogIndex,
seq: u64,
first_parent: bool,
cancel: &'r CancellationToken,
shallow: HashSet<ObjectId>,
}
impl<'r> PathWalk<'r> {
fn new(
repo: &'r gix::Repository,
tips: Vec<ObjectId>,
paths: Vec<String>,
first_parent: bool,
cancel: &'r CancellationToken,
index: &'r mut crate::log_index::LogIndex,
) -> IdeResult<Self> {
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<Option<(ObjectId, Vec<ObjectId>)>> {
while let Some((_, _, id)) = self.queue.pop() {
let commit = self.repo.find_commit(id).map_err(gx)?;
let parents: Vec<ObjectId> = commit.parent_ids().map(|p| p.detach()).collect();
// Parents cut off by a shallow boundary are treated as absent.
let present: Vec<ObjectId> = 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::<IdeResult<Vec<_>>>()?;
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::<IdeResult<Vec<_>>>()?;
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<ObjectId>)>;
fn next(&mut self) -> Option<Self::Item> {
self.step().transpose()
}
}
pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let repo = r.gix()?;
Ok(first_parent_changes(&repo, rev)?.files)
-62
View File
@@ -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<repo::Cache>,
ignored: std::sync::RwLock<IgnoredNames>,
log_index: std::sync::Mutex<log_index::LogIndex>,
index_progress: std::sync::Mutex<LogIndexProgress>,
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
}
@@ -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<LogIndexProgress> {
{
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::<IdeResult<_>>()?;
{
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::<Vec<_>>();
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<LogDetails> {
self.log_details_cancellable(revision, &CancellationToken::new())
}
+2 -159
View File
@@ -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<Commit>,
@@ -36,9 +33,6 @@ pub(crate) struct LogIndex {
memory: HashMap<ObjectId, Arc<Commit>>,
dirty: Vec<Arc<Commit>>,
dirty_messages: Vec<(String, String)>,
dirty_search: Vec<(String, Vec<String>)>,
path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>,
dirty_paths: Vec<(String, Option<(String, u16)>)>,
pub head: Option<(ObjectId, Option<SystemTime>)>,
pub reachable: HashSet<ObjectId>,
pub views: HashMap<String, Arc<IndexedView>>,
@@ -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<String> = 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<String> {
let chars: Vec<_> = text.to_lowercase().chars().collect();
chars.windows(3).map(|s| s.iter().collect()).collect::<HashSet<_>>().into_iter().collect()
}
/// Missing/unindexed commits are always checked directly by the caller.
pub fn message_candidates(&self, text: &str) -> Option<HashSet<String>> {
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<HashSet<String>> = 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<HashSet<String>> {
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<HashSet<String>> {
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<dyn std::error::Error>> {
@@ -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<Option<(ObjectId, u16)>> {
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::<Option<(String, u16)>>(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)
}
}
-1
View File
@@ -65,7 +65,6 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
inner: std::sync::Mutex::new(cache),
ignored: Default::default(),
log_index: Default::default(),
index_progress: Default::default(),
log_signatures: Default::default(),
}))
}
+28 -2
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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());
}
-1
View File
@@ -262,7 +262,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
-10
View File
@@ -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<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
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>,
-7
View File
@@ -496,7 +496,6 @@ export const commands = {
vcsOpenExternalLog: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("vcs_open_external_log", { path })),
vcsLogDetails: (repo: string | null, revision: string) => typedError<LogDetails, IdeError>(__TAURI_INVOKE("vcs_log_details", { repo, revision })),
vcsLogDetailsBatch: (repo: string | null, revisions: string[], request: RequestId) => typedError<LogDetails[], IdeError>(__TAURI_INVOKE("vcs_log_details_batch", { repo, revisions, request })),
vcsLogIndex: (repo: string | null, request: number | null) => typedError<LogIndexProgress, IdeError>(__TAURI_INVOKE("vcs_log_index", { repo, request })),
vcsRevisionChildren: (repo: string | null, revision: string, directory: string) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_revision_children", { repo, revision, directory })),
vcsRevisionFiles: (repo: string | null, revision: string) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_revision_files", { repo, revision })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__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 = {
-2
View File
@@ -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() {
<SplitPanel id="commits" defaultSize="54%" minSize="25%">
<div className={styles.log}>
<FilterBar />
<LogIndexStatus repositories={repo} />
<CommitList />
</div>
</SplitPanel>
@@ -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),
},
};
-110
View File
@@ -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<typeof setInterval>;
}
const jobs = new Map<string, Job>();
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 (
<div key={repo} role="status" style={{ display: "flex", alignItems: "center", gap: 6 }}>
{tx`Indexing Git log: ${job.progress.indexed} / ${job.progress.total}`}
<IconButton
icon={job.paused ? "play" : "pause"}
title={job.paused ? "Resume Indexing" : "Pause Indexing"}
onClick={() => {
job.paused = !job.paused;
if (job.paused && job.request != null) void commands.cancelRequest(job.request).catch(() => undefined);
else start(repo, job);
notify();
}}
/>
</div>
);
});
}
-3
View File
@@ -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",
-3
View File
@@ -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": "图标主题",