mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
91d6c7de57
commit
c3b7e34b22
18 files changed
+118
-566
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
+81
-174
@@ -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,12 +217,20 @@ 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(),
|
||||
@@ -192,24 +244,12 @@ fn log_indexed(
|
||||
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 {
|
||||
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 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();
|
||||
@@ -229,31 +269,19 @@ fn log_indexed(
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "图标主题",
|
||||
|
||||
Reference in new issue
Block a user