From 9f2e6b570f441dc63075022ff85b6189e74c80da Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Wed, 4 Nov 2026 23:31:32 +0500 Subject: [PATCH] feat(vcs): correct Git log graph projection and expand history views (#1076) Git Log could lose commits when filtering by several paths, draw wrong ancestry after filtering or collapsing rows, and open the wrong repository in multi-root views. The graph is now projected over the full commit DAG and laid out in Bek order, so filtered and collapsed views keep the real ancestry; each log view keeps its own filters and requests. Commit metadata, messages and path changes are kept in a persistent index (by object id, in the IDE's cache directory) with seekable paging, and the log gains parent-aware merge comparisons, a lazy revision browser, read-only logs of repositories outside the project (opened with command execution disabled), commit bookmarks, configurable built-in columns and batched commit details. The adapted IntelliJ algorithms are attributed in THIRD_PARTY_LICENSES.md. Closes #872 Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> --- Cargo.lock | 2 + THIRD_PARTY_LICENSES.md | 9 + crates/ide-host/src/lib.rs | 19 +- crates/ide-host/src/ops/doc/commands.rs | 2 +- crates/ide-host/src/ops/lsp.rs | 3 + crates/ide-host/src/ops/vcs/commands.rs | 261 ++- crates/ide-host/src/project.rs | 51 +- crates/ide-host/src/trust.rs | 8 + crates/ide-host/tests/external_log.rs | 115 ++ crates/ide-remote/src/mapping.rs | 42 +- crates/ide-vcs/Cargo.toml | 2 + crates/ide-vcs/src/bek.rs | 192 ++ crates/ide-vcs/src/history.rs | 1007 +++++++--- crates/ide-vcs/src/lib.rs | 257 ++- crates/ide-vcs/src/log_index.rs | 385 ++++ crates/ide-vcs/src/repo.rs | 3 + crates/ide-vcs/tests/log_projection.rs | 438 +++++ crates/ide-vcs/tests/log_shape.rs | 24 +- src-tauri/src/commands/mod.rs | 8 + src-tauri/src/commands/vcs.rs | 85 + src/bindings.ts | 120 +- src/editor/core/controller.ts | 24 +- src/editor/core/inputClose.test.ts | 37 + src/features/search/common/open.ts | 5 + src/features/vcs/CommitChooser.tsx | 50 + src/features/vcs/GitLog.module.css | 65 + src/features/vcs/GitLog.tsx | 1683 ++++++++++++++--- src/features/vcs/GitMergeDiff.tsx | 95 + src/features/vcs/LogScope.tsx | 37 + src/features/vcs/PathFilterDialog.tsx | 138 ++ src/features/vcs/RevisionBrowser.tsx | 245 +++ .../vcs/__tests__/GitMergeDiff.test.tsx | 41 + .../vcs/__tests__/logDetails.test.tsx | 143 +- .../__tests__/logGraphCorrectness.test.tsx | 144 ++ src/features/vcs/__tests__/logLoading.test.ts | 279 +++ src/features/vcs/__tests__/logOptions.test.ts | 11 +- src/features/vcs/__tests__/logSearch.test.ts | 28 + .../vcs/__tests__/logUiFeatures.test.tsx | 205 ++ src/features/vcs/__tests__/logViews.test.tsx | 142 ++ src/features/vcs/__tests__/multiRoot.test.ts | 36 +- src/features/vcs/gitTabs.ts | 4 +- src/features/vcs/graph.ts | 326 +++- src/features/vcs/historyActions.tsx | 60 +- src/features/vcs/index.ts | 31 +- src/features/vcs/logBookmarks.ts | 16 + src/features/vcs/logChanges.ts | 35 +- src/features/vcs/logContext.ts | 5 + src/features/vcs/logDetails.ts | 144 ++ src/features/vcs/logIndex.tsx | 110 ++ src/features/vcs/logOptions.ts | 46 +- src/features/vcs/logPaneActions.ts | 24 +- src/features/vcs/logPresentation.ts | 30 + src/features/vcs/logSearch.ts | 44 + src/features/vcs/logStore.ts | 910 +++++++-- src/features/vcs/multiRoot.ts | 20 +- src/platform/locales/text/fr.json | 4 - src/platform/locales/text/ko.json | 4 - src/platform/locales/text/pt.json | 4 - src/platform/locales/text/zh.json | 65 +- src/workbench/Workbench.tsx | 13 +- src/workbench/contributions.ts | 5 + src/workbench/lightEdit.test.tsx | 13 +- 62 files changed, 7473 insertions(+), 881 deletions(-) create mode 100644 crates/ide-host/tests/external_log.rs create mode 100644 crates/ide-vcs/src/bek.rs create mode 100644 crates/ide-vcs/src/log_index.rs create mode 100644 crates/ide-vcs/tests/log_projection.rs create mode 100644 src/editor/core/inputClose.test.ts create mode 100644 src/features/vcs/CommitChooser.tsx create mode 100644 src/features/vcs/GitMergeDiff.tsx create mode 100644 src/features/vcs/LogScope.tsx create mode 100644 src/features/vcs/PathFilterDialog.tsx create mode 100644 src/features/vcs/RevisionBrowser.tsx create mode 100644 src/features/vcs/__tests__/GitMergeDiff.test.tsx create mode 100644 src/features/vcs/__tests__/logGraphCorrectness.test.tsx create mode 100644 src/features/vcs/__tests__/logLoading.test.ts create mode 100644 src/features/vcs/__tests__/logSearch.test.ts create mode 100644 src/features/vcs/__tests__/logUiFeatures.test.tsx create mode 100644 src/features/vcs/__tests__/logViews.test.tsx create mode 100644 src/features/vcs/logBookmarks.ts create mode 100644 src/features/vcs/logContext.ts create mode 100644 src/features/vcs/logDetails.ts create mode 100644 src/features/vcs/logIndex.tsx create mode 100644 src/features/vcs/logPresentation.ts create mode 100644 src/features/vcs/logSearch.ts diff --git a/Cargo.lock b/Cargo.lock index bd6ee1ba..4e1e1d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4662,12 +4662,14 @@ dependencies = [ name = "ide-vcs" version = "0.1.36" dependencies = [ + "blake3", "dunce", "encoding_rs", "gix", "ide-core", "ignore", "libc", + "redb", "regex-lite", "serde", "serde_json", diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 6c0c117a..2ebfd19a 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -52,6 +52,15 @@ Their original copyright headers and Apache-2.0 licensing are retained. At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`. +## JetBrains IntelliJ Platform VCS log graph algorithms + +`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering, +graph layout and LinearBek merge linearization algorithms from +[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph) +(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and +`linearBek/LinearBekGraphBuilder.java`). Modified from the originals. +Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below). + ## JetBrains IntelliJ Platform diff algorithm `src/features/vcs/comparison/**` is a TypeScript port of the text comparison of diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index edaafb43..b0eded26 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -79,6 +79,7 @@ pub struct Host { pub runtime: tokio::runtime::Handle, /// One project per window, keyed by window label. projects: RwLock>>, + pub(crate) external_logs: RwLock>>>, requests: requests::Requests, project_requests: RwLock>>, } @@ -122,6 +123,7 @@ impl Host { owner, runtime, projects: RwLock::default(), + external_logs: RwLock::default(), requests: Default::default(), project_requests: RwLock::default(), }; @@ -178,6 +180,10 @@ impl Host { } pub fn close_project(&self, window: &str) { + self.external_logs.write().remove(self.owner_window(window)); + for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { + self.cancel_request(request); + } let project = self.projects.write().remove(window); if let Some(project) = project { if !project.light { @@ -193,9 +199,6 @@ impl Host { // asked for, its runs, its shells and its debug sessions — and the language // servers finish shutting down before the window is gone, as IDEA closes a // project's processes. - for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { - self.cancel_request(request); - } self.processes.kill_project(&project.root); self.terminals.kill_key(self.owner_window(window)); self.shutdown_project_workers_blocking( @@ -311,14 +314,20 @@ impl Host { } pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken { - let token = self.begin_request(id); - self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id); + let token = self.begin_project_read_request(window, id); if self.project(window).is_ok_and(|p| !p.is_trusted()) { token.cancel(); } token } + /// Read-only work remains cancellable/disposed with its window in restricted projects. + pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken { + let token = self.begin_request(id); + self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id); + token + } + pub fn end_request(&self, id: RequestId) { for ids in self.project_requests.write().values_mut() { ids.retain(|entry| *entry != id); diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs index e1fab8f9..ddb9c8a6 100644 --- a/crates/ide-host/src/ops/doc/commands.rs +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -18,7 +18,7 @@ pub async fn blocking(f: impl FnOnce() -> IdeResult + Send /// editor buffer, so syntax features and language-server requests work in it. pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult { let project = host.project(window)?; - let vd = project.lsp.virtual_document(&uri).await?; + let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?; blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await } diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 36764719..2e2402f3 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -260,6 +260,9 @@ pub async fn lsp_implementations( /// Contents of a read-only language-server document, including JDT library /// classes and disk class files. Cached per project. pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult { + if uri.starts_with("git-revision:") { + return crate::ops::vcs::revision_virtual_document(host, window, &uri).await; + } host.project(window)?.lsp.virtual_document(&uri).await } diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 42c2b294..4075dd9d 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -12,6 +12,76 @@ use std::path::{Path, PathBuf}; use crate::apply_git_executable; +/// Read-only repository access also works in a standalone log window. +struct LogAccess { + project: Option>, + external: Option>, + cache: PathBuf, +} + +/// Immutable Git objects exposed through the editor's ordinary read-only VFS. +pub async fn revision_virtual_document( + host: &Host, + window: &str, + uri: &str, +) -> IdeResult { + #[derive(Deserialize)] + struct Target { + repo: PathBuf, + revision: String, + path: PathBuf, + } + let encoded = uri + .strip_prefix("git-revision:") + .and_then(|s| s.split('/').next()) + .ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?; + if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(ide_core::IdeError::invalid("invalid Git revision URI")); + } + let bytes = (0..encoded.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&encoded[i..i + 2], 16) + .map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI")) + }) + .collect::>>()?; + let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?; + let path = target.repo.join(&target.path); + let text = vcs_file_at(host, window, path, Some(target.revision.clone())) + .await? + .ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?; + Ok(ide_lsp::types::VirtualDocument { + uri: uri.into(), + language_id: host.languages.detect(&target.path, text.lines().next()), + text, + title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(), + description: Some(format!("{} · {}", target.repo.display(), target.revision)), + decompiled: false, + }) +} +impl LogAccess { + fn new(host: &Host, window: &str, at: Option<&Path>) -> Self { + let project = host.project(window).ok(); + let external = host + .external_logs + .read() + .get(host.owner_window(window)) + .and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned()) + .filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none())); + Self { project, external, cache: host.local_data_dir.join("cache/git-log") } + } + fn with_log_repo_at(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + if let Some(repo) = &self.external { + repo.enable_log_index(&self.cache); + return f(repo); + } + self.project + .as_ref() + .ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))? + .with_log_repo_at(at, f) + } +} + /// A repository of the project (IDEA's VCS root). #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -182,8 +252,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult> { } pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option) -> IdeResult { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await } /// Gutter change markers for a file, computed against its current (unsaved) text. @@ -197,9 +267,21 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu } pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option) -> IdeResult> { - let project = host.project(window)?; + let project = host.project(window).ok(); + let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| { + rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned() + }); blocking(move || { - project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)) + if let Some(project) = project { + return project.with_log_repo_of(&path, |r| { + r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?) + }); + } + external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at( + &path, + rev.as_deref(), + encoding_rs::UTF_8, + ) }) .await } @@ -246,8 +328,124 @@ pub async fn vcs_block_history( } pub async fn vcs_log(host: &Host, window: &str, repo: Option, query: LogQuery) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await + let project = LogAccess::new(host, window, repo.as_deref()); + let request = query.request; + 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); + r.log_cancellable(&query, &cancel) + }) + }) + .await; + if let Some(id) = request { + host.end_request(id); + } + result +} + +pub async fn vcs_log_refs( + host: &Host, + window: &str, + repo: Option, +) -> IdeResult> { + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await +} + +pub async fn vcs_log_users(host: &Host, window: &str, repo: Option) -> IdeResult> { + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await +} + +pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult { + let path = dunce::canonicalize(path)?; + let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?; + let root = repo.root().to_path_buf(); + repo.set_execution_allowed(false); + repo.enable_log_index(&host.local_data_dir.join("cache/git-log")); + if let Ok(project) = host.project(window) { + project.open_external_log(&root)?; + } + let mut logs = host.external_logs.write(); + let roots = logs.entry(host.owner_window(window).to_string()).or_default(); + if !roots.iter().any(|r| r.root() == root) { + roots.push(std::sync::Arc::new(repo)); + } + Ok(root.into()) +} + +pub async fn vcs_log_details( + host: &Host, + window: &str, + repo: Option, + revision: String, +) -> IdeResult { + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await +} + +pub async fn vcs_log_details_batch( + host: &Host, + window: &str, + repo: Option, + revisions: Vec, + request: RequestId, +) -> IdeResult> { + let project = LogAccess::new(host, window, repo.as_deref()); + let cancel = host.begin_project_read_request(window, request); + let result = blocking(move || { + project.with_log_repo_at(repo.as_deref(), |r| { + revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect() + }) + }) + .await; + host.end_request(request); + 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, + repo: Option, + revision: String, + directory: String, +) -> IdeResult> { + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await +} + +pub async fn vcs_revision_files( + host: &Host, + window: &str, + repo: Option, + revision: String, +) -> IdeResult> { + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await } pub async fn vcs_commit_files( @@ -256,8 +454,8 @@ pub async fn vcs_commit_files( repo: Option, commit: String, ) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await } /// Combined changes of several commits, `commits` oldest first. @@ -267,13 +465,13 @@ pub async fn vcs_commits_changes( repo: Option, commits: Vec, ) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await } pub async fn vcs_branches(host: &Host, window: &str, repo: Option) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await } pub async fn vcs_stashes(host: &Host, window: &str, repo: Option) -> IdeResult> { @@ -628,8 +826,8 @@ pub async fn vcs_config_get( repo: Option, key: String, ) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await } /// Local history operation that may take a while (hooks, large checkouts); git's @@ -690,8 +888,8 @@ pub async fn vcs_create_tag( } pub async fn vcs_tags(host: &Host, window: &str, repo: Option) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await } /// Merge dialog: merges `branches` into the current branch. @@ -727,8 +925,8 @@ pub async fn vcs_rebase_with( } pub async fn vcs_remotes(host: &Host, window: &str, repo: Option) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await } pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option, edit: RemoteEdit) -> IdeResult<()> { @@ -815,8 +1013,8 @@ pub async fn vcs_diff_with_worktree( repo: Option, rev: String, ) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await } /// Files that differ between two revisions (the "N files updated" of Update Project). @@ -827,8 +1025,8 @@ pub async fn vcs_diff_revisions( from: String, to: String, ) -> IdeResult> { - let project = host.project(window)?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await + let project = LogAccess::new(host, window, repo.as_deref()); + blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await } /// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull @@ -1015,6 +1213,25 @@ pub(crate) fn register(d: &mut Dispatcher) { vcs_block_history(&c.host, &c.window, request, path, text, lines).await }); d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await }); + d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await }); + d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await }); + d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await }); + d.add("vcs_log_details", |c| async move { + vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await + }); + 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 + }); + d.add("vcs_revision_files", |c| async move { + vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await + }); d.add("vcs_commit_files", |c| async move { vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await }); diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index e33e4c8e..093326ab 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -34,6 +34,8 @@ pub struct Project { pub lsp: Arc, /// Git repositories: the one containing the project root and those nested below it. pub repos: Arc>, + /// Explicitly opened external logs remain read-only and outside project indexing. + pub(crate) external_log_repos: RwLock>>, /// Incrementally maintained syntax trees of the open documents. pub syntax: Arc, /// Tree-sitter structure queries (LSP fallback) over `syntax`. @@ -462,6 +464,7 @@ impl Host { index, lsp, repos, + external_log_repos: RwLock::default(), structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots, @@ -595,6 +598,7 @@ impl Host { index, lsp, repos: Arc::default(), + external_log_repos: RwLock::default(), structure: Arc::new(StructureService::new(syntax.clone())), syntax, detected_roots: Arc::default(), @@ -746,6 +750,51 @@ impl Project { } } + pub fn open_external_log(&self, path: &Path) -> IdeResult { + let root = dunce::canonicalize(path)?; + let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?; + let root = repo.root().to_path_buf(); + repo.set_execution_allowed(false); + let mut external = self.external_log_repos.write(); + if !external.iter().any(|r| r.root() == root) { + external.push(Arc::new(repo)); + } + Ok(root) + } + + /// Log read operations may also use an explicitly opened external worktree. + /// Mutations continue to use `with_repo_at` and cannot reach these repositories. + pub fn with_log_repo_at(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + if let Some(path) = at + && self.repos().at_root(path).is_none() + { + let repo = self + .external_log_repos + .read() + .iter() + .find(|r| r.root() == path) + .cloned() + .ok_or_else(|| IdeError::unavailable("external Git log is not open"))?; + return f(&repo); + } + self.with_repo_at(at, f) + } + + pub fn with_log_repo_of(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + if self.repos().for_path(path).is_some() { + return self.with_repo_of(path, f); + } + let repo = self + .external_log_repos + .read() + .iter() + .filter(|r| path.starts_with(r.root())) + .max_by_key(|r| r.root().components().count()) + .cloned() + .ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?; + f(&repo) + } + /// Runs `f` with the repository `path` (a file or folder) belongs to. pub fn with_repo_of(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { match self.repos().for_path(path) { @@ -962,7 +1011,7 @@ impl Project { return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes()) .ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding")); } - let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default(); + let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default(); Ok(ide_vfs::detect_encoding(&bytes, fallback)) } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index e365ada5..5e2a0945 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -307,6 +307,14 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "vcs_blame" | "vcs_block_history" | "vcs_log" + | "vcs_log_refs" + | "vcs_log_users" + | "vcs_log_details" + | "vcs_log_details_batch" + | "vcs_log_index" + | "vcs_revision_children" + | "vcs_revision_files" + | "vcs_open_external_log" | "vcs_commit_files" | "vcs_commits_changes" | "vcs_branches" diff --git a/crates/ide-host/tests/external_log.rs b/crates/ide-host/tests/external_log.rs new file mode 100644 index 00000000..83d58f04 --- /dev/null +++ b/crates/ide-host/tests/external_log.rs @@ -0,0 +1,115 @@ +//! Explicit external logs allow history reads without adding a writable project root. +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc}; + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(root) + .args(["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false"]) + .args(args) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_history_is_readable_and_never_becomes_a_write_target() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("project"); + let external = base.join("external"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&external).unwrap(); + git(&external, &["init", "-b", "main"]); + let deleted = external.join("deleted.txt"); + std::fs::write(&deleted, "historical content\n").unwrap(); + git(&external, &["add", "."]); + git(&external, &["commit", "-m", "initial"]); + let first = git(&external, &["rev-parse", "HEAD"]); + git(&external, &["rm", "deleted.txt"]); + git(&external, &["commit", "-m", "delete"]); + let head = git(&external, &["rev-parse", "HEAD"]); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + // A standalone editor needs no project, indexer, or writable VCS root. + vcs::vcs_open_external_log(&host, "standalone", external.clone()).await.unwrap(); + let standalone = vcs::vcs_log(&host, "standalone", Some(external.clone()), Default::default()).await.unwrap(); + assert_eq!(standalone[0].id, head); + let details = vcs::vcs_log_details_batch( + &host, + "standalone", + Some(external.clone()), + vec![head.clone(), first.clone()], + ide_core::RequestId(987), + ) + .await + .unwrap(); + assert!(details.iter().all(|d| d.signature == "N")); + assert_eq!( + vcs::vcs_revision_children(&host, "standalone", Some(external.clone()), first.clone(), String::new()) + .await + .unwrap(), + vec!["deleted.txt"] + ); + assert!(host.project("standalone").is_err()); + host.trust.decide(&external, false, false).unwrap(); + let restricted = host.open_project("restricted", &external).unwrap(); + assert!(!restricted.is_trusted()); + vcs::vcs_open_external_log(&host, "restricted", external.clone()).await.unwrap(); + let restricted_log = vcs::vcs_log( + &host, + "restricted", + Some(external.clone()), + ide_vcs::LogQuery { request: Some(ide_core::RequestId(988)), summary_only: true, ..Default::default() }, + ) + .await + .unwrap(); + assert_eq!(restricted_log[0].id, head); + assert_eq!( + vcs::vcs_log_details_batch( + &host, + "restricted", + Some(external.clone()), + vec![head.clone()], + ide_core::RequestId(989) + ) + .await + .unwrap()[0] + .signature, + "N" + ); + host.close_project("restricted"); + let payload = + serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap(); + let hex = payload.iter().map(|b| format!("{b:02x}")).collect::(); + let document = + vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap(); + assert_eq!(document.text, "historical content\n"); + host.trust.decide(&root, true, false).unwrap(); + let project = host.open_project("main", &root).unwrap(); + assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err()); + vcs::vcs_open_external_log(&host, "main", external.clone()).await.unwrap(); + let log = vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.unwrap(); + assert_eq!(log.iter().map(|c| c.id.as_str()).collect::>(), vec![head.as_str(), first.as_str()]); + assert_eq!( + vcs::vcs_file_at(&host, "main", deleted.clone(), Some(first.clone())).await.unwrap().as_deref(), + Some("historical content\n") + ); + assert!(project.repos().at_root(&external).is_none()); + assert!(vcs::vcs_checkout(&host, "main", Some(external.clone()), first.clone(), false, None).await.is_err()); + assert!(vcs::vcs_apply_patch(&host, "main", Some(external.clone()), String::new()).await.is_err()); + assert_eq!(git(&external, &["rev-parse", "HEAD"]), head); + assert!(!deleted.exists()); + host.close_project("main"); +} diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs index c8a2c36c..ce814b1c 100644 --- a/crates/ide-remote/src/mapping.rs +++ b/crates/ide-remote/src/mapping.rs @@ -143,7 +143,10 @@ impl Mappings { } let (l, rest, _) = best?; // A `..` from the server must not leave the local folder. - if rest.split('/').any(|s| s == ".." || s == ".") { + if rest.split('/').filter(|s| !s.is_empty()).any(|s| { + let mut components = Path::new(s).components(); + !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() + }) { return None; } Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s))) @@ -307,6 +310,43 @@ mod tests { assert_eq!(local_folder("/src/", &root), Some(root.join("src"))); } + #[cfg(windows)] + #[test] + fn native_anchors_never_replace_the_project_relative_fallback() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let inside = root.join("src"); + let outside = tmp.path().join("outside"); + let missing = tmp.path().join("missing"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir(&outside).unwrap(); + for slash in ["\\", "/"] { + let spelling = |path: &Path| path.to_string_lossy().replace('\\', slash); + assert_eq!(local_folder(&spelling(&inside), &root), Some(inside.clone())); + assert_eq!(local_folder(&spelling(&outside), &root), None); + assert_eq!(local_folder(&spelling(&missing), &root), Some(root.join(posix(&missing)))); + assert_eq!(local_folder(&format!("{slash}src{slash}"), &root), Some(inside.clone())); + } + } + + #[test] + fn remote_paths_cannot_replace_or_leave_a_local_mapping() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let m = Mappings::new(&server(vec![]), root, "/srv"); + for remote in ["/srv/../outside", "/srv/./file"] { + assert_eq!(m.local(remote), None, "{remote}"); + } + #[cfg(windows)] + for remote in + ["/srv/..\\outside", "/srv/C:\\outside", "/srv/\\outside", "/srv/dir\\..\\outside", "/srv/C:/outside"] + { + assert_eq!(m.local(remote), None, "{remote}"); + } + assert_eq!(m.local("/srv/dir/file"), Some(root.join("dir/file"))); + assert_eq!(m.local("/srv"), Some(root.to_path_buf())); + } + #[cfg(unix)] #[test] fn absolute_symlink_inside_project_resolves_to_the_project_spelling() { diff --git a/crates/ide-vcs/Cargo.toml b/crates/ide-vcs/Cargo.toml index 4d79193c..512b1bde 100644 --- a/crates/ide-vcs/Cargo.toml +++ b/crates/ide-vcs/Cargo.toml @@ -15,6 +15,8 @@ serde_json.workspace = true specta.workspace = true gix = { workspace = true, features = ["sha1", "sha256", "status", "blob-diff", "revision", "index", "dirwalk", "parallel"] } tracing.workspace = true +redb.workspace = true +blake3.workspace = true tempfile.workspace = true which = "8" regex-lite = "0.1" diff --git a/crates/ide-vcs/src/bek.rs b/crates/ide-vcs/src/bek.rs new file mode 100644 index 00000000..ad94665b --- /dev/null +++ b/crates/ide-vcs/src/bek.rs @@ -0,0 +1,192 @@ +//! Bek branch partitioning and bottom-up block merge. +//! Adapted from JetBrains intellij-community BekBranchCreator/BekBranch/Merger, +//! Copyright 2000-2026 JetBrains s.r.o. and contributors, Apache-2.0. +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; + +pub(crate) fn sort(parents: &[Vec], times: &[i64], heads: &[usize]) -> Vec { + let n = parents.len(); + let mut children = vec![Vec::new(); n]; + for (i, ps) in parents.iter().enumerate() { + for &p in ps { + children[p].push(i); + } + } + let mut layout = vec![0usize; n]; + let mut important = Vec::new(); + let mut next_layout = 1; + for &head in heads { + if layout[head] != 0 { + continue; + } + important.push(head); + let mut stack = vec![head]; + while let Some(&node) = stack.last() { + let first = layout[node] == 0; + if first { + layout[node] = next_layout; + } + if let Some(&p) = parents[node].iter().find(|&&p| layout[p] == 0) { + stack.push(p); + } else { + if first { + next_layout += 1; + } + stack.pop(); + } + } + } + let mut done = vec![false; n]; + let mut restrictions = vec![HashSet::new(); n]; + let mut releases = vec![Vec::new(); n]; + let mut branches = Vec::new(); + for head in important { + if done[head] { + continue; + } + let start = layout[head]; + done[head] = true; + let mut branch = vec![head]; + let mut stack = vec![head]; + while let Some(&node) = stack.last() { + let mut next = None; + for &p in parents[node].iter().rev() { + if done[p] { + if layout[p] < start && restrictions[node].insert(p) { + releases[p].push(node); + } + } else if layout[node] <= layout[p] && !children[p].iter().any(|&c| !done[c] && layout[c] <= layout[p]) + { + next = Some(p); + break; + } + } + if let Some(p) = next { + done[p] = true; + branch.push(p); + stack.push(p); + } else { + stack.pop(); + } + } + branches.push(branch); + } + let mut owner = vec![0usize; n]; + for (b, nodes) in branches.iter().enumerate() { + for &node in nodes { + owner[node] = b; + } + } + let mut remaining: Vec<_> = branches.iter().map(Vec::len).collect(); + let mut prepared = vec![None; branches.len()]; + let mut queue = BinaryHeap::new(); + let prepare = |b: usize, + remaining: &[usize], + restrictions: &[HashSet], + prepared: &mut [Option], + queue: &mut BinaryHeap>| { + let end = remaining[b]; + if end == 0 || prepared[b].is_some() || !restrictions[branches[b][end - 1]].is_empty() { + return; + } + let mut start = end - 1; + while start > 0 { + let up = branches[b][start - 1]; + let down = branches[b][start]; + if !restrictions[up].is_empty() { + break; + } + let connects = parents[up].contains(&down); + if !(parents[up].len() > 1 && connects) { + if !connects { + break; + } + let delta = times[up].abs_diff(times[down]); + if delta > 3 * 24 * 3600 || (start + 20 < end && delta > 4 * 3600) { + break; + } + } + start -= 1; + } + prepared[b] = Some(start); + queue.push(Reverse((times[branches[b][start]], b))); + }; + for b in 0..branches.len() { + prepare(b, &remaining, &restrictions, &mut prepared, &mut queue); + } + let mut result = vec![0; n]; + let mut offset = n; + while let Some(Reverse((_, b))) = queue.pop() { + let start = prepared[b].take().unwrap(); + let part = &branches[b][start..remaining[b]]; + offset -= part.len(); + result[offset..offset + part.len()].copy_from_slice(part); + remaining[b] = start; + let mut released = HashSet::from([b]); + for &node in part { + for &up in &releases[node] { + restrictions[up].remove(&node); + let branch = owner[up]; + if restrictions[up].is_empty() && remaining[branch] > 0 && branches[branch][remaining[branch] - 1] == up + { + released.insert(branch); + } + } + } + for branch in released { + prepare(branch, &remaining, &restrictions, &mut prepared, &mut queue); + } + } + debug_assert_eq!(offset, 0, "Bek must place every node"); + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn branch_blocks_preserve_every_edge_under_clock_skew_and_interleaved_refs() { + let mut seed = 17u64; + let mut random = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + seed as usize + }; + for _ in 0..200 { + let n = 70; + let parents: Vec> = (0..n) + .map(|i| { + let mut ps = Vec::new(); + if i + 1 < n { + for _ in 0..(random() % 3 + 1) { + let p = i + 1 + random() % (n - i - 1); + if !ps.contains(&p) { + ps.push(p); + } + } + } + ps + }) + .collect(); + let times: Vec<_> = (0..n).map(|_| (random() % 1_000_000) as i64).collect(); + let mut children = vec![0; n]; + for ps in &parents { + for &p in ps { + children[p] += 1; + } + } + let mut heads: Vec<_> = (0..n).filter(|&i| children[i] == 0 || i % 11 == 0).collect(); + heads.sort_by_cached_key(|_| random()); + let result = sort(&parents, ×, &heads); + assert_eq!(result.iter().copied().collect::>().len(), n); + let mut positions = vec![0; n]; + for (i, &node) in result.iter().enumerate() { + positions[node] = i; + } + for (node, ps) in parents.iter().enumerate() { + for &p in ps { + assert!(positions[node] < positions[p]); + } + } + } + } +} diff --git a/crates/ide-vcs/src/history.rs b/crates/ide-vcs/src/history.rs index ebdc7b27..be0da59b 100644 --- a/crates/ide-vcs/src/history.rs +++ b/crates/ide-vcs/src/history.rs @@ -4,9 +4,8 @@ use crate::repo::{gx, resolve, to_string}; use crate::{ChangeKind, ChangedFile, Commit, LogOrder, LogQuery, Repo, RevisionChange}; use gix::ObjectId; use gix::bstr::ByteSlice; -use ide_core::{IdeError, IdeResult}; +use ide_core::{CancellationToken, IdeError, IdeResult}; use std::collections::{HashMap, HashSet}; -use std::path::Path; /// Log "Text or hash" filter: the message (substring or regex), or a hash prefix. enum TextFilter { @@ -50,52 +49,283 @@ impl TextFilter { } } -pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { +pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut index = r.log_index.lock().unwrap_or_else(|p| p.into_inner()); + let result = if q.summary_only { + let mut all = q.clone(); + all.skip = 0; + all.limit = 0; + all.seek = None; + all.retain_until = None; + all.request = None; + all.stop_at = None; + let refs = r.log_refs()?; + let shallow = r + .gix()? + .shallow_commits() + .map_err(gx)? + .map(|ids| ids.iter().copied().collect::>()) + .unwrap_or_default(); + let key = format!("{}:{refs:?}:{shallow:?}", serde_json::to_string(&all).map_err(gx)?); + let cached = index.views.get(&key).cloned(); + let view = if let Some(cached) = cached { + cached + } else { + // Immutable metadata is reused across roots, filters, merges and ref changes. + all.request = q.request; + let commits = + std::sync::Arc::new(crate::log_index::IndexedView::new(log_indexed(r, &all, cancel, &mut index)?)); + if index.views.len() >= 4 { + index.views.clear(); + } + index.views.insert(key, commits.clone()); + commits + }; + let start = q + .seek + .as_ref() + .and_then(|id| view.positions.get(id).copied()) + .map(|i| i.saturating_sub(if q.limit == 0 { 50 } else { 50.min(q.limit as usize / 2) })) + .unwrap_or(q.skip as usize); + let retain = q.retain_until.as_ref().and_then(|id| view.positions.get(id).copied()).map(|i| i + 1).unwrap_or(0); + let limit = if q.limit == 0 { usize::MAX } else { (q.limit as usize).max(retain.saturating_sub(start)) }; + let mut result = Vec::new(); + for (i, c) in view.commits.iter().enumerate().skip(start).take(limit) { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut c = c.clone(); + c.log_position = Some(i as u32); + c.log_total = Some(view.commits.len() as u32); + let stop = q.stop_at.as_ref() == Some(&c.id); + result.push(c); + if stop { + break; + } + } + Ok(result) + } else { + log_indexed(r, q, cancel, &mut index) + }; + index.flush(); + result +} + +fn log_indexed( + r: &Repo, + q: &LogQuery, + cancel: &CancellationToken, + index: &mut crate::log_index::LogIndex, +) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } let repo = r.gix()?; + let shallow: HashSet = + repo.shallow_commits().map_err(gx)?.map(|ids| ids.iter().copied().collect()).unwrap_or_default(); let range = q.range.as_deref().map(str::trim).filter(|s| !s.is_empty()); let revs: Vec<&str> = q.branch.iter().chain(&q.branches).map(|b| b.trim()).filter(|b| !b.is_empty()).collect(); - let tips = if range.is_some() { Vec::new() } else { tips(&repo, &revs)? }; - if tips.is_empty() && range.is_none() { + let ranges: Vec<_> = q.ranges.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let tips = + if range.is_some() || (!ranges.is_empty() && revs.is_empty()) { Vec::new() } else { tips(&repo, &revs)? }; + if tips.is_empty() && range.is_none() && ranges.is_empty() { return Ok(Vec::new()); } let decorations = decorations(&repo)?; - let path_filter = match q.path.as_deref() { - Some(p) => { - let rel = r.rel_path(p)?; - if rel.is_empty() { None } else { Some(rel) } + let paths: Vec = q + .path + .iter() + .chain(&q.paths) + .map(|p| r.rel_path(p)) + .collect::>>()? + .into_iter() + .filter(|p| !p.is_empty()) + .collect(); + let range_ids = if let Some(range) = range { + Some(range_ids(r, range, q.order, q.first_parent, cancel)?) + } else if !ranges.is_empty() { + let mut ids = Vec::new(); + let mut seen = HashSet::new(); + for range in &ranges { + for id in range_ids(r, range, q.order, q.first_parent, cancel)? { + if seen.insert(id) { + ids.push(id); + } + } } - None => None, + if !tips.is_empty() { + for id in range_ids( + r, + &tips.iter().map(ToString::to_string).collect::>().join(" "), + q.order, + q.first_parent, + cancel, + )? { + if seen.insert(id) { + ids.push(id); + } + } + } + Some(sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &index.nodes)?) + } else { + None }; - let authors: Vec = - q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect(); - let text = q.text.as_deref().map(|t| TextFilter::new(t, q.text_regex, q.text_match_case)).transpose()?.flatten(); - let limit = if q.limit == 0 { usize::MAX } else { q.limit as usize }; - let mut to_skip = q.skip as usize; - - let path_walk = path_filter.is_some(); - type Walk<'a> = Box)>> + 'a>; - let walk: Walk<'_> = if let Some(range) = range { - let ids = range_ids(r, range, path_filter.as_deref())?; - let repo = &repo; - Box::new(ids.into_iter().map(move |id| { - let commit = repo.find_commit(id).map_err(gx)?; - Ok((id, commit.parent_ids().map(|p| p.detach()).collect())) - })) - } else if let Some(rel) = path_filter { - Box::new(PathWalk::new(&repo, tips, rel, q.first_parent)?) - } else if repo.is_shallow().unwrap_or(false) { - // Topological order needs complete history; shallow clones fall back to date order. - let mut walk = repo.rev_walk(tips).sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default())); - if q.first_parent { - walk = walk.first_parent_only(); + let mut history_paths = HashMap::new(); + let path_ids = if paths.is_empty() { + None + } else { + 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(); + } + } + } + } 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::>>()?, + ); } - let walk = walk.all().map_err(gx)?; - Box::new(walk.map(|info| info.map(|i| (i.id, i.parent_ids.to_vec())).map_err(gx))) + Some(ids) + }; + 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(), + matches: HashMap::new(), + projected: HashMap::new(), + cancel, + index, + shallow: shallow.clone(), + }; + type Walk<'a> = Box> + 'a>; + let walk: Walk<'_> = if let Some(ids) = range_ids { + Box::new(ids.into_iter().map(Ok)) + } else if q.summary_only { + let ids = visibility.index.graph(&repo, &tips, &shallow, q.first_parent, cancel)?; + Box::new( + sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &visibility.index.nodes)? + .into_iter() + .map(Ok), + ) + } else if repo.is_shallow().unwrap_or(false) || q.request.is_some() { + // The process can be interrupted even during indegree preparation. Git also + // respects shallow boundaries while retaining a topological order. + let mut args = vec![ + "rev-list".to_string(), + match q.order { + LogOrder::Topo | LogOrder::Standard => "--topo-order", + LogOrder::Date => "--date-order", + } + .into(), + ]; + if q.first_parent { + args.push("--first-parent".into()); + } + if q.limit > 0 + && q.order != LogOrder::Standard + && paths.is_empty() + && q.authors.is_empty() + && q.author.is_none() + && q.text.as_deref().is_none_or(|text| text.trim().is_empty()) + && !q.no_merges + && q.since_ms.is_none() + && q.until_ms.is_none() + { + args.push(format!("--max-count={}", u64::from(q.skip) + u64::from(q.limit))); + } + args.push("--end-of-options".into()); + args.extend(tips.iter().map(ToString::to_string)); + let result = + crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::>(), cancel)?; + let ids = result + .stdout_str() + .lines() + .map(|line| ObjectId::from_hex(line.as_bytes()).map_err(gx)) + .collect::>>()?; + if repo.is_shallow().unwrap_or(false) { + // A separately bounded walk provides the domain; pagination must never + // turn a shallow boundary into a connection through a locally present object. + let mut boundary_args = vec!["rev-list", "--end-of-options"]; + let ids_as_text: Vec<_> = tips.iter().map(ToString::to_string).collect(); + boundary_args.extend(ids_as_text.iter().map(String::as_str)); + let boundaries = crate::cli::run_cancellable(r.root(), &boundary_args, cancel)?; + visibility.range_ids = Some( + boundaries + .stdout_str() + .lines() + .map(|id| ObjectId::from_hex(id.as_bytes()).map_err(gx)) + .collect::>>()?, + ); + } + Box::new(ids.into_iter().map(Ok)) } else { use gix::traverse::commit::{Parents, topo}; let graph = repo.commit_graph_if_enabled().ok().flatten(); let sorting = match q.order { - LogOrder::Topo => topo::Sorting::TopoOrder, + LogOrder::Topo | LogOrder::Standard => topo::Sorting::TopoOrder, LogOrder::Date => topo::Sorting::DateOrder, }; let topo = topo::Builder::from_iters(&repo.objects, tips, None::>) @@ -104,226 +334,385 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult> { .with_commit_graph(graph) .build() .map_err(gx)?; - Box::new(topo.map(|info| info.map(|i| (i.id, i.parent_ids.to_vec())).map_err(gx))) + Box::new(topo.map(|info| info.map(|i| i.id).map_err(gx))) }; - - let shape = Shape { - no_merges: q.no_merges, - first_parent: q.first_parent, - since: q.since_ms.map(|ms| (ms / 1000.0).floor() as i64), - until: q.until_ms.map(|ms| (ms / 1000.0).ceil() as i64), + let walk: Walk<'_> = if q.order == LogOrder::Standard { + let ids = walk.collect::>>()?; + Box::new( + sort_subset(&repo, ids, q.order, q.first_parent, &shallow, cancel, &visibility.index.nodes)? + .into_iter() + .map(Ok), + ) + } else { + walk }; - // Walks in (roughly) commit time order can stop once they are well past `since`. - let time_ordered = range.is_none() && (q.order == LogOrder::Date || path_walk); - let mut older_run = 0usize; - // A user or text filter hides commits within the history: the shown ones are linked to their - // nearest shown ancestors instead (dashed past hidden ones), as IDEA's filtered graph does. - // A path walk already links commits that way. - let mut links = (!path_walk && (!authors.is_empty() || text.is_some())).then(FilteredLinks::default); - let mut extra = 0usize; - - let mut out = Vec::new(); - for item in walk { - let (id, mut parents) = item?; - if q.first_parent { - parents.truncate(1); - } - if out.len() >= limit { - // Full: walk on only to link the last commits to their shown ancestors. - let Some(l) = links.as_ref() else { break }; - extra += 1; - if l.is_empty() || extra > LINK_LIMIT { - break; - } - } - let commit = repo.find_commit(id).map_err(gx)?; - let commit_time = commit.time().map(|t| t.seconds).unwrap_or(0); - let mut shown = true; - if shape.is_active() { - let merge = commit.parent_ids().nth(1).is_some(); - if shape.since.is_some_and(|s| commit_time < s) { - older_run += 1; - if time_ordered && older_run >= OLDER_RUN_LIMIT { + let head = repo.head_id().ok().map(|id| id.detach()); + let shallow_stamp = + repo.shallow_file().ok().and_then(|path| std::fs::metadata(path).ok()).and_then(|m| m.modified().ok()); + let stamp = head.map(|id| (id, shallow_stamp)); + if visibility.index.head != stamp { + let mut additions = Vec::new(); + let mut cursor = head; + let old_head = visibility.index.head; + if let (Some((old, old_shallow)), Some(_)) = (old_head, head) + && old_shallow == shallow_stamp + { + while let Some(id) = cursor { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if id == old { break; } - shown = false; - } else { - older_run = 0; - if !shape.shows(merge, commit_time) { - shown = false; - } else if links.is_none() { - parents = shape.rewrite_parents(&repo, &parents)?; + let record = visibility.index.get(&repo, id)?; + if record.parents.len() != 1 { + cursor = None; + break; + } + additions.push(id); + cursor = Some(ObjectId::from_hex(record.parents[0].as_bytes()).map_err(gx)?); + } + } else { + cursor = None; + } + if cursor.is_some() && cursor == old_head.map(|(id, _)| id) { + visibility.index.reachable.extend(additions); + visibility.index.head = stamp; + } else { + let mut reachable = HashSet::new(); + if let Some(head) = head { + for item in repo.rev_walk([head]).all().map_err(gx)? { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + reachable.insert(item.map_err(gx)?.id); } } - } - let sig = commit.author().map_err(gx)?; - let name = to_string(sig.name.trim().as_bstr()); - let email = to_string(sig.email.trim().as_bstr()); - if shown && !authors.is_empty() { - let (name, email) = (name.to_lowercase(), email.to_lowercase()); - shown = authors.iter().any(|a| name.contains(a.as_str()) || email.contains(a.as_str())); - } - let message = to_string(commit.message_raw_sloppy()); - if shown && let Some(filter) = text.as_ref() { - shown = filter.matches(&message, &id); - } - let listed = shown && to_skip == 0 && out.len() < limit; - if let Some(l) = links.as_mut() { - l.visit(id, &parents, shown, listed.then_some(out.len())); - } - if !shown { - continue; - } - if to_skip > 0 { - to_skip -= 1; - continue; - } - if !listed { - continue; - } - let time_ms = sig.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0); - let summary = message.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string(); - let hex = id.to_string(); - out.push(Commit { - short_id: hex[..hex.len().min(8)].to_string(), - id: hex, - parents: parents.iter().map(ToString::to_string).collect(), - author: name, - email, - time_ms, - summary, - message: message.trim_end().to_string(), - refs: decorations.get(&id).cloned().unwrap_or_default(), - commit_time_ms: commit_time as f64 * 1000.0, - dashed: Vec::new(), - }); - if out.len() >= limit && links.is_none() { - break; + visibility.index.reachable = reachable; + visibility.index.head = stamp; } } - if let Some(l) = links { - l.apply(&mut out); + let mut out = Vec::new(); + let mut skip = q.skip as usize; + let limit = if q.limit == 0 { usize::MAX } else { q.limit as usize }; + for item in walk { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let id = item?; + if !visibility.shows(&repo, id)? { + continue; + } + if skip > 0 { + skip -= 1; + continue; + } + let record = visibility.index.get(&repo, id)?; + let parents = record + .parents + .iter() + .map(|p| ObjectId::from_hex(p.as_bytes()).map_err(gx)) + .collect::>>()?; + let shown_parents = if shallow.contains(&id) { Vec::new() } else { visibility.parents(&repo, &parents)? }; + let mut commit = (*record).clone(); + commit.graph_dashed = shown_parents.iter().map(|p| !parents.contains(p)).collect(); + commit.dashed = if commit.graph_dashed.iter().any(|d| *d) { commit.graph_dashed.clone() } else { Vec::new() }; + commit.graph_parents = Some(shown_parents.iter().map(ToString::to_string).collect()); + if shallow.contains(&id) { + commit.graph_boundary_parents = commit.parents.clone(); + } + commit.on_current_branch = Some(visibility.index.reachable.contains(&id)); + commit.refs = decorations.get(&id).cloned().unwrap_or_default(); + commit.history_path = history_paths.get(&id).cloned(); + if !q.summary_only { + commit.message = visibility.index.message(&repo, id)?; + commit.message_truncated = false; + } + out.push(commit); + if out.len() >= limit || q.stop_at.as_deref() == Some(id.to_string().as_str()) { + break; + } } Ok(out) } -/// Commits walked past a full page to link its last commits to their shown ancestors. -const LINK_LIMIT: usize = 20_000; - -/// The links of a filtered log: each listed commit's nearest shown ancestors. The walk visits -/// children before parents, so a listed commit waits on its parents; a hidden commit passes -/// the waits on to its own parents, and a shown one takes them. -#[derive(Default)] -struct FilteredLinks { - /// Commit → listed commits (by index) waiting on it, and whether the way passed a hidden one. - waiting: HashMap>, - /// Per listed commit: its shown ancestors in the order found, and whether each link is dashed. - found: HashMap>, -} - -impl FilteredLinks { - fn is_empty(&self) -> bool { - self.waiting.is_empty() - } - - fn visit(&mut self, id: ObjectId, parents: &[ObjectId], shown: bool, listed: Option) { - let waits = self.waiting.remove(&id).unwrap_or_default(); - if shown { - for (child, dashed) in waits { - let links = self.found.entry(child).or_default(); - match links.iter_mut().find(|(p, _)| *p == id) { - Some(link) => link.1 &= dashed, - None => links.push((id, dashed)), - } - } - if let Some(index) = listed { - for p in parents { - Self::wait(&mut self.waiting, *p, index, false); - } - } - } else { - for p in parents { - for (child, _) in &waits { - Self::wait(&mut self.waiting, *p, *child, true); - } - } - } - } - - fn wait(waiting: &mut HashMap>, on: ObjectId, child: usize, dashed: bool) { - let list = waiting.entry(on).or_default(); - match list.iter_mut().find(|(c, _)| *c == child) { - Some(w) => w.1 &= dashed, - None => list.push((child, dashed)), - } - } - - /// Replaces the listed commits' parents by their shown ancestors (none found: none). - fn apply(mut self, out: &mut [Commit]) { - for (index, commit) in out.iter_mut().enumerate() { - let links = self.found.remove(&index).unwrap_or_default(); - commit.parents = links.iter().map(|(id, _)| id.to_string()).collect(); - commit.dashed = - if links.iter().any(|(_, d)| *d) { links.iter().map(|(_, d)| *d).collect() } else { Vec::new() }; - } - } -} - -/// Consecutive commits older than `since` after which a time-ordered walk stops. -const OLDER_RUN_LIMIT: usize = 64; - -/// Which commits the Log graph shows independently of the detail filters (author, text): -/// `--no-merges` and the date range. Hidden commits are skipped and the parents of shown -/// ones rewritten past them, like git's parent rewriting. -struct Shape { - no_merges: bool, +/// A ready-node stack gives incoming merge parents priority, while child counts +/// prevent an ancestor shared with another branch from moving ahead of its children. +fn sort_subset( + repo: &gix::Repository, + ids: Vec, + order: LogOrder, first_parent: bool, - /// Commit time bounds in seconds; `since` inclusive, `until` exclusive. - since: Option, - until: Option, + shallow: &HashSet, + cancel: &CancellationToken, + nodes: &HashMap, i64)>, +) -> IdeResult> { + let positions: HashMap<_, _> = ids.iter().enumerate().map(|(i, id)| (*id, i)).collect(); + let mut parents = Vec::with_capacity(ids.len()); + let mut children = vec![0usize; ids.len()]; + let mut times = Vec::with_capacity(ids.len()); + for id in &ids { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let (all_parents, time) = if let Some(record) = nodes.get(id) { + record.clone() + } else { + let commit = repo.find_commit(*id).map_err(gx)?; + (commit.parent_ids().map(|p| p.detach()).collect(), commit.time().map(|t| t.seconds).unwrap_or(0)) + }; + times.push(time); + let mut ordered = Vec::new(); + let mut pending: Vec<_> = all_parents + .into_iter() + .take(if shallow.contains(id) { + 0 + } else if first_parent { + 1 + } else { + usize::MAX + }) + .collect(); + let mut visited = HashSet::new(); + while let Some(parent) = pending.pop() { + if !visited.insert(parent) { + continue; + } + if let Some(position) = positions.get(&parent) { + if !ordered.contains(position) { + ordered.push(*position); + } + } else if !shallow.contains(&parent) && repo.has_object(parent) { + pending.extend( + repo.find_commit(parent) + .map_err(gx)? + .parent_ids() + .take(if first_parent { 1 } else { usize::MAX }) + .map(|p| p.detach()), + ); + } + } + ordered.reverse(); + for p in &ordered { + children[*p] += 1; + } + parents.push(ordered); + } + if order == LogOrder::Standard { + let refs = decorations(repo)?; + let mut remote_names = HashSet::new(); + for reference in repo.references().map_err(gx)?.remote_branches().map_err(gx)?.flatten() { + if let Some(name) = reference.name().as_bstr().strip_prefix(b"refs/remotes/") { + remote_names.insert(to_string(name.as_bstr())); + } + } + let priority = |i: usize| { + let labels = refs.get(&ids[i]); + let best = labels + .into_iter() + .flatten() + .map(|name| { + ( + if name == "HEAD" { + 0 + } else if name == "main" || name == "master" { + 1 + } else if name.starts_with("tag: ") { + 4 + } else if remote_names.contains(name) { + 3 + } else { + 2 + }, + name.clone(), + ) + }) + .min() + .unwrap_or((5, ids[i].to_string())); + (best, i) + }; + let mut heads: Vec<_> = (0..ids.len()).filter(|i| children[*i] == 0 || refs.contains_key(&ids[*i])).collect(); + heads.sort_by_key(|i| priority(*i)); + let sorted = crate::bek::sort(&parents, ×, &heads); + return Ok(sorted.into_iter().map(|i| ids[i]).collect()); + } + let mut ready: std::collections::BTreeSet<_> = + children.iter().enumerate().filter_map(|(i, count)| (*count == 0).then_some(i)).collect(); + let mut stack = Vec::new(); + let mut dates = std::collections::BinaryHeap::new(); + if order == LogOrder::Date { + for &i in &ready { + dates.push((times[i], std::cmp::Reverse(i))); + } + } + let mut emitted = HashSet::new(); + let mut out = Vec::with_capacity(ids.len()); + while let Some(next) = + if order == LogOrder::Date { dates.pop().map(|(_, i)| i.0) } else { stack.pop().or_else(|| ready.pop_first()) } + { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if !emitted.insert(next) { + continue; + } + ready.remove(&next); + out.push(ids[next]); + let ordered: Vec<_> = + if order == LogOrder::Topo { parents[next].iter().rev().copied().collect() } else { parents[next].clone() }; + for parent in ordered { + children[parent] -= 1; + if children[parent] == 0 { + ready.insert(parent); + stack.push(parent); + if order == LogOrder::Date { + dates.push((times[parent], std::cmp::Reverse(parent))); + } + } + } + } + Ok(out) } -/// Upper bound of commits visited while rewriting one commit's parents. -const REWRITE_LIMIT: usize = 4096; +/// Visibility and graph projection share exactly the same predicates. Revision parents +/// remain immutable; only graph parents skip hidden commits. Memoisation avoids walking +/// the same hidden ancestry for each visible child, without a truncation limit. +struct Visibility<'a> { + speed_refs: &'a HashMap>, + query: &'a LogQuery, + path_ids: Option>, + range_ids: Option>, + authors: Vec, + text: Option, + text_candidates: Option>, + user_candidates: Option>, + path_candidates: Option>, + matches: HashMap, + projected: HashMap>, + cancel: &'a CancellationToken, + index: &'a mut crate::log_index::LogIndex, + shallow: HashSet, +} -impl Shape { - fn is_active(&self) -> bool { - self.no_merges || self.since.is_some() || self.until.is_some() +impl Visibility<'_> { + fn shows(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult { + if self.cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + 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) + || self.path_ids.as_ref().is_some_and(|ids| !ids.contains(&id)) + || self.range_ids.as_ref().is_some_and(|ids| !ids.contains(&id)) + { + false + } else { + let commit = self.index.get(repo, id)?; + let time = commit.commit_time_ms; + let name = commit.author.to_lowercase(); + let email = commit.email.to_lowercase(); + !(self.query.no_merges && commit.parents.len() > 1) + && self.query.since_ms.is_none_or(|s| time >= s) + && self.query.until_ms.is_none_or(|u| time < u) + && self.query.speed_search.as_ref().is_none_or(|query| { + let mut columns = + vec![commit.summary.clone(), self.speed_refs.get(&id).cloned().unwrap_or_default().join(" ")]; + if self.query.search_fields.iter().any(|f| f == "author") { + columns.push(commit.author.clone()); + } + if self.query.search_fields.iter().any(|f| f == "hash") { + columns.push(commit.short_id.clone()); + } + query.to_lowercase().split_whitespace().all(|word| { + columns.iter().any(|column| { + let mut chars = word.chars().peekable(); + for c in column.to_lowercase().chars() { + if chars.peek() == Some(&c) { + chars.next(); + } + } + chars.peek().is_none() + }) + }) + }) + && (self.authors.is_empty() + || self.authors.iter().any(|a| { + if self.query.authors_exact { + crate::log_index::matches_user(a, &name, &email) + } else { + name.contains(a.as_str()) || email.contains(a.as_str()) + } + })) + && 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) + } + None => true, + } + }; + self.matches.insert(id, shown); + Ok(shown) } - fn shows(&self, merge: bool, time: i64) -> bool { - !(self.no_merges && merge) && self.since.is_none_or(|s| time >= s) && self.until.is_none_or(|u| time < u) - } - - /// `parents` with each hidden one replaced by its nearest shown ancestors; parents - /// older than `since` are dropped (their history is not shown). - fn rewrite_parents(&self, repo: &gix::Repository, parents: &[ObjectId]) -> IdeResult> { - let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - let mut stack: Vec = parents.iter().rev().copied().collect(); - let mut visited = 0usize; - while let Some(p) = stack.pop() { - if !seen.insert(p) || !repo.has_object(p) { - continue; + fn parents(&mut self, repo: &gix::Repository, parents: &[ObjectId]) -> IdeResult> { + let parents = if self.query.first_parent { &parents[..parents.len().min(1)] } else { parents }; + let mut out = Vec::new(); + for parent in parents { + // Iterative postorder: a long hidden chain must not consume the call stack. + let mut stack = vec![(*parent, false)]; + while let Some((id, expanded)) = stack.pop() { + if self.projected.contains_key(&id) { + continue; + } + if self.shows(repo, id)? { + self.projected.insert(id, vec![id]); + } else if !repo.has_object(id) { + self.projected.insert(id, Vec::new()); + } else { + let commit = self.index.get(repo, id)?; + let ancestors = commit + .parents + .iter() + .take(if self.shallow.contains(&id) { + 0 + } else if self.query.first_parent { + 1 + } else { + usize::MAX + }) + .map(|p| ObjectId::from_hex(p.as_bytes()).map_err(gx)) + .collect::>>()?; + if !expanded { + stack.push((id, true)); + stack.extend(ancestors.iter().rev().map(|p| (*p, false))); + } else { + let mut nearest = Vec::new(); + for p in ancestors { + for ancestor in self.projected.get(&p).into_iter().flatten() { + if !nearest.contains(ancestor) { + nearest.push(*ancestor); + } + } + } + self.projected.insert(id, nearest); + } + } } - visited += 1; - if visited > REWRITE_LIMIT { - break; - } - let commit = repo.find_commit(p).map_err(gx)?; - let time = commit.time().map(|t| t.seconds).unwrap_or(0); - if self.since.is_some_and(|s| time < s) { - continue; - } - let ids: Vec = commit.parent_ids().map(|i| i.detach()).collect(); - if self.shows(ids.len() > 1, time) { - if !out.contains(&p) { - out.push(p); + for ancestor in &self.projected[parent] { + if !out.contains(ancestor) { + out.push(*ancestor); } - continue; } - let next = if self.first_parent { &ids[..ids.len().min(1)] } else { &ids[..] }; - stack.extend(next.iter().rev()); } Ok(out) } @@ -331,18 +720,25 @@ impl Shape { /// Commit ids of a revision range (`A..B`, `A...B`, `^A B`…), topologically ordered, via /// `git rev-list` so every rev syntax git understands (`@{u}`, `HEAD~3`) works. -fn range_ids(r: &Repo, range: &str, path: Option<&str>) -> IdeResult> { - let mut args: Vec<&str> = vec!["rev-list", "--topo-order", "--end-of-options"]; +fn range_ids( + r: &Repo, + range: &str, + order: LogOrder, + first_parent: bool, + cancel: &CancellationToken, +) -> IdeResult> { + let mut args: Vec<&str> = vec!["rev-list", if order == LogOrder::Date { "--date-order" } else { "--topo-order" }]; + if first_parent { + args.push("--first-parent"); + } + args.push("--end-of-options"); for part in range.split_whitespace() { if part.starts_with('-') { return Err(ide_core::IdeError::invalid(format!("invalid revision range '{range}'"))); } args.push(part); } - if let Some(rel) = path { - args.extend(["--", rel]); - } - let out = crate::cli::run(r.root(), &args, None)?; + let out = crate::cli::run_cancellable(r.root(), &args, cancel)?; out.stdout_str() .lines() .filter(|l| !l.trim().is_empty()) @@ -354,11 +750,69 @@ fn range_ids(r: &Repo, range: &str, path: Option<&str>) -> IdeResult IdeResult> { let mut tips = Vec::new(); if !revs.is_empty() { - for rev in revs { - let id = resolve(repo, rev)?; - let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?; - if !tips.contains(&commit.id) { - tips.push(commit.id); + let refs = decorations(repo)?; + let branches: Vec<_> = refs + .iter() + .flat_map(|(id, names)| { + names + .iter() + .filter(|name| *name != "HEAD" && !name.starts_with("tag: ")) + .map(move |name| (name.as_str(), *id)) + }) + .collect(); + let mut excludes = Vec::new(); + let includes: Vec<_> = revs.iter().filter(|rev| !rev.starts_with('-')).collect(); + for rev in revs.iter().filter_map(|rev| rev.strip_prefix('-')) { + let re = if branches.iter().any(|(name, _)| *name == rev) { + None + } else { + Some( + regex_lite::Regex::new(&format!("^(?:{rev})$")) + .map_err(|e| IdeError::invalid(format!("invalid branch pattern: {e}")))?, + ) + }; + excludes.push((rev, re)); + } + if includes.is_empty() { + for (name, id) in &branches { + if !excludes.iter().any(|(exact, re)| name == exact || re.as_ref().is_some_and(|re| re.is_match(name))) + && !tips.contains(id) + { + tips.push(*id); + } + } + } + for rev in includes { + if excludes.iter().any(|(exact, re)| *rev == *exact || re.as_ref().is_some_and(|re| re.is_match(rev))) { + continue; + } + match resolve(repo, rev) { + Ok(id) => { + let commit = repo.find_object(id).map_err(gx)?.peel_to_commit().map_err(gx)?; + if !tips.contains(&commit.id) { + tips.push(commit.id); + } + } + Err(error) => { + let re = regex_lite::Regex::new(&format!("^(?:{rev})$")).map_err(|_| error.clone())?; + let matches: Vec<_> = branches + .iter() + .filter(|(name, _)| { + re.is_match(name) + && !excludes + .iter() + .any(|(exact, re)| name == exact || re.as_ref().is_some_and(|re| re.is_match(name))) + }) + .collect(); + if matches.is_empty() && !rev.chars().any(|c| ".*+?[]()|^$".contains(c)) { + return Err(error); + } + for (_, id) in matches { + if !tips.contains(id) { + tips.push(*id); + } + } + } } } return Ok(tips); @@ -367,7 +821,9 @@ fn tips(repo: &gix::Repository, revs: &[&str]) -> IdeResult> { tips.push(id.detach()); } let platform = repo.references().map_err(gx)?; - for iter in [platform.local_branches().map_err(gx)?, platform.remote_branches().map_err(gx)?] { + for iter in + [platform.local_branches().map_err(gx)?, platform.remote_branches().map_err(gx)?, platform.tags().map_err(gx)?] + { for reference in iter { let Ok(mut reference) = reference else { continue }; if let Ok(id) = reference.peel_to_id() { @@ -382,7 +838,7 @@ fn tips(repo: &gix::Repository, revs: &[&str]) -> IdeResult> { } /// Commit id → decoration labels. -fn decorations(repo: &gix::Repository) -> IdeResult>> { +pub(crate) fn decorations(repo: &gix::Repository) -> IdeResult>> { let mut map: HashMap> = HashMap::new(); if let Ok(id) = repo.head_id() { map.entry(id.detach()).or_default().push("HEAD".to_string()); @@ -414,24 +870,35 @@ fn decorations(repo: &gix::Repository) -> IdeResult { repo: &'r gix::Repository, - rel: String, + paths: Vec, queue: std::collections::BinaryHeap<(i64, u64, ObjectId)>, seen: HashSet, - memo: HashMap>, + 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, rel: String, first_parent: bool) -> IdeResult { + 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, - rel, + paths, queue: Default::default(), seen: Default::default(), - memo: 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)?; @@ -440,6 +907,9 @@ impl<'r> PathWalk<'r> { } 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(()); } @@ -458,13 +928,23 @@ impl<'r> PathWalk<'r> { 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 = path_entry(self.repo, id, &self.rel, &mut self.memo)?; + 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 { - if path_entry(self.repo, *p, &self.rel, &mut self.memo)? == mine { + let theirs = self + .paths + .iter() + .map(|path| self.index.path_entry(self.repo, *p, path)) + .collect::>>()?; + if theirs == mine { same_parent = Some(*p); break; } @@ -475,7 +955,7 @@ impl<'r> PathWalk<'r> { for p in &present { self.push(*p)?; } - if !present.is_empty() || mine.is_some() { + if !present.is_empty() || mine.iter().any(Option::is_some) { return Ok(Some((id, parents))); } } @@ -492,23 +972,6 @@ impl Iterator for PathWalk<'_> { } } -/// Id of the tree entry at `rel` in `commit` (memoised). -fn path_entry( - repo: &gix::Repository, - commit: ObjectId, - rel: &str, - memo: &mut HashMap>, -) -> IdeResult> { - if let Some(v) = memo.get(&commit) { - return Ok(*v); - } - let commit_obj = repo.find_commit(commit).map_err(gx)?; - let tree = commit_obj.tree().map_err(gx)?; - let id = tree.lookup_entry_by_path(Path::new(rel)).map_err(gx)?.map(|e| e.object_id()); - memo.insert(commit, id); - Ok(id) -} - 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 0db7caed..7adf881e 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -112,22 +112,64 @@ pub struct Commit { pub id: String, pub short_id: String, pub parents: Vec, + /// Connections in the filtered graph; never use these as revision/diff parents. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph_parents: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub graph_dashed: Vec, + /// Repository-relative filename at this revision in rename-following history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_position: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_total: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub graph_boundary_parents: Vec, + /// Reachability from HEAD, computed independently of the visible filters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_current_branch: Option, pub author: String, pub email: String, + #[serde(default)] + pub committer: String, + #[serde(default)] + pub committer_email: String, pub time_ms: f64, pub summary: String, pub message: String, + #[serde(default)] + pub message_truncated: bool, pub refs: Vec, /// Committer time (Log › View Options › Commit Timestamp). #[serde(default)] pub commit_time_ms: f64, - /// Per parent, under a user or text filter: the link passes commits the filter hides - /// (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten. + /// Legacy alias of `graph_dashed`, indexed by displayed parents. #[serde(default)] #[specta(optional)] pub dashed: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct LogDetails { + pub message: String, + pub containing_branches: Vec, + /// Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked. + pub signature: String, + pub signer: String, + pub key: String, + 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")] @@ -137,13 +179,29 @@ pub enum LogOrder { Topo, /// `--date-order`: commit time, but never a parent before its children. Date, + // Topological grouping with incoming merge parents visited before the main line. + Standard, } #[derive(Clone, Debug, Default, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct LogQuery { + #[serde(default)] + pub speed_search: Option, + #[serde(default)] + pub search_fields: Vec, + /// Jump directly to an indexed window containing this commit (50 rows of context). + #[serde(default)] + pub seek: Option, + #[serde(default)] + pub retain_until: Option, pub branch: Option, pub path: Option, + #[serde(default)] + pub paths: Vec, + /// File history follows renames; structure filters retain fixed paths. + #[serde(default)] + pub follow: bool, pub author: Option, pub text: Option, pub skip: u32, @@ -152,6 +210,9 @@ pub struct LogQuery { /// not the left). Takes precedence over `branch`. #[serde(default)] pub range: Option, + /// Additional ranges unioned with each other and with selected branches. + #[serde(default)] + pub ranges: Vec, /// `text` is a regular expression (Log filter `.*`). #[serde(default)] pub text_regex: bool, @@ -165,6 +226,8 @@ pub struct LogQuery { #[serde(default)] pub authors: Vec, #[serde(default)] + pub authors_exact: bool, + #[serde(default)] pub order: LogOrder, /// Follow only the first parent of merges (`--first-parent`). #[serde(default)] @@ -179,6 +242,13 @@ pub struct LogQuery { pub since_ms: Option, #[serde(default)] pub until_ms: Option, + #[serde(default)] + pub request: Option, + #[serde(default)] + pub summary_only: bool, + /// Inclusive refresh boundary. Clients join only verified linear prefixes. + #[serde(default)] + pub stop_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, Type)] @@ -487,6 +557,7 @@ pub struct ChangelistState { pub files: std::collections::BTreeMap, } +mod bek; mod blame; mod block_history; pub mod changelists; @@ -496,6 +567,7 @@ mod diff; mod history; mod ignore; mod index_writes; +mod log_index; mod manage; mod ops; mod patch; @@ -599,6 +671,9 @@ pub struct Repo { git_dir: PathBuf, inner: std::sync::Mutex, ignored: std::sync::RwLock, + log_index: std::sync::Mutex, + index_progress: std::sync::Mutex, + log_signatures: std::sync::Mutex>, } impl Repo { @@ -738,7 +813,183 @@ impl Repo { /// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: `. pub fn log(&self, query: &LogQuery) -> IdeResult> { let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); - history::log(self, query) + history::log(self, query, &CancellationToken::new()) + } + pub fn log_cancellable(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + history::log(self, query, cancel) + } + /// Enables the disposable, content-addressed log cache outside the worktree. + pub fn enable_log_index(&self, cache_dir: &Path) { + let key = blake3::hash(self.git_dir.to_string_lossy().as_bytes()).to_hex().to_string(); + self.log_index.lock().unwrap_or_else(|p| p.into_inner()).open(&cache_dir.join(key)); + } + /// Reference names and peeled commit ids, including tags and detached HEAD. + pub fn log_refs(&self) -> IdeResult> { + let repo = self.gix()?; + Ok(history::decorations(&repo)? + .into_iter() + .flat_map(|(id, names)| names.into_iter().map(move |name| (name, id.to_string()))) + .collect()) + } + pub fn log_users(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + let out = cli::run(self.root(), &["log", "--all", "--format=%an <%ae>"], None)?; + Ok(out + .stdout_str() + .lines() + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>() + .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()) + } + pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult { + let allowed = self.execution_allowed.load(std::sync::atomic::Ordering::Acquire); + let _scope = crate::ExecutionScope::enter(allowed); + let repo = self.gix()?; + let object = + repo.find_object(repo::resolve(&repo, revision)?).map_err(repo::gx)?.peel_to_commit().map_err(repo::gx)?; + let commit_id = object.id; + let id = commit_id.to_string(); + let contains = format!("--contains={id}"); + let branches = + cli::run_cancellable(self.root(), &["branch", "-a", &contains, "--format=%(refname:short)"], cancel)?; + let cache_key = format!("{allowed}:{id}"); + let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned(); + let mut details = if let Some(details) = cached { + details + } else { + let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len()); + let headers = &object.data[..end]; + let signed = headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-"); + let output = if allowed && signed { + Some(cli::run_cancellable( + self.root(), + &["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id], + cancel, + )?) + } else { + None + }; + let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default(); + let fields: Vec<_> = text.trim_end().split('\0').collect(); + let message = { + let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner()); + let message = index.message(&repo, commit_id)?; + index.flush(); + message + }; + let details = LogDetails { + message, + containing_branches: Vec::new(), + signature: if !signed { + "N".into() + } else { + fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string() + }, + signer: fields.get(1).unwrap_or(&"").to_string(), + key: fields.get(2).unwrap_or(&"").to_string(), + fingerprint: fields.get(3).unwrap_or(&"").to_string(), + }; + self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone()); + details + }; + details.containing_branches = + branches.stdout_str().lines().filter(|s| !s.ends_with("/HEAD")).map(str::to_string).collect(); + Ok(details) + } + + /// Lists one immutable revision directory. Directory names end in `/`. + pub fn revision_children(&self, revision: &str, directory: &str) -> IdeResult> { + let repo = self.gix()?; + let id = repo::resolve(&repo, revision)?.to_string(); + let directory = self.rel_path(std::path::Path::new(directory))?; + let tree = if directory.is_empty() { id } else { format!("{id}:{directory}") }; + let out = cli::run(self.root(), &["ls-tree", "-z", "--", &tree], None)?; + Ok(out + .stdout + .split(|b| *b == 0) + .filter_map(|line| { + let split = line.iter().position(|b| *b == b'\t')?; + let name = String::from_utf8_lossy(&line[split + 1..]); + let dir = line.starts_with(b"040000 "); + Some(format!( + "{}{name}{}", + if directory.is_empty() { String::new() } else { format!("{directory}/") }, + if dir { "/" } else { "" } + )) + }) + .collect()) + } + + pub fn revision_files(&self, revision: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + let repo = self.gix()?; + let id = repo::resolve(&repo, revision)?.to_string(); + let out = cli::run(self.root(), &["ls-tree", "-r", "-z", "--name-only", "--", &id], None)?; + Ok(out + .stdout + .split(|b| *b == 0) + .filter(|p| !p.is_empty()) + .map(|p| String::from_utf8_lossy(p).into_owned()) + .collect()) } /// Files changed by `commit` relative to its first parent (renames detected). pub fn commit_files(&self, commit: &str) -> IdeResult> { diff --git a/crates/ide-vcs/src/log_index.rs b/crates/ide-vcs/src/log_index.rs new file mode 100644 index 00000000..c1d013aa --- /dev/null +++ b/crates/ide-vcs/src/log_index.rs @@ -0,0 +1,385 @@ +//! Immutable commit metadata, persisted by object id. Ref decorations and graph +//! projections are deliberately never cached as commit metadata. + +use crate::Commit; +use crate::repo::{gx, to_string}; +use gix::ObjectId; +use gix::bstr::ByteSlice; +use ide_core::IdeResult; +use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::{Arc, Mutex, OnceLock, Weak}; +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, + pub positions: HashMap, +} +impl IndexedView { + pub fn new(commits: Vec) -> Self { + let positions = commits.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect(); + Self { commits, positions } + } +} + +#[derive(Default)] +pub(crate) struct LogIndex { + opened: bool, + db: Option>, + 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>, + pub nodes: HashMap, i64)>, + pub shallow: HashSet, +} + +impl LogIndex { + /// A permanent DAG: refresh discovers only new objects, then joins arbitrary + /// changed tips against known ancestry. Reachability is recomputed for resets/deleted refs. + pub fn graph( + &mut self, + repo: &gix::Repository, + tips: &[ObjectId], + shallow: &HashSet, + first_parent: bool, + cancel: &ide_core::CancellationToken, + ) -> IdeResult> { + if &self.shallow != shallow { + self.nodes.clear(); + self.shallow = shallow.clone(); + } + let mut pending = tips.to_vec(); + while let Some(id) = pending.pop() { + if cancel.is_cancelled() { + return Err(ide_core::IdeError::cancelled()); + } + if self.nodes.contains_key(&id) { + continue; + } + let record = self.get(repo, id)?; + let parents: Vec<_> = if shallow.contains(&id) { + Vec::new() + } else { + record.parents.iter().map(|s| ObjectId::from_hex(s.as_bytes()).map_err(gx)).collect::>()? + }; + pending.extend(parents.iter().copied()); + self.nodes.insert(id, (parents, (record.commit_time_ms / 1000.0) as i64)); + } + let mut seen = HashSet::new(); + let mut ids = Vec::new(); + let mut pending = tips.to_vec(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + ids.push(id); + if let Some((parents, _)) = self.nodes.get(&id) { + pending.extend(parents.iter().take(if first_parent { 1 } else { usize::MAX }).rev().copied()); + } + } + 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; + } + self.opened = true; + // Several windows can read the same worktree; redb permits one owning + // handle per file, shared here rather than silently disabling its index. + static DATABASES: OnceLock>>> = OnceLock::new(); + let path = dir.join("commits.redb"); + let mut databases = DATABASES.get_or_init(Mutex::default).lock().unwrap_or_else(|p| p.into_inner()); + self.db = databases.get(&path).and_then(Weak::upgrade).or_else(|| { + let db = Arc::new(std::fs::create_dir_all(dir).ok().and_then(|_| Database::create(&path).ok())?); + databases.insert(path, Arc::downgrade(&db)); + Some(db) + }); + } + + pub fn get(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult> { + if let Some(commit) = self.memory.get(&id) { + return Ok(commit.clone()); + } + let key = id.to_string(); + let cached = self.db.as_ref().and_then(|db| { + let txn = db.begin_read().ok()?; + let table = txn.open_table(COMMITS).ok()?; + let value = table.get(key.as_str()).ok()??; + serde_json::from_slice::(value.value()).ok().filter(|c| c.id == key) + }); + let commit = if let Some(commit) = cached { + Arc::new(commit) + } else { + let commit = repo.find_commit(id).map_err(gx)?; + let author = commit.author().map_err(gx)?; + let committer = commit.committer().map_err(gx)?; + let raw = commit.message_raw_sloppy(); + let summary = raw.split(|b| *b == b'\n').find(|l| !l.trim().is_empty()).unwrap_or_default().trim(); + let summary_text = to_string(summary.as_bstr()); + let record = Arc::new(Commit { + id: key.clone(), + short_id: key[..key.len().min(8)].to_string(), + parents: commit.parent_ids().map(|p| p.to_string()).collect(), + graph_parents: None, + graph_dashed: Vec::new(), + dashed: Vec::new(), + on_current_branch: None, + author: to_string(author.name.trim().as_bstr()), + email: to_string(author.email.trim().as_bstr()), + committer: to_string(committer.name.trim().as_bstr()), + committer_email: to_string(committer.email.trim().as_bstr()), + time_ms: author.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0), + commit_time_ms: committer.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0), + summary: summary_text.clone(), + message: summary_text, + message_truncated: raw.trim() != summary, + history_path: None, + log_position: None, + log_total: None, + graph_boundary_parents: Vec::new(), + refs: Vec::new(), + }); + if self.db.is_some() { + self.dirty.push(record.clone()); + } + record + }; + // Keep hot pages in memory; older metadata stays in the persistent index. + if self.memory.len() >= 4096 { + self.flush(); + self.memory.clear(); + } + self.memory.insert(id, commit.clone()); + Ok(commit) + } + + pub fn message(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult { + if let Some((_, message)) = self.dirty_messages.iter().find(|(key, _)| key == &id.to_string()) { + return Ok(message.clone()); + } + let key = id.to_string(); + let cached = self.db.as_ref().and_then(|db| { + let txn = db.begin_read().ok()?; + let table = txn.open_table(MESSAGES).ok()?; + Some(table.get(key.as_str()).ok()??.value().to_string()) + }); + if let Some(message) = cached { + return Ok(message); + } + let message = to_string(repo.find_commit(id).map_err(gx)?.message_raw_sloppy()).trim_end().to_string(); + if self.db.is_some() { + self.dirty_messages.push((key, message.clone())); + } + Ok(message) + } + + 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() + { + return; + } + let result = (|| -> Result<(), Box> { + let Some(db) = &self.db else { + return Ok(()); + }; + let txn = db.begin_write()?; + { + let mut table = txn.open_table(COMMITS)?; + for commit in &self.dirty { + let bytes = serde_json::to_vec(commit.as_ref())?; + 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(()) + })(); + if let Err(error) = result { + 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) + } +} + +pub(crate) fn normalize_user(user: &str) -> String { + user.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() +} + +pub(crate) fn matches_user(query: &str, name: &str, email: &str) -> bool { + let query_name = query.split('<').next().unwrap_or(query).trim(); + let query_email = query.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(query); + let target = + [normalize_user(name), normalize_user(email), normalize_user(email.split('@').next().unwrap_or(email))]; + [query_name, query_email, query_email.split('@').next().unwrap_or(query_email)] + .iter() + .any(|alias| target.contains(&normalize_user(alias))) +} diff --git a/crates/ide-vcs/src/repo.rs b/crates/ide-vcs/src/repo.rs index 47e145b6..6ad81787 100644 --- a/crates/ide-vcs/src/repo.rs +++ b/crates/ide-vcs/src/repo.rs @@ -64,6 +64,9 @@ pub(crate) fn discover(path: &Path) -> IdeResult> { git_dir, 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 new file mode 100644 index 00000000..10c9c61a --- /dev/null +++ b/crates/ide-vcs/tests/log_projection.rs @@ -0,0 +1,438 @@ +use ide_vcs::{LogOrder, LogQuery, Repo}; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .current_dir(dir) + .args([ + "-c", + "init.defaultBranch=main", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn commit(dir: &Path, file: &str, message: &str) -> String { + std::fs::write(dir.join(file), message).unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["commit", "-qm", message]); + git(dir, &["rev-parse", "HEAD"]) +} + +#[test] +fn branch_and_independent_ranges_form_a_union() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let root = commit(dir, "a", "root"); + let first = commit(dir, "a", "first"); + let second = commit(dir, "a", "second"); + let third = commit(dir, "a", "third"); + git(dir, &["checkout", "-qb", "side", &root]); + let side = commit(dir, "b", "side"); + let repo = Repo::discover(dir).unwrap().unwrap(); + for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] { + let log = repo + .log(&LogQuery { + branches: vec!["side".into()], + ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")], + order, + ..Default::default() + }) + .unwrap(); + let got: std::collections::HashSet<_> = log.iter().map(|c| c.id.clone()).collect(); + assert_eq!(got, [root.clone(), first.clone(), third.clone(), side.clone()].into()); + let ranges = repo + .log(&LogQuery { + ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")], + order, + ..Default::default() + }) + .unwrap(); + assert_eq!( + ranges.iter().map(|c| c.id.clone()).collect::>(), + [first.clone(), third.clone()].into() + ); + let positions: std::collections::HashMap<_, _> = + log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect(); + for (i, c) in log.iter().enumerate() { + for p in c.graph_parents.as_ref().unwrap() { + assert!(positions.get(p.as_str()).is_none_or(|p| *p > i)); + } + } + } +} + +#[test] +fn lazy_summaries_seek_and_index_survive_refresh_and_restart() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let mut ids = Vec::new(); + for i in 0..70 { + ids.push(commit(dir, "a", &format!("summary {i}\n\nneedle body {i}"))); + } + let cache = tempfile::tempdir().unwrap(); + let repo = Repo::discover(dir).unwrap().unwrap(); + repo.enable_log_index(cache.path()); + let query = LogQuery { summary_only: true, limit: 5, ..Default::default() }; + let page = repo.log(&query).unwrap(); + assert_eq!(page[0].message, "summary 69"); + assert!(page[0].message_truncated); + assert_eq!(page[0].log_position, Some(0)); + assert_eq!(page[0].log_total, Some(70)); + let window = repo.log(&LogQuery { seek: Some(ids[5].clone()), limit: 20, ..query.clone() }).unwrap(); + assert!(window.iter().any(|c| c.id == ids[5])); + assert!(window[0].log_position.unwrap() > 0); + assert!(repo.log(&LogQuery { request: Some(ide_core::RequestId(1)), ..query.clone() }).is_ok()); + 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(); + assert_eq!(refreshed[0].id, new); + assert_eq!(refreshed.len(), 71); + drop(repo); + let repo = Repo::discover(dir).unwrap().unwrap(); + repo.enable_log_index(cache.path()); + let hit = repo.log(&LogQuery { text: Some("needle body 69".into()), ..query }).unwrap(); + assert_eq!(hit[0].id, ids[69]); + assert_eq!(repo.log_details(&ids[69]).unwrap().message, "summary 69\n\nneedle body 69"); + assert_eq!(repo.log_details(&ids[69]).unwrap().signature, "N"); +} + +#[test] +fn followed_history_carries_the_filename_at_each_revision() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let first = commit(dir, "old.txt", "original"); + git(dir, &["mv", "old.txt", "new.txt"]); + git(dir, &["commit", "-qm", "rename"]); + let renamed = git(dir, &["rev-parse", "HEAD"]); + let tip = commit(dir, "new.txt", "latest"); + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap(); + assert_eq!( + log.iter().map(|c| (c.id.as_str(), c.history_path.as_deref())).collect::>(), + vec![(tip.as_str(), Some("new.txt")), (renamed.as_str(), Some("new.txt")), (first.as_str(), Some("old.txt"))] + ); + assert_eq!(repo.revision_children(&first, "").unwrap(), vec!["old.txt"]); +} + +#[test] +fn detail_filters_connect_visible_ancestors_without_changing_revision_parents() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let a = commit(dir, "a", "keep root"); + commit(dir, "b", "hidden middle"); + git(dir, &["commit", "--amend", "--no-edit", "--author=Other "]); + let b = git(dir, &["rev-parse", "HEAD"]); + let c = commit(dir, "a", "keep tip"); + let repo = Repo::discover(dir).unwrap().unwrap(); + for q in [ + LogQuery { text: Some("keep".into()), ..Default::default() }, + LogQuery { path: Some("a".into()), ..Default::default() }, + LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() }, + ] { + let page = repo.log(&LogQuery { limit: 1, ..q.clone() }).unwrap(); + assert_eq!(page[0].id, c); + assert_eq!(page[0].parents, vec![b.clone()]); + assert_eq!(page[0].graph_parents, Some(vec![a.clone()])); + assert_eq!(page[0].graph_dashed, vec![true]); + assert_eq!(page[0].on_current_branch, Some(true)); + let next = repo.log(&LogQuery { skip: 1, limit: 1, ..q }).unwrap(); + assert_eq!(next[0].id, a); + assert_eq!(next[0].graph_parents, Some(vec![])); + } +} + +#[test] +fn all_history_includes_commits_reachable_only_from_tags() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let root = commit(dir, "a", "root"); + let tip = commit(dir, "a", "tagged tip"); + git(dir, &["tag", "v1"]); + git(dir, &["reset", "--hard", &root]); + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery::default()).unwrap(); + let tagged = log.iter().find(|c| c.id == tip).unwrap(); + assert!(tagged.refs.contains(&"tag: v1".into())); + assert_eq!(tagged.on_current_branch, Some(false)); +} + +#[test] +fn projection_does_not_truncate_long_hidden_chains() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let mut stream = String::new(); + for i in 0..4100 { + let message = if i == 0 || i == 4099 { "keep" } else { "hide" }; + stream.push_str(&format!( + "commit refs/heads/main\ncommitter Test {} +0000\ndata {}\n{}\n\n", + 1000 + i, + message.len(), + message + )); + } + let mut child = Command::new("git") + .current_dir(dir) + .args(["fast-import", "--quiet"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(stream.as_bytes()).unwrap(); + assert!(child.wait().unwrap().success()); + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery { text: Some("keep".into()), ..Default::default() }).unwrap(); + assert_eq!(log.len(), 2); + assert_eq!(log[0].graph_parents, Some(vec![log[1].id.clone()])); +} + +#[test] +fn path_and_shallow_history_remain_topological_with_clock_skew() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + git(&source, &["init", "-q"]); + let make = |message: &str, parent: Option<&str>, time: &str| { + std::fs::write(source.join("a.txt"), message).unwrap(); + git(&source, &["add", "a.txt"]); + let tree = git(&source, &["write-tree"]); + let mut cmd = Command::new("git"); + cmd.current_dir(&source).args([ + "-c", + "user.name=Test", + "-c", + "user.email=t@example.com", + "commit-tree", + &tree, + "-m", + message, + ]); + if let Some(p) = parent { + cmd.args(["-p", p]); + } + let out = cmd.env("GIT_AUTHOR_DATE", time).env("GIT_COMMITTER_DATE", time).output().unwrap(); + assert!(out.status.success()); + String::from_utf8(out.stdout).unwrap().trim().to_string() + }; + let root = make("root", None, "@5000 +0000"); + let left = make("left", Some(&root), "@1000 +0000"); + let right = make("right", Some(&root), "@2000 +0000"); + git(&source, &["update-ref", "refs/heads/main", &left]); + git(&source, &["update-ref", "refs/heads/side", &right]); + let shallow = tmp.path().join("shallow"); + git( + tmp.path(), + &[ + "clone", + "-q", + "--depth=2", + "--no-single-branch", + &format!("file://{}", source.display()), + shallow.to_str().unwrap(), + ], + ); + for dir in [&source, &shallow] { + let repo = Repo::discover(dir).unwrap().unwrap(); + for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] { + let log = repo.log(&LogQuery { order, path: Some("a.txt".into()), ..Default::default() }).unwrap(); + assert_eq!(log.len(), 3); + let positions: std::collections::HashMap<_, _> = + log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect(); + for (i, c) in log.iter().enumerate() { + for parent in c.graph_parents.as_ref().unwrap() { + assert!(positions.get(parent.as_str()).is_none_or(|p| *p > i)); + } + } + } + } +} + +#[test] +fn multiple_paths_exact_authors_patterns_and_rename_history() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let root = commit(dir, "old.txt", "root"); + git(dir, &["mv", "old.txt", "new.txt"]); + git(dir, &["commit", "-qm", "rename"]); + let rename = git(dir, &["rev-parse", "HEAD"]); + let tip = commit(dir, "other.txt", "another file"); + git(dir, &["branch", "feature/log"]); + git(dir, &["branch", "feature/skip", &root]); + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery { paths: vec!["new.txt".into(), "other.txt".into()], ..Default::default() }).unwrap(); + assert_eq!(log.iter().map(|c| c.id.clone()).collect::>(), vec![tip.clone(), rename.clone()]); + let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap(); + assert_eq!(log.iter().map(|c| c.id.clone()).collect::>(), vec![rename, root.clone()]); + let log = repo + .log(&LogQuery { branches: vec!["feature/.*".into(), "-feature/skip".into()], ..Default::default() }) + .unwrap(); + assert_eq!(log[0].id, tip); + std::fs::write(dir.join("other.txt"), "different author").unwrap(); + git(dir, &["add", "-A"]); + git(dir, &["-c", "user.name=Test User", "-c", "user.email=other@example.com", "commit", "-qm", "different author"]); + let log = repo.log(&LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() }).unwrap(); + assert_eq!(log.len(), 3); + assert!(log.iter().all(|c| c.author == "Test")); + assert!(repo.log_users().unwrap().contains(&"Test User ".to_string())); +} + +#[test] +fn metadata_and_path_cache_survive_restart_without_caching_refs_or_filters() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("repo"); + std::fs::create_dir(&dir).unwrap(); + git(&dir, &["init", "-q"]); + let first = commit(&dir, "a", "first\n\nComplete commit body"); + git(&dir, &["tag", "v1"]); + git(&dir, &["tag", "-a", "annotated", "-m", "release"]); + let cache = tmp.path().join("cache"); + { + let repo = Repo::discover(&dir).unwrap().unwrap(); + repo.enable_log_index(&cache); + let log = repo.log(&LogQuery { path: Some("a".into()), summary_only: true, ..Default::default() }).unwrap(); + assert!(log[0].message_truncated); + assert_eq!(log[0].message, "first"); + assert_eq!(repo.log_details(&first).unwrap().message, "first\n\nComplete commit body"); + let details = repo.log_details("annotated").unwrap(); + assert_eq!(details.message, "first\n\nComplete commit body"); + assert_eq!(details.signature, "N"); + assert!(details.containing_branches.contains(&"main".to_string())); + assert_eq!(repo.revision_files(&first).unwrap(), vec!["a"]); + } + let second = commit(&dir, "a", "second"); + git(&dir, &["tag", "-f", "v1"]); + let repo = Repo::discover(&dir).unwrap().unwrap(); + repo.enable_log_index(&cache); + let log = repo.log(&LogQuery { path: Some("a".into()), ..Default::default() }).unwrap(); + assert_eq!(log[0].id, second); + assert!(log[0].refs.contains(&"tag: v1".into())); + assert!(!log[1].refs.contains(&"tag: v1".into())); + assert!(log[1].message.contains("Complete commit body")); + assert!(!log[1].message_truncated); +} + +#[test] +fn cancelled_query_does_not_publish_a_partial_page() { + let tmp = tempfile::tempdir().unwrap(); + git(tmp.path(), &["init", "-q"]); + commit(tmp.path(), "a", "root"); + let repo = Repo::discover(tmp.path()).unwrap().unwrap(); + let cancel = ide_core::CancellationToken::new(); + cancel.cancel(); + assert_eq!( + repo.log_cancellable(&LogQuery::default(), &cancel).unwrap_err().kind, + ide_core::error::ErrorKind::Cancelled + ); +} + +#[test] +fn shallow_boundaries_do_not_reconnect_to_locally_present_ancestors() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let root = commit(dir, "a", "keep root"); + let boundary = commit(dir, "a", "hidden boundary"); + let tip = commit(dir, "a", "keep tip"); + git(dir, &["tag", "root", &root]); + std::fs::write(dir.join(".git/shallow"), format!("{boundary}\n")).unwrap(); + let repo = Repo::discover(dir).unwrap().unwrap(); + for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] { + for text in [None, Some("keep".to_string())] { + let log = repo.log(&LogQuery { order, text, path: Some("a".into()), ..Default::default() }).unwrap(); + let boundary_row = log.iter().find(|c| c.id == boundary); + if let Some(row) = boundary_row { + assert_eq!(row.parents, vec![root.clone()]); + assert_eq!(row.graph_parents, Some(vec![])); + } else { + assert_eq!(log.iter().find(|c| c.id == tip).unwrap().graph_parents, Some(vec![])); + } + } + } +} + +#[test] +fn exact_branch_exclusions_treat_punctuation_literally() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let root = commit(dir, "a", "root"); + git(dir, &["branch", "release/1.0"]); + let tip = commit(dir, "a", "tip"); + git(dir, &["branch", "release/1x0"]); + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo + .log(&LogQuery { branches: vec!["release/.*".into(), "-release/1.0".into()], ..Default::default() }) + .unwrap(); + assert_eq!(log.iter().map(|c| c.id.as_str()).collect::>(), vec![tip.as_str(), root.as_str()]); +} + +#[test] +fn mode_only_changes_appear_in_path_history() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + let root = commit(dir, "script", "root"); + git(dir, &["update-index", "--chmod=+x", "script"]); + git(dir, &["commit", "-qm", "make executable"]); + let tip = git(dir, &["rev-parse", "HEAD"]); + let repo = Repo::discover(dir).unwrap().unwrap(); + let log = repo.log(&LogQuery { path: Some("script".into()), ..Default::default() }).unwrap(); + assert_eq!(log.iter().map(|c| c.id.as_str()).collect::>(), vec![tip.as_str(), root.as_str()]); +} + +#[test] +fn multiple_paths_are_simplified_together_at_merges() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + git(dir, &["init", "-q"]); + commit(dir, "a", "a0"); + commit(dir, "b", "b0"); + git(dir, &["branch", "side"]); + commit(dir, "a", "left"); + git(dir, &["checkout", "side"]); + commit(dir, "b", "right"); + git(dir, &["checkout", "main"]); + git(dir, &["merge", "--no-ff", "side", "-m", "merge both paths"]); + let repo = Repo::discover(dir).unwrap().unwrap(); + let expected = git(dir, &["log", "--topo-order", "--format=%H", "--", "a", "b"]); + for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] { + let log = repo + .log(&LogQuery { + branch: Some("HEAD".into()), + paths: vec!["a".into(), "b".into()], + order, + ..Default::default() + }) + .unwrap(); + assert_eq!(log[0].summary, "merge both paths"); + assert_eq!( + log.iter().map(|c| c.id.as_str()).collect::>(), + expected.lines().collect() + ); + assert_eq!(log[0].parents.len(), 2); + assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2); + } +} diff --git a/crates/ide-vcs/tests/log_shape.rs b/crates/ide-vcs/tests/log_shape.rs index b26236ec..4414d6d5 100644 --- a/crates/ide-vcs/tests/log_shape.rs +++ b/crates/ide-vcs/tests/log_shape.rs @@ -64,7 +64,7 @@ fn ids(commits: &[Commit]) -> Vec { } fn parents_of(commits: &[Commit], id: &str) -> Vec { - commits.iter().find(|c| c.id == id).unwrap().parents.clone() + commits.iter().find(|c| c.id == id).unwrap().graph_parents.clone().unwrap() } #[test] @@ -75,6 +75,14 @@ fn date_order_follows_commit_time_and_reports_it() { assert_eq!(out[0].commit_time_ms, 5_000_000.0); } +#[test] +fn incoming_order_groups_the_merged_branch_before_the_main_parent() { + let h = history(); + let out = log(&h, LogQuery { order: LogOrder::Standard, ..Default::default() }); + assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.s1.clone(), h.c2.clone(), h.c1.clone()]); + assert_eq!(out[1].parents, vec![h.c2.clone(), h.s1.clone()]); +} + #[test] fn no_merges_links_children_to_the_merge_parents() { let h = history(); @@ -90,6 +98,7 @@ fn first_parent_skips_merged_branches() { let out = log(&h, LogQuery { first_parent: true, ..Default::default() }); assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.c1.clone()]); assert_eq!(parents_of(&out, &h.m), vec![h.c2.clone()]); + assert_eq!(out.iter().find(|c| c.id == h.m).unwrap().parents, vec![h.c2.clone(), h.s1.clone()]); } #[test] @@ -201,19 +210,26 @@ fn a_user_filter_links_shown_commits_past_hidden_ones() { let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap(); let links: Vec<(&str, Vec<&str>, Vec)> = log .iter() - .map(|c| (c.summary.as_str(), c.parents.iter().map(String::as_str).collect(), c.dashed.clone())) + .map(|c| { + ( + c.summary.as_str(), + c.graph_parents.as_ref().unwrap().iter().map(String::as_str).collect(), + if c.graph_dashed.iter().any(|d| *d) { c.graph_dashed.clone() } else { Vec::new() }, + ) + }) .collect(); assert_eq!( links, [("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),], "m2's parent o1 is hidden: m2 links to m1, dashed" ); - assert!(!log.iter().any(|c| c.parents.contains(&o1))); + assert!(!log.iter().any(|c| c.graph_parents.as_ref().unwrap().contains(&o1))); + assert_eq!(log[1].parents, vec![o1]); // A page ends at m2: walking on still links it to m1. let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap(); assert_eq!(page.len(), 2); - assert_eq!(page[1].parents, std::slice::from_ref(&m1)); + assert_eq!(page[1].graph_parents.as_deref().unwrap(), std::slice::from_ref(&m1)); let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap(); assert_eq!(next.iter().map(|c| c.id.clone()).collect::>(), std::slice::from_ref(&m1)); let _ = m3; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index dfb51cd2..03265689 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -249,6 +249,14 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_blame, vcs::vcs_block_history, vcs::vcs_log, + vcs::vcs_log_refs, + vcs::vcs_log_users, + 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, vcs::vcs_commits_changes, vcs::vcs_branches, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index ba3dee82..93439846 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -88,6 +88,91 @@ pub async fn vcs_log( ide_host::ops::vcs::vcs_log(&state.host, window.label(), repo, query).await } +#[tauri::command] +#[specta::specta] +pub async fn vcs_log_refs( + window: Window, + state: State<'_, AppState>, + repo: Option, +) -> IdeResult> { + ide_host::ops::vcs::vcs_log_refs(&state.host, window.label(), repo).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_log_users( + window: Window, + state: State<'_, AppState>, + repo: Option, +) -> IdeResult> { + ide_host::ops::vcs::vcs_log_users(&state.host, window.label(), repo).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_open_external_log( + window: Window, + state: State<'_, AppState>, + path: PathBuf, +) -> IdeResult { + ide_host::ops::vcs::vcs_open_external_log(&state.host, window.label(), path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_log_details( + window: Window, + state: State<'_, AppState>, + repo: Option, + revision: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_log_details(&state.host, window.label(), repo, revision).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_log_details_batch( + window: Window, + state: State<'_, AppState>, + repo: Option, + revisions: Vec, + request: RequestId, +) -> IdeResult> { + ide_host::ops::vcs::vcs_log_details_batch(&state.host, window.label(), repo, revisions, request).await +} +#[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>, + repo: Option, + revision: String, + directory: String, +) -> IdeResult> { + ide_host::ops::vcs::vcs_revision_children(&state.host, window.label(), repo, revision, directory).await +} + +#[tauri::command] +#[specta::specta] +pub async fn vcs_revision_files( + window: Window, + state: State<'_, AppState>, + repo: Option, + revision: String, +) -> IdeResult> { + ide_host::ops::vcs::vcs_revision_files(&state.host, window.label(), repo, revision).await +} + #[tauri::command] #[specta::specta] pub async fn vcs_commit_files( diff --git a/src/bindings.ts b/src/bindings.ts index a4813315..6bb04f59 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -475,7 +475,15 @@ export const commands = { * editor text. Cancellable with `cancel_request(request)`. */ vcsBlockHistory: (request: RequestId, path: string, text: string | null, lines: [number, number]) => typedError(__TAURI_INVOKE("vcs_block_history", { request, path, text, lines })), - vcsLog: (repo: string | null, query: LogQuery) => typedError(__TAURI_INVOKE("vcs_log", { repo, query })), + vcsLog: (repo: string | null, query: LogQuery) => typedError(__TAURI_INVOKE("vcs_log", { repo, query })), + vcsLogRefs: (repo: string | null) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("vcs_log_refs", { repo })), + vcsLogUsers: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_log_users", { repo })), + 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 })), /** Combined changes of several commits, `commits` oldest first. */ vcsCommitsChanges: (repo: string | null, commits: string[]) => typedError(__TAURI_INVOKE("vcs_commits_changes", { repo, commits })), @@ -2116,24 +2124,7 @@ export type CommandForm = { formAnswers: FormAnswer[], }; -export type Commit = { - id: string, - shortId: string, - parents: string[], - author: string, - email: string, - timeMs: number | null, - summary: string, - message: string, - refs: string[], - /** Committer time (Log › View Options › Commit Timestamp). */ - commitTimeMs?: number | null, - /** - * Per parent, under a user or text filter: the link passes commits the filter hides - * (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten. - */ - dashed?: boolean[], -}; +export type Commit = Commit_Serialize | Commit_Deserialize; /** Commit Options (IDEA's commit options popup). */ export type CommitOptions = { @@ -2174,6 +2165,64 @@ export type CommitSettings = { wrapAtMargin?: boolean, }; +export type Commit_Deserialize = { + id: string, + shortId: string, + parents: string[], + /** Connections in the filtered graph; never use these as revision/diff parents. */ + graphParents?: string[] | null, + graphDashed?: boolean[], + /** Repository-relative filename at this revision in rename-following history. */ + historyPath?: string | null, + logPosition?: number | null, + logTotal?: number | null, + graphBoundaryParents?: string[], + /** Reachability from HEAD, computed independently of the visible filters. */ + onCurrentBranch?: boolean | null, + author: string, + email: string, + committer?: string, + committerEmail?: string, + timeMs: number | null, + summary: string, + message: string, + messageTruncated?: boolean, + refs: string[], + /** Committer time (Log › View Options › Commit Timestamp). */ + commitTimeMs?: number | null, + /** Legacy alias of `graph_dashed`, indexed by displayed parents. */ + dashed?: boolean[], +}; + +export type Commit_Serialize = { + id: string, + shortId: string, + parents: string[], + /** Connections in the filtered graph; never use these as revision/diff parents. */ + graphParents?: string[] | null, + graphDashed?: boolean[], + /** Repository-relative filename at this revision in rename-following history. */ + historyPath?: string | null, + logPosition?: number | null, + logTotal?: number | null, + graphBoundaryParents?: string[], + /** Reachability from HEAD, computed independently of the visible filters. */ + onCurrentBranch?: boolean | null, + author: string, + email: string, + committer: string, + committerEmail: string, + timeMs: number | null, + summary: string, + message: string, + messageTruncated: boolean, + refs: string[], + /** Committer time (Log › View Options › Commit Timestamp). */ + commitTimeMs: number | null, + /** Legacy alias of `graph_dashed`, indexed by displayed parents. */ + dashed?: boolean[], +}; + export type CompareResult = { repositories: RepositoryComparison[], skippedPaths: IpcPath[], @@ -4857,6 +4906,22 @@ export type Location = { uri?: string | null, }; +export type LogDetails = { + message: string, + containingBranches: string[], + /** Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked. */ + signature: string, + signer: string, + key: string, + fingerprint: string, +}; + +export type LogIndexProgress = { + indexed: number, + total: number, + running: boolean, +}; + export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLocation = { @@ -4870,11 +4935,19 @@ export type LogOrder = /** `--topo-order`: lines of history are not intermixed. */ "topo" | /** `--date-order`: commit time, but never a parent before its children. */ -"date"; +"date" | "standard"; export type LogQuery = { + speedSearch?: string | null, + searchFields?: string[], + /** Jump directly to an indexed window containing this commit (50 rows of context). */ + seek?: string | null, + retainUntil?: string | null, branch: string | null, path: IpcPath | null, + paths?: IpcPath[], + /** File history follows renames; structure filters retain fixed paths. */ + follow?: boolean, author: string | null, text: string | null, skip: number, @@ -4884,6 +4957,8 @@ export type LogQuery = { * not the left). Takes precedence over `branch`. */ range?: string | null, + /** Additional ranges unioned with each other and with selected branches. */ + ranges?: string[], /** `text` is a regular expression (Log filter `.*`). */ textRegex?: boolean, /** `text` matches case-sensitively (Log filter `Cc`). */ @@ -4892,6 +4967,7 @@ export type LogQuery = { branches?: string[], /** More author needles next to `author`; a commit matches any of them (Log User filter). */ authors?: string[], + authorsExact?: boolean, order?: LogOrder, /** Follow only the first parent of merges (`--first-parent`). */ firstParent?: boolean, @@ -4904,6 +4980,10 @@ export type LogQuery = { */ sinceMs?: number | null, untilMs?: number | null, + request?: RequestId | null, + summaryOnly?: boolean, + /** Inclusive refresh boundary. Clients join only verified linear prefixes. */ + stopAt?: string | null, }; export type LspRefreshKind = "inlayHints" | "semanticTokens" | "codeLens"; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 2d5bdd45..6faa65f5 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -24,6 +24,7 @@ import { notifications, resolveFileEditor, useContributions, + useStandaloneEditor, type EditorCloseSession, type EditorInput, type OpenOptions, @@ -230,6 +231,7 @@ export class EditorController implements SlotHost { private closeSession: EditorCloseSession | null = null; private closeCommitted = false; private projectGeneration = 0; + private replacingProject = false; private admissionGeneration = 0; private openSequence = 0; private selectionRequests = new Map(); @@ -255,7 +257,14 @@ export class EditorController implements SlotHost { _provideOpenEditorPaths(() => openFilePaths(useTabs.getState())); let prevTabs = useTabs.getState(); this.disposers.push( - useTabs.subscribe((s) => { + useTabs.subscribe((s, previous) => { + const open = openKeys(s); + const closed = new Map(); + for (const pane of Object.values(previous.panes)) + for (const tab of pane.tabs) if (!open.has(tab.input.key)) closed.set(tab.input.key, tab.input); + if (!this.replacingProject) + for (const input of closed.values()) + useContributions.getState().editorKinds.get(input.kind)?.onClose?.(input); const prev = prevTabs; prevTabs = s; if ( @@ -265,6 +274,11 @@ export class EditorController implements SlotHost { ++this.activationGeneration; this.stampActive(s); this.onTabsChanged(prev, s); + const kinds = useContributions.getState().editorKinds; + const standalone = Object.values(s.panes) + .flatMap((pane) => pane.tabs) + .find((tab) => kinds.get(tab.input.kind)?.standalone); + useStandaloneEditor.setState({ title: standalone ? tabTitle(standalone.input, kinds) : null }); }), ); tabOptions.activateOnClose = editorPrefs().tabs.activateOnClose as typeof tabOptions.activateOnClose; @@ -299,7 +313,12 @@ export class EditorController implements SlotHost { const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds); // The guard decided already. Disposal must never prompt or autosave // custom documents after the backend has replaced their project. - useTabs.setState(initialTabsState(), true); + this.replacingProject = true; + try { + useTabs.setState(initialTabsState(), true); + } finally { + this.replacingProject = false; + } this.closeSession = null; this.closeCommitted = false; this.preparedDocuments.clear(); @@ -367,6 +386,7 @@ export class EditorController implements SlotHost { this.selectionRequests.clear(); this.manager.stop(); useTabs.setState(initialTabsState(), true); + useStandaloneEditor.setState({ title: null }); void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e)); } diff --git a/src/editor/core/inputClose.test.ts b/src/editor/core/inputClose.test.ts new file mode 100644 index 00000000..8fe0e86a --- /dev/null +++ b/src/editor/core/inputClose.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerEditorKind } from "@/workbench/contributions"; +import { EditorController, useTabs } from "./controller"; +import { createMemoryBackend } from "./backend"; +import { initialTabsState } from "./model/tabs"; +let controller: EditorController | null = null; +afterEach(() => { + controller?.stop(); + controller = null; + useTabs.setState(initialTabsState(), true); +}); +describe("custom editor close lifecycle", () => { + it("notifies once when the last split showing an input closes", async () => { + const close = vi.fn(); + const dispose = registerEditorKind({ + kind: "close-probe", + title: () => "Probe", + component: () => null, + onClose: close, + }); + controller = new EditorController(createMemoryBackend({})); + controller.start(); + const input = { kind: "close-probe", key: "close-probe:1" }; + try { + await controller.openInput(input); + controller.split(useTabs.getState().activePane, "row"); + const panes = Object.values(useTabs.getState().panes); + expect(panes).toHaveLength(2); + await controller.closeTab(panes[0]!.id, panes[0]!.tabs[0]!.id); + expect(close).not.toHaveBeenCalled(); + await controller.closeInput(input.key); + expect(close).toHaveBeenCalledExactlyOnceWith(input); + } finally { + dispose(); + } + }); +}); diff --git a/src/features/search/common/open.ts b/src/features/search/common/open.ts index a11da385..6e3b890a 100644 --- a/src/features/search/common/open.ts +++ b/src/features/search/common/open.ts @@ -14,6 +14,11 @@ export async function openLocation( pos?: Position | null, options: Omit = {}, ): Promise { + if (path.startsWith("git-log:")) { + const { executeAction } = await import("@/platform/actions"); + await executeAction("vcs.log.revealBookmark", path); + return; + } const service = maybeEditorService(); if (!service) { notifications().notify({ diff --git a/src/features/vcs/CommitChooser.tsx b/src/features/vcs/CommitChooser.tsx new file mode 100644 index 00000000..90115f3f --- /dev/null +++ b/src/features/vcs/CommitChooser.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import type { Commit } from "@/bindings"; +import { Dialog, List, SearchField } from "@/ui"; +import { openDialog } from "./dialogs"; +import { commitKey } from "./multiRoot"; + +export function chooseLogCommit(commits: Commit[], title: string): Promise { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + resolve(commit); + }} + /> + )); + }); +} + +function CommitChooser({ + commits, + title, + onDone, +}: { + commits: Commit[]; + title: string; + onDone: (commit: Commit | null) => void; +}) { + const [query, setQuery] = useState(""); + const shown = commits.filter((c) => + `${c.id} ${c.summary} ${c.author}`.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + ); + return ( + onDone(null)} width={520}> + +
+ `${c.shortId} ${c.summary} — ${c.author}`} + onActivate={onDone} + rowHeight={26} + aria-label={title} + /> +
+
+ ); +} diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index 78140139..fb89714b 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -139,12 +139,23 @@ /* commit table */ .listWrap { + position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; min-width: 0; } +.speedSearch { + position: absolute; + top: 4px; + right: 8px; + z-index: 1; + padding: 4px 8px; + background: var(--bg-popup); + border: 1px solid var(--border); + color: var(--fg); +} .list { flex: 1; min-height: 0; @@ -165,6 +176,39 @@ cursor: default; font-size: var(--fs-ui); } +.columnHeader { + position: sticky; + top: 0; + z-index: 2; + background: var(--bg-app); + border-bottom: 1px solid var(--border); + color: var(--fg-subtle); +} +.columnHeader:hover { + background: var(--bg-app); +} +.columnCell { + flex: none; + overflow: hidden; + text-overflow: ellipsis; +} +.columnHeading { + position: relative; + padding-inline-end: 6px; + cursor: grab; +} +.columnResize { + position: absolute; + inset-block: 0; + inset-inline-end: 0; + width: 6px; + cursor: col-resize; + touch-action: none; +} +.columnResize:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: -2px; +} /* View Options › Highlight › Current Branch: commits reachable from HEAD are tinted. */ .currentBranch { --log-row-bg: var(--bg-log-current-branch); @@ -342,6 +386,21 @@ user-select: text; font-size: var(--fs-ui); } +.commitTooltip { + width: min(520px, calc(100vw - 32px)); + max-height: 60vh; + overflow: auto; + padding: 12px; + white-space: normal; + user-select: text; +} +.revisionSource { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} .subjectLine { color: var(--fg-strong); font-weight: 600; @@ -349,6 +408,12 @@ word-break: break-word; line-height: 1.5; } +.expandMessage { + display: block; + margin-block-start: 6px; + font-weight: normal; + font-size: var(--fs-small); +} .fullMessage { margin-top: 6px; color: var(--fg); diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 124306da..8f781519 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1,3 +1,5 @@ +import { useBookmarkPopups } from "@/features/search/bookmarks/store"; +import { commitBookmarkPath } from "./logBookmarks"; import { copySelectionOnKeyDown } from "@/ui/selectableText"; import { branchesByRepo, commitKey, commitRepo, graphCommit, type RepoCommit } from "./multiRoot"; /** Git tool window: Log (graph, filters, commit details) and Console tabs. */ @@ -6,6 +8,7 @@ import { BranchFilterPopup } from "./BranchFilterPopup"; import { memo, useCallback, + useContext, useEffect, useLayoutEffect, useMemo, @@ -13,10 +16,11 @@ import { useState, type KeyboardEvent, type MouseEvent as ReactMouseEvent, + type ReactNode, } from "react"; import clsx from "clsx"; import { useVirtualizer } from "@tanstack/react-virtual"; -import type { Branch, ChangedFile, Commit, RevisionChange } from "@/bindings"; +import type { Branch, ChangedFile, Commit } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { maybeEditorService, type EditorInput } from "@/workbench/contributions"; @@ -29,6 +33,9 @@ import { SplitHandle, SplitPanel, Spinner, + Dialog, + Popup, + TOOLTIP_DELAY, Tabs, Tree, fileIconName, @@ -37,9 +44,13 @@ import { type TreeHandle, T, copyText, + lazyComponent, } from "@/ui"; import { hasPrimaryModifier } from "@/platform/keymap"; -import { collapseLinear, computeGraph, hideLongEdges, laneColor, reachableFrom, type GraphRow } from "./graph"; +import { laneColor, reachableFrom, type GraphRow, type GraphSegment } from "./graph"; +import { nextSpeedMatch } from "./logSearch"; +import { LogIndexStatus } from "./logIndex"; +import { presentLogGraph } from "./logPresentation"; import { closeLogTab, extendCommitSelection, @@ -49,10 +60,16 @@ import { isPushed, isMine, loadMore, + logState, + loadNewer, ME, dateRangeLabel, refsSignature, reloadLog, + openLogTab, + recentLogFilters, + recentFilterValues, + toggleCommitBookmark, selectCommit, selectGitTab, selectedCommits, @@ -78,7 +95,7 @@ import { isFavoriteBranch, type BranchTreeNode, } from "./branches"; -import { chooseDateRange, chooseMany, promptText } from "./dialogs"; +import { chooseDateRange, chooseMany, openDialog } from "./dialogs"; import { buildFileTree, dirKeys, type FileTreeNode } from "./fileTree"; import { DIFF_KIND, diffInput } from "./diffInput"; import { @@ -97,9 +114,37 @@ import { useWorkflowSettings } from "@/platform/workflowSettings"; import { gitTabs, shownTab } from "./gitTabs"; import { LocalChangesView, StashContent, tabItems, useStashTabCounts } from "./CommitView"; import { WorktreesPanel } from "./gitMenuDialogs"; +import { chooseLogPaths } from "./PathFilterDialog"; +import { goToRef } from "./logStore"; +import { LogIdContext } from "./logContext"; +import { LogScope } from "./LogScope"; +import { browseRepositoryAt, openExternalLog } from "./RevisionBrowser"; +import { invalidateLogDetails, loadLogDetails, useCommitDetails } from "./logDetails"; const ROW = 26; const LANE = 14; +interface LogColumn { + id: string; + title: string; + width: number; + render(commit: Commit): ReactNode; +} +const LogMergePreview = lazyComponent(() => import("./GitMergeDiff").then((m) => m.GitMergeDiff)); +const LogDiffPreview = lazyComponent(() => import("./DiffEditor").then((m) => m.DiffEditor)); + +function useLogRepositories() { + const scope = useContext(LogIdContext); + const tab = useGitLog((s) => s.logTabs.find((t) => t.id === (scope ?? s.shownLog))); + const roots = useVcs((s) => s.roots); + const repo = tab?.repo; + const repositories = tab?.repositories; + return useMemo(() => (repositories?.length ? repositories : repo ? [repo] : roots), [repo, roots, repositories]); +} + +function useReadOnlyLog() { + const scope = useContext(LogIdContext); + return useGitLog((s) => s.logTabs.find((t) => t.id === (scope ?? s.shownLog))?.readOnly === true); +} /** The Git window's tabs (they depend on the commit interface) and the one shown. */ function useGitTabs() { @@ -116,8 +161,9 @@ function useGitTabs() { /** Tool window header: [Local Changes / Stash…] Log, log tabs, Console and Worktrees content tabs. */ export function GitTabs() { const available = useVcs((s) => s.available); + const external = useGitLog((s) => s.logTabs.some((t) => t.id === s.tab && t.readOnly)); const { tabs, shown } = useGitTabs(); - if (!available) return null; + if (!available && !external) return null; return ( s.shownLog); const available = useVcs((s) => s.available); - if (!available) + const external = useGitLog((s) => s.logTabs.some((t) => t.id === s.tab && t.readOnly)); + if (!available && !external) return ( The project is not a Git repository. @@ -159,7 +205,9 @@ export function GitToolWindow() { ) : ( // Each log tab has its own view (its filters and commits are swapped into the store). - + + + )} ); @@ -168,43 +216,78 @@ export function GitToolWindow() { // --------------------------------------------------------------------------- // Log -function LogView() { +export function LogView() { + const scope = useContext(LogIdContext); + const shown = useGitLog((s) => s.shownLog); + const active = !scope || scope === shown; const version = useVcs((s) => s.version); const commits = useGitLog((s) => s.commits); const selection = useGitLog((s) => s.selection); const root = useVcs((s) => s.root); - const repo = useVcs((s) => s.roots); + const repo = useLogRepositories(); + const readOnly = useReadOnlyLog(); const showBranches = useLogOptions((s) => s.showBranches); + const showDetails = useLogOptions((s) => s.showDetails); const branchesLoaded = useVcs((s) => s.branchesLoaded); const lastRefs = useRef(null); const allRefs = useRef(null); + const externalRefs = useRef(null); useEffect(() => { let live = true; - void branchesByRepo(repo) - .then((all) => { + void Promise.all([ + branchesByRepo(repo), + Promise.all(repo.map(async (root) => [root, await call(commands.vcsLogRefs(root))] as const)), + ]) + .then(([all, refs]) => { if (!live) return; - const signature = JSON.stringify(all); - if (allRefs.current !== null && allRefs.current !== signature) void reloadLog({ preserve: true }); + const signature = JSON.stringify([all, refs]); + if (allRefs.current !== null && allRefs.current !== signature) { + invalidateLogDetails(repo); + void reloadLog({ preserve: true }, scope ?? undefined); + } allRefs.current = signature; + if (readOnly) externalRefs.current = JSON.stringify(refs.map(([, value]) => value)); }) .catch(() => undefined); return () => { live = false; }; - }, [repo, version]); + }, [repo, version, readOnly, scope]); + useEffect(() => { + if (!readOnly) return; + let live = true; + const timer = setInterval(() => { + void Promise.all(repo.map((root) => call(commands.vcsLogRefs(root)))) + .then((refs) => { + if (!live) return; + const signature = JSON.stringify(refs); + if (externalRefs.current && externalRefs.current !== signature) { + invalidateLogDetails(repo); + void reloadLog({ preserve: true }, scope ?? undefined); + } + externalRefs.current = signature; + }) + .catch(() => undefined); + }, 5000); + return () => { + live = false; + clearInterval(timer); + }; + }, [repo, readOnly, scope]); // On mount, when the window switches to another project and when another of its // repositories is selected (its branches are needed to notice its refs moving). useEffect(() => { lastRefs.current = null; allRefs.current = null; - if (!useGitLog.getState().commits.length) void reloadLog(); - void loadBranches().catch(() => undefined); - }, [root, repo]); + void reloadLog({ preserve: logState(scope ?? undefined).commits.length > 0 }, scope ?? undefined); + if (!readOnly) void loadBranches().catch(() => undefined); + }, [root, repo, readOnly, scope]); // Once the repository's branches are known, settle the branch filter (the // remembered refs, else the current branch) and load the log with it. useEffect(() => { + if (!active) return; if (!branchesLoaded) return; // The refs the log is loaded with, so the first checkout or commit after this reloads it. const { branch, branches } = useVcs.getState(); @@ -212,10 +295,11 @@ function LogView() { const settled = initLogBranchFilter(); const { commits, loading } = useGitLog.getState(); if (settled || (!commits.length && !loading)) void reloadLog(); - }, [root, repo, branchesLoaded]); + }, [root, repo, branchesLoaded, active]); // HEAD or a branch tip moved (commit, undo, reset, rebase, fetch…): reload (keeps the selection). useEffect(() => { + if (!active) return; const { branch, branches, branchesLoaded } = useVcs.getState(); if (!branchesLoaded) return; const refs = refsSignature(branch, branches) + JSON.stringify(useVcs.getState().heads); @@ -223,7 +307,7 @@ function LogView() { lastRefs.current = refs; if (previous !== null && previous !== refs && useGitLog.getState().commits.length) void reloadLog({ preserve: true }); - }, [version]); + }, [version, active]); return ( // Its own layout without the Branches pane, so each keeps its sizes. @@ -244,13 +328,18 @@ function LogView() {
+
- - - - + {showDetails && ( + <> + + + + + + )} ); } @@ -265,12 +354,24 @@ function branchDetail(n: BranchTreeNode): string | undefined { } export function BranchesPanel() { + const scope = useContext(LogIdContext); + const shown = useGitLog((s) => s.shownLog); + const active = !scope || scope === shown; + const logGeneration = useGitLog((s) => s.generation); const selectedBranches = useVcs((s) => s.branches); - const repositories = useVcs((s) => s.roots); + const repositories = useLogRepositories(); + const readOnly = useReadOnlyLog(); const version = useVcs((s) => s.version); const [allBranches, setAllBranches] = useState>({}); const [allTags, setAllTags] = useState>({}); - const branches = selectedBranches; + const currentRepository = currentRepo(); + const branches = useMemo( + () => + repositories.length === 1 && repositories[0] !== currentRepository + ? (allBranches[repositories[0]!] ?? []) + : selectedBranches, + [repositories, currentRepository, allBranches, selectedBranches], + ); const root = useVcs((s) => s.root); const branchesLoaded = useVcs((s) => s.branchesLoaded); const current = useVcs((s) => s.branch?.head ?? null); @@ -283,14 +384,14 @@ export function BranchesPanel() { // Branches reload when refs move (fetch, new tag…): the tags are re-read with them. useEffect(() => { let live = true; - call(commands.vcsTags(currentRepo())).then( + call(commands.vcsTags(repositories[0] ?? currentRepo())).then( (t) => live && setTags(t), () => live && setTags([]), ); return () => { live = false; }; - }, [root, branches]); + }, [root, branches, repositories]); useEffect(() => { let live = true; void branchesByRepo(repositories) @@ -302,7 +403,7 @@ export function BranchesPanel() { return () => { live = false; }; - }, [repositories, version]); + }, [repositories, version, logGeneration]); const allRoots = useMemo(() => { if (repositories.length < 2) return buildBranchTree(branches, favorites, tags); return buildRepositoryBranchTree( @@ -357,9 +458,10 @@ export function BranchesPanel() { [refs, selected], ); useEffect(() => { + if (!active) return; useBranchesPane.setState({ selected: targets }); - }, [targets]); - useEffect(() => () => useBranchesPane.setState({ selected: [] }), []); + return () => useBranchesPane.setState({ selected: [] }); + }, [targets, active]); const target = targets[0] ?? undefined; const favorite = !!target?.branch && isFavoriteBranch(target.branch, favorites); const onKeyDownCapture = (e: KeyboardEvent) => { @@ -376,42 +478,51 @@ export function BranchesPanel() {
- + {!readOnly && ( + <> + + + + + + + + )} (readOnly ? void reloadLog({ preserve: true }) : void loadBranches())} /> - - - - - void loadBranches()} />
@@ -462,6 +573,15 @@ export function BranchesPanel() { setLogFilters({ branches: sel, repositories: n.repo ? [n.repo] : [] }); }} onContextMenu={(n, _s, e) => { + if (readOnly) { + if (!n?.ref) return; + e.preventDefault(); + showContextMenu(e, [ + { label: "Show in Git Log", run: () => setLogBranches([n.ref!]) }, + { label: "Copy", run: () => void copyText(n.ref!) }, + ]); + return; + } const menu = n?.branch ? branchMenu(n.branch, n.repo ? (useVcs.getState().heads[n.repo]?.head ?? null) : current) : n?.kind === "tag" @@ -558,13 +678,19 @@ function FilterButton({ label, value, onOpen, onClear, hidden }: LogFilter & { h } function FilterBar() { + const scope = useContext(LogIdContext); + const shown = useGitLog((s) => s.shownLog); + const active = !scope || scope === shown; + const logGeneration = useGitLog((s) => s.generation); const filters = useGitLog((s) => s.filters); const commits = useGitLog((s) => s.commits); const selectedBranches = useVcs((s) => s.branches); const [branches, setBranches] = useState(selectedBranches); const version = useVcs((s) => s.version); const root = useVcs((s) => s.root); - const repositories = useVcs((s) => s.roots); + const repositories = useLogRepositories(); + const readOnly = useReadOnlyLog(); + const bookmarks = useGitLog((s) => s.bookmarks); const showBranches = useLogOptions((s) => s.showBranches); useEffect(() => { let live = true; @@ -584,7 +710,7 @@ function FilterBar() { return () => { live = false; }; - }, [repositories, filters.repositories, version]); + }, [repositories, filters.repositories, version, logGeneration]); const [text, setText] = useState(filters.text); const [branchAnchor, setBranchAnchor] = useState(null); // Filters that do not fit move behind a chevron, like a toolbar's overflow. @@ -613,10 +739,10 @@ function FilterBar() { } useEffect(() => { - if (text === filters.text) return; + if (!active || text === filters.text) return; const t = setTimeout(() => setLogFilters({ text }), 300); return () => clearTimeout(t); - }, [text, filters.text]); + }, [text, filters.text, active]); const branchNames = () => branches.filter((b) => !b.name.endsWith("/HEAD")).map((b) => b.name); @@ -624,19 +750,38 @@ function FilterBar() { setBranchAnchor(null); const r = await chooseMany({ title: "Filter by Branch", - options: ["HEAD", ...branchNames()], + options: [ + ...new Set([ + "HEAD", + ...branchNames(), + ...recentFilterValues("branches").flat(), + ...recentFilterValues("range").filter((s): s is string => !!s), + ]), + ], initial: filters.branches, - customLabel: "Other revisions (comma-separated):", + customLabel: "Branches, patterns, exclusions or ranges (comma-separated):", }); if (r) setLogBranches(r); }; - const openUser = (anchor: HTMLElement) => { - const authors = [...new Set(commits.map((c) => c.author))].sort((a, b) => a.localeCompare(b)).slice(0, 30); + const openUser = async (anchor: HTMLElement) => { + const roots = filters.repositories?.length + ? filters.repositories + : repositories.length + ? repositories + : [currentRepo()]; + const users = await Promise.all(roots.map((repo) => call(commands.vcsLogUsers(repo)).catch(() => [] as string[]))); + const authors = [...new Set(users.flat().concat(commits.map((c) => `${c.author} <${c.email}>`)))].sort((a, b) => + a.localeCompare(b), + ); menuBelow(anchor, [ + ...recentFilterValues("authors").map((authors) => ({ + label: authors.join(", "), + run: () => setLogFilters({ authors }), + })), { label: ME, checked: filters.authors.includes(ME), run: () => setLogFilters({ authors: [ME] }) }, ...(authors.length ? [{ kind: "separator" as const }] : []), - ...authors.map((a) => ({ + ...authors.slice(0, 30).map((a) => ({ label: a, checked: filters.authors.includes(a), run: () => setLogFilters({ authors: [a] }), @@ -659,6 +804,9 @@ function FilterBar() { const openDate = (anchor: HTMLElement) => menuBelow(anchor, [ + ...recentFilterValues("date") + .filter((date) => date != null) + .map((date) => ({ label: date.label, run: () => setLogFilters({ date }) })), ...DATE_OPTIONS.map((o) => ({ label: o.label, checked: filters.date?.label === o.label, @@ -676,14 +824,18 @@ function FilterBar() { const openPaths = (anchor: HTMLElement) => menuBelow(anchor, [ + ...recentFilterValues("paths") + .filter((paths) => paths?.length) + .map((paths) => ({ label: paths!.join(", "), run: () => setLogFilters({ paths, path: null }) })), { label: "Select Folders…", run: async () => { - const cur = filters.path && root ? relTo(root, filters.path) : (filters.path ?? ""); - const r = await promptText({ title: "Filter by Path", label: "File or folder", initial: cur, mono: true }); - if (!r) return; - const v = r.value.trim(); - setLogFilters({ path: v ? (v.startsWith("/") || !root ? v : `${root}/${v}`) : null }); + const paths = await chooseLogPaths( + repositories.length ? repositories : root ? [root] : [], + filters.paths ?? (filters.path ? [filters.path] : []), + readOnly, + ); + if (paths) setLogFilters({ path: null, paths }); }, }, ]); @@ -709,7 +861,7 @@ function FilterBar() { : []), { label: "Branch", - value: filters.branches.join(", ") || null, + value: filters.range || filters.branches.join(", ") || null, onOpen: setBranchAnchor, onClear: () => setLogBranches([]), }, @@ -727,9 +879,13 @@ function FilterBar() { }, { label: "Paths", - value: filters.path ? (root ? relTo(root, filters.path) : filters.path) || "." : null, + value: filters.paths?.length + ? filters.paths.map((p) => (root ? relTo(root, p) : p)).join(", ") + : filters.path + ? (root ? relTo(root, filters.path) : filters.path) || "." + : null, onOpen: openPaths, - onClear: () => setLogFilters({ path: null }), + onClear: () => setLogFilters({ path: null, paths: [] }), }, ]; @@ -798,6 +954,71 @@ function FilterBar() { /> )} + + menuBelow( + e, + Object.entries(bookmarks).map(([key, summary]) => ({ + label: summary, + run: () => { + const split = key.lastIndexOf("\0"); + void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : undefined); + }, + })), + ) + } + /> + + menuBelow(e, [ + { + label: "In Tool Window", + run: () => { + openLogTab("log", `Log ${useGitLog.getState().logTabs.length + 1}`); + }, + }, + { + label: "In Editor", + run: () => { + openLogTab("log", `Log ${useGitLog.getState().logTabs.length + 1}`, "editor"); + }, + }, + { label: "External Repository…", run: openExternalLog }, + ]) + } + /> + + menuBelow( + e, + recentLogFilters().map((filters) => ({ + label: + [ + filters.range, + filters.branches.join(", "), + filters.authors.join(", "), + filters.text, + filters.path, + filters.paths?.join(", "), + filters.date?.label, + ] + .filter(Boolean) + .join(" · ") || tx("All commits"), + run: () => setLogFilters(filters), + })), + ) + } + /> void reloadLog({ preserve: true })} /> updateLogOptions({ sort: "date" }) }, { label: "Topologically", checked: o.sort === "topo", run: () => updateLogOptions({ sort: "topo" }) }, + { + label: "Incoming Commits First", + checked: o.sort === "standard", + run: () => updateLogOptions({ sort: "standard" }), + }, { kind: "separator" }, { kind: "header", label: "Options" }, toggle("First Parent", o, "firstParent"), toggle("No Merges", o, "noMerges"), + { + label: "Linearize Merges", + checked: o.linearizeMerges, + run: () => + updateLogOptions({ linearizeMerges: !o.linearizeMerges, ...(!o.linearizeMerges ? { sort: "standard" } : {}) }), + }, { kind: "separator" }, { kind: "header", label: "Branch Actions" }, { kind: "action", action: "vcs.log.collapseLinear" }, @@ -850,9 +1082,16 @@ export function viewOptionsMenu(o: LogOptions): MenuItem[] { toggle("Long Edges", o, "longEdges"), toggle("Commit Timestamp", o, "commitTimestamp"), toggle("References on the Left", o, "refsOnLeft"), + toggle("Details", o, "showDetails"), + toggle("Repository", o, "showRepository"), { label: "Columns", - submenu: [toggle("Author", o, "showAuthor"), toggle("Date", o, "showDate"), toggle("Hash", o, "showHash")], + submenu: [ + toggle("Author", o, "showAuthor"), + toggle("Date", o, "showDate"), + toggle("Hash", o, "showHash"), + toggle("Signature", o, "showSignature"), + ], }, { kind: "separator" }, { kind: "header", label: "Highlight" }, @@ -864,6 +1103,22 @@ export function viewOptionsMenu(o: LogOptions): MenuItem[] { /** IDEA's Git Log commit context menu (Git.Log.ContextMenu); `selection` = all selected commits, newest first. */ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { + if ((c as RepoCommit).readOnly) + return [ + { kind: "action", action: "vcs.log.copyRevision", arg: c }, + { label: "Copy Commit Message", run: () => copyCommitMessage(c) }, + { + label: useGitLog.getState().bookmarks[commitKey(c)] ? "Remove Commit Bookmark" : "Bookmark Commit", + run: () => toggleCommitBookmark(c), + }, + { + label: "Assign Mnemonic", + run: () => useBookmarkPopups.setState({ chooser: { path: commitBookmarkPath(c), line: 0, text: c.summary } }), + }, + { kind: "action", action: "vcs.log.goToParent", arg: c }, + { kind: "action", action: "vcs.log.goToChild", arg: c }, + { label: "Browse Repository at Revision", run: () => browseRepositoryAt(c) }, + ]; const many = selection.length > 1 && selection.some((x) => commitKey(x) === commitKey(c)); const head = (commitRepo(c) ? useVcs.getState().heads[commitRepo(c)!]?.head : useVcs.getState().branch?.head) ?? null; const { outgoing } = useGitLog.getState(); @@ -894,11 +1149,16 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { : true); return [ { kind: "action", action: "vcs.log.copyRevision", arg: c }, - { label: "Copy Commit Message", run: () => void navigator.clipboard?.writeText(c.message) }, + { + label: useGitLog.getState().bookmarks[commitKey(c)] ? "Remove Commit Bookmark" : "Bookmark Commit", + run: () => toggleCommitBookmark(c), + }, + { label: "Copy Commit Message", run: () => copyCommitMessage(c) }, { kind: "action", action: "vcs.log.createPatch", arg: c }, { kind: "action", action: "vcs.log.cherryPick", arg: c }, { kind: "separator" }, { kind: "action", action: "vcs.log.checkoutRevision", arg: c }, + { label: "Browse Repository at Revision", run: () => browseRepositoryAt(c) }, { kind: "action", action: "vcs.log.compareWithLocal", arg: c }, { kind: "separator" }, { kind: "action", action: "vcs.log.resetToHere", arg: c }, @@ -922,6 +1182,10 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { { kind: "action", action: "vcs.log.newBranch", arg: c }, { kind: "action", action: "vcs.log.newTag", arg: c }, { kind: "separator" }, + { + label: "Assign Mnemonic", + run: () => useBookmarkPopups.setState({ chooser: { path: commitBookmarkPath(c), line: 0, text: c.summary } }), + }, { kind: "action", action: "vcs.log.goToChild", arg: c }, { kind: "action", action: "vcs.log.goToParent", arg: c, disabled: !c.parents.length }, { kind: "separator" }, @@ -929,10 +1193,58 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] { ]; } +async function copyCommitMessage(c: Commit) { + const message = c.messageTruncated ? (await loadLogDetails(c)).message : c.message; + await copyText(message); +} + function CommitList() { + const scope = useContext(LogIdContext); + const shown = useGitLog((s) => s.shownLog); + const active = !scope || scope === shown; const all = useGitLog((s) => s.commits); - const allRows = useGitLog((s) => s.rows); + const selection = useGitLog((s) => s.selection); + const earlier = useMemo(() => { + const seen = new Set(); + return all.some((c) => { + const repo = commitRepo(c); + if (seen.has(repo)) return false; + seen.add(repo); + return (c.logPosition ?? 0) > 0; + }); + }, [all]); const opts = useLogOptions(); + const bookmarks = useGitLog((s) => s.bookmarks); + const fragments = useGitLog((s) => s.fragments); + const { expanded: expandedFragments, collapsed: collapsedFragments, merges: expandedMerges } = fragments; + const [hoverColor, setHoverColor] = useState(null); + const [boundaryTitles, setBoundaryTitles] = useState>(() => new Map()); + const boundaryRequests = useRef(new Set()); + const [speedSearch, setSpeedSearch] = useState(""); + const speedGeneration = useRef(0); + const speedTimer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(speedTimer.current), []); + const columns = useMemo(() => { + const builtins: LogColumn[] = [ + ...(opts.showAuthor ? [{ id: "author", title: "Author", width: 130, render: (c: Commit) => c.author }] : []), + ...(opts.showDate + ? [ + { + id: "date", + title: "Date", + width: 120, + render: (c: Commit) => formatTime(opts.commitTimestamp ? (c.commitTimeMs ?? c.timeMs) : c.timeMs), + }, + ] + : []), + ...(opts.showHash ? [{ id: "hash", title: "Hash", width: 72, render: (c: Commit) => c.shortId }] : []), + ...(opts.showSignature + ? [{ id: "signature", title: "Signature", width: 100, render: (c: Commit) => }] + : []), + ]; + const order = [...new Set([...opts.columnOrder, ...builtins.map((c) => c.id)])]; + return builtins.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + }, [opts.showAuthor, opts.showDate, opts.showHash, opts.showSignature, opts.commitTimestamp, opts.columnOrder]); const user = useGitLog((s) => s.user); const headCommit = useVcs((s) => s.branch?.headCommit ?? null); const branches = useVcs((s) => s.branches); @@ -943,23 +1255,66 @@ function CommitList() { const headKeys = useMemo( () => new Set( - all.filter((c) => c.id === (commitRepo(c) ? heads[commitRepo(c)!]?.headCommit : headCommit)).map(commitKey), + all + .filter( + (c) => c.refs.includes("HEAD") || c.id === (commitRepo(c) ? heads[commitRepo(c)!]?.headCommit : headCommit), + ) + .map(commitKey), ), [all, heads, headCommit], ); - const collapsed = useMemo( - () => (opts.collapseLinear ? collapseLinear(all.map(graphCommit), headKeys) : null), - [all, opts.collapseLinear, headKeys], + const { + commits, + rows, + linear, + collapsed, + graph: displayedGraph, + } = useMemo( + () => presentLogGraph(all, opts, fragments, new Set([...headKeys, ...selection, ...Object.keys(bookmarks)])), + [all, opts, fragments, headKeys, selection, bookmarks], + ); + const { edges: highlighted, nodes: highlightedNodes } = useMemo(() => { + const empty = { edges: new Set(), nodes: new Set() }; + if (!hoverColor) return empty; + const nodeId = hoverColor.startsWith("node:") ? hoverColor.slice(5) : null; + const merge = nodeId ?? linear?.fragments.get(hoverColor); + const fragment = merge ? linear?.nodes.get(merge) : null; + const nodes = new Set(fragment); + if (!fragment) { + const source = nodeId + ? displayedGraph.find((c) => c.id === nodeId) + : displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === hoverColor)); + const target = source?.parents.find((p) => nodeId || `${source.id}\0${p}` === hoverColor); + if (source) nodes.add(source.id); + const byId = new Map(displayedGraph.map((c) => [c.id, c])); + const children = new Map(); + for (const c of displayedGraph) for (const p of c.parents) children.set(p, (children.get(p) ?? 0) + 1); + for (let current = target; current && !nodes.has(current);) { + nodes.add(current); + const c = byId.get(current); + if (!c || c.parents.length !== 1 || children.get(current) !== 1 || c.refs?.length || headKeys.has(current)) + break; + current = c.parents[0]; + } + } + return { + nodes, + edges: new Set( + displayedGraph.flatMap((c) => + nodes.has(c.id) ? c.parents.filter((p) => nodes.has(p)).map((p) => `${c.id}\0${p}`) : [], + ), + ), + }; + }, [hoverColor, linear, displayedGraph, headKeys]); + const minWidth = useMemo( + () => + rows.reduce((width, row) => Math.max(width, row.width * LANE), 100) + + columns.reduce( + (width, column) => width + (opts.columnWidths[column.id] ?? column.width ?? 120), + opts.showRepository && useVcs.getState().roots.length > 1 ? 220 : 120, + ), + [rows, columns, opts.columnWidths, opts.showRepository], ); - const commits = useMemo(() => { - if (!collapsed) return all; - const byKey = new Map(all.map((c) => [commitKey(c), c])); - return collapsed.commits.map((c) => byKey.get(c.id)!); - }, [all, collapsed]); - const rows = useMemo(() => { - const laid = collapsed ? computeGraph(collapsed.graph).rows : allRows; - return opts.longEdges ? laid : hideLongEdges(laid); - }, [collapsed, allRows, opts.longEdges]); const filterBranches = useGitLog((s) => s.filters.branches); const currentBranch = useVcs((s) => s.branch?.head ?? null); // Nothing to tell apart when the log is filtered to the current branch. @@ -968,29 +1323,48 @@ function CommitList() { if (!opts.highlightCurrentBranch || onlyCurrent) return null; const reach = new Set(); for (const head of headKeys) for (const id of reachableFrom(all.map(graphCommit), head)) reach.add(id); + for (const c of all) { + if (c.onCurrentBranch === true) reach.add(commitKey(c)); + else if (c.onCurrentBranch === false) reach.delete(commitKey(c)); + } return reach; }, [all, headKeys, opts.highlightCurrentBranch, onlyCurrent]); const loading = useGitLog((s) => s.loading); const done = useGitLog((s) => s.done); const error = useGitLog((s) => s.error); const selected = useGitLog((s) => s.selected); - const selection = useGitLog((s) => s.selection); const selectedSet = useMemo(() => new Set(selection), [selection]); const revealTick = useGitLog((s) => s.revealTick); const scrollRef = useRef(null); + const previousRows = useRef(commits); + useLayoutEffect(() => { + const element = scrollRef.current; + const previous = previousRows.current; + previousRows.current = commits; + if (!element || previous === commits) return; + const top = Math.max(0, Math.floor((element.scrollTop - ROW) / ROW)); + // Preserve the first surviving visible commit and its pixel offset. + for (let i = top; i < previous.length; i++) { + const next = commits.findIndex((c) => commitKey(c) === commitKey(previous[i]!)); + if (next < 0) continue; + element.scrollTop += (next - i) * ROW; + break; + } + }, [commits]); const finished = done; const virtualizer = useVirtualizer({ count: commits.length + (finished ? 0 : 1), getScrollElement: () => scrollRef.current, estimateSize: () => ROW, overscan: 20, + scrollMargin: ROW, }); const items = virtualizer.getVirtualItems(); const last = items[items.length - 1]; useEffect(() => { - if (!finished && !loading && last && last.index >= commits.length - 40) void loadMore(); - }, [last, commits.length, finished, loading]); + if (!finished && !loading && last && last.index >= commits.length - 40) void loadMore(scope ?? undefined); + }, [last, commits.length, finished, loading, active, scope]); const selectedIndex = selected ? commits.findIndex((c) => commitKey(c) === selected) : -1; useLayoutEffect(() => { @@ -1011,6 +1385,54 @@ function CommitList() { }, []); const onKeyDown = (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key.length === 1 || e.key === "Backspace" || e.key === "Escape")) { + const query = e.key === "Escape" ? "" : e.key === "Backspace" ? speedSearch.slice(0, -1) : speedSearch + e.key; + setSpeedSearch(query); + speedGeneration.current++; + clearTimeout(speedTimer.current); + speedTimer.current = setTimeout(() => setSpeedSearch(""), 1500); + if (query) { + const found = nextSpeedMatch(commits, query, opts, -1); + if (found >= 0) { + selectCommit(commitKey(commits[found]!), true); + virtualizer.scrollToIndex(found, { align: "auto" }); + } else if (query.length >= 2) { + const generation = ++speedGeneration.current; + const roots = [...new Set(all.map(commitRepo))]; + void Promise.all( + roots.map(async (repo) => { + const [hit] = await call( + commands.vcsLog(repo ?? currentRepo(), { + skip: 0, + limit: 1, + summaryOnly: true, + speedSearch: query, + searchFields: [...(opts.showAuthor ? ["author"] : []), ...(opts.showHash ? ["hash"] : [])], + branch: null, + path: null, + author: null, + text: null, + }), + ); + return hit ? { ...hit, repo } : null; + }), + ) + .then((hits) => { + const hit = hits.find(Boolean); + if (hit && speedGeneration.current === generation) void goToRef(hit.id, commitRepo(hit)); + }) + .catch(() => undefined); + } + } + e.preventDefault(); + return; + } + if (speedSearch && (e.key === "F3" || e.key === "Enter")) { + e.preventDefault(); + const found = nextSpeedMatch(commits, speedSearch, opts, selectedIndex, e.shiftKey ? -1 : 1); + if (found >= 0) selectCommit(commitKey(commits[found]!), true); + return; + } const lead = selectedIndex >= 0 ? commits[selectedIndex] : undefined; if (lead && (e.key === "ArrowLeft" || e.key === "ArrowRight") && !e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); @@ -1037,8 +1459,113 @@ function CommitList() { if (error && !commits.length) return
{error}
; + const fragment = (from: string, target: string, expand: boolean) => { + if (from === target && (expandedMerges.has("*") ? !expandedMerges.has(from) : expandedMerges.has(from))) { + useGitLog.setState({ + fragments: { + ...fragments, + merges: expandedMerges.has("*") + ? new Set([...expandedMerges, from]) + : new Set([...expandedMerges].filter((id) => id !== from)), + }, + }); + return; + } + if ( + from === target && + linear && + all.find((c) => commitKey(c) === from)?.parents.length === 2 && + linear.graph.find((c) => c.id === from)?.parents.length === 1 + ) { + useGitLog.setState({ + fragments: { + ...fragments, + merges: expandedMerges.has("*") + ? new Set([...expandedMerges].filter((id) => id !== from)) + : new Set([...expandedMerges, from]), + }, + }); + return; + } + if (from === target) { + const shown = (linear?.graph ?? collapsed?.graph ?? all.map(graphCommit)).find((c) => c.id === from); + target = shown?.parents[0] ?? target; + expand = !!shown?.dashed?.[0]; + } + const merge = linear?.fragments.get(`${from}\0${target}`); + if (merge) { + useGitLog.setState({ + fragments: { + ...fragments, + merges: expandedMerges.has("*") + ? new Set([...expandedMerges].filter((id) => id !== merge)) + : new Set([...expandedMerges, merge]), + }, + }); + return; + } + const graph = new Map( + all.map((c) => { + const g = graphCommit(c); + return [g.id, g] as const; + }), + ); + const childCounts = new Map(); + for (const c of graph.values()) + for (const parent of c.parents) childCounts.set(parent, (childCounts.get(parent) ?? 0) + 1); + const hidden: string[] = []; + for (const start of expand ? (graph.get(from)?.parents ?? []) : [target]) { + const chain: string[] = []; + let current: string | undefined = start; + const seen = new Set(); + while (current && !seen.has(current)) { + seen.add(current); + const node = graph.get(current); + if ( + !node || + (expand && current === target) || + node.parents.length !== 1 || + childCounts.get(current) !== 1 || + node.refs.length || + headKeys.has(current) + ) + break; + chain.push(current); + current = node.parents[0]; + } + if (!expand || current === target) hidden.push(...chain); + } + if (expand) { + useGitLog.setState({ + fragments: { + ...fragments, + expanded: new Set([...expandedFragments, ...hidden]), + collapsed: new Set([...collapsedFragments].filter((id) => !hidden.includes(id))), + }, + }); + } else { + useGitLog.setState({ + fragments: { + ...fragments, + expanded: new Set([...expandedFragments].filter((id) => !hidden.includes(id))), + collapsed: new Set([...collapsedFragments, ...hidden]), + }, + }); + } + }; + return (
+ {earlier && ( + + )} + {speedSearch && ( +
+ {speedSearch} +
+ )} {error && (
{error} @@ -1050,19 +1577,51 @@ function CommitList() { role="listbox" aria-label={tx("Commits")} aria-multiselectable - aria-activedescendant={selectedIndex >= 0 ? `commit-${selectedIndex}` : undefined} + aria-activedescendant={selectedIndex >= 0 ? `commit-${scope ?? "log"}-${selectedIndex}` : undefined} tabIndex={0} onKeyDown={onKeyDown} + onCopy={(e) => { + if (window.getSelection()?.toString()) return; + const selected = commits.filter((c) => selectedSet.has(commitKey(c))); + if (!selected.length) return; + e.preventDefault(); + e.clipboardData.setData( + "text/plain", + selected + .map((c) => + [ + c.summary, + c.refs.join(" "), + ...(opts.showRepository ? [commitRepo(c) ?? ""] : []), + ...columns.map((column) => (typeof column.render(c) === "string" ? column.render(c) : "")), + ].join("\t"), + ) + .join("\n"), + ); + e.clipboardData.setData( + "application/x-openidea-vcs-commits", + JSON.stringify({ version: 1, commits: selected.map((c) => ({ root: commitRepo(c), hash: c.id })) }), + ); + }} > +
+ {opts.showRepository && useVcs.getState().roots.length > 1 && ( + {tx("Repository")} + )} + {tx("Commit")} + {columns.map((column) => ( + + ))} +
{!commits.length && !loading && finished && (
{headCommit ? tx("No commits match the filters") : tx("No commits yet")}
)} -
+
{items.map((v) => { const c = commits[v.index]; if (!c) return ( -
+
{" "} Loading… @@ -1072,10 +1631,10 @@ function CommitList() { return ( { + setHoverColor(edge); + if (!edge) return; + const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge)); + const target = source?.parents.find((p) => `${source.id}\0${p}` === edge); + if ( + !source || + !target || + commits.some((c) => commitKey(c) === target) || + boundaryRequests.current.has(target) + ) + return; + const original = all.find((c) => commitKey(c) === source.id); + if (!original) return; + boundaryRequests.current.add(target); + const split = target.lastIndexOf("\0"), + id = target.slice(split + 1); + void loadLogDetails({ ...original, id }) + .then((details) => + setBoundaryTitles((current) => + new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`), + ), + ) + .catch(() => undefined); + }} + onFragment={fragment} + onNavigate={(index, key) => { + const target = commits[index]; + if (target) selectCommit(commitKey(target), true); + else if (key) { + const split = key.lastIndexOf("\0"); + void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo()); + } + }} + targetTitle={(index, key) => { + const target = commits[index]; + return target + ? `${target.shortId} ${target.summary} — ${target.author}` + : (key && boundaryTitles.get(key)) || + `${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`; + }} + columns={columns} + bookmarked={!!bookmarks[commitKey(c)]} /> ); })} @@ -1092,6 +1697,106 @@ function CommitList() { ); } +function LogColumnHeader({ + column, + columns, + options, +}: { + column: LogColumn; + columns: LogColumn[]; + options: LogOptions; +}) { + const drag = useRef<{ x: number; width: number } | null>(null); + const width = options.columnWidths[column.id] ?? column.width ?? 120; + const resize = (value: number) => + updateLogOptions({ + columnWidths: { ...logOptions().columnWidths, [column.id]: Math.max(40, Math.min(800, value)) }, + }); + const move = (source: string, target: string) => { + const ids = columns.map((c) => c.id); + const from = ids.indexOf(source), + to = ids.indexOf(target); + if (from < 0 || to < 0 || from === to) return; + ids.splice(from, 1); + ids.splice(to, 0, source); + updateLogOptions({ columnOrder: ids }); + }; + const index = columns.findIndex((c) => c.id === column.id); + return ( + { + e.dataTransfer.setData("application/x-openidea-log-column", column.id); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => { + if (e.dataTransfer.types.includes("application/x-openidea-log-column")) e.preventDefault(); + }} + onDrop={(e) => { + e.preventDefault(); + move(e.dataTransfer.getData("application/x-openidea-log-column"), column.id); + }} + onContextMenu={(e) => { + e.preventDefault(); + showContextMenu(e, [ + { + label: "Move Left", + disabled: index === 0, + run: () => move(column.id, columns[index - 1]?.id ?? column.id), + }, + { + label: "Move Right", + disabled: index === columns.length - 1, + run: () => move(column.id, columns[index + 1]?.id ?? column.id), + }, + { label: "Reset Width", run: () => resize(column.width ?? 120) }, + ]); + }} + > + {tx(column.title)} + { + e.preventDefault(); + e.stopPropagation(); + drag.current = { x: e.clientX, width }; + e.currentTarget.setPointerCapture(e.pointerId); + }} + onPointerMove={(e) => { + if (drag.current && e.currentTarget.hasPointerCapture(e.pointerId)) + resize(drag.current.width + e.clientX - drag.current.x); + }} + onPointerUp={(e) => { + drag.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + }} + onPointerCancel={() => { + drag.current = null; + }} + onLostPointerCapture={() => { + drag.current = null; + }} + onKeyDown={(e) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + e.preventDefault(); + e.stopPropagation(); + if (e.altKey) move(column.id, columns[index + (e.key === "ArrowLeft" ? -1 : 1)]?.id ?? column.id); + else resize(width + (e.key === "ArrowLeft" ? -10 : 10)); + }} + /> + + ); +} + const CommitRow = memo(function CommitRow({ id, commit: c, @@ -1104,6 +1809,15 @@ const CommitRow = memo(function CommitRow({ opts, remoteNames, onPick, + hoverColor, + highlighted, + highlightedNodes, + onHover, + onFragment, + onNavigate, + targetTitle, + columns, + bookmarked, }: { id: string; commit: Commit; @@ -1116,8 +1830,35 @@ const CommitRow = memo(function CommitRow({ opts: LogOptions; remoteNames: ReadonlySet; onPick: (id: string, e: ReactMouseEvent) => void; + hoverColor: string | null; + highlighted: ReadonlySet; + highlightedNodes: ReadonlySet; + onHover: (color: string | null) => void; + onFragment: (from: string, target: string, expand: boolean) => void; + onNavigate: (index: number, key?: string) => void; + targetTitle: (index: number, key?: string) => string; + columns: LogColumn[]; + bookmarked: boolean; }) { const merge = opts.highlightMerges && c.parents.length > 1; + const subjectRef = useRef(null); + const [tooltip, setTooltip] = useState(false); + const tooltipTimer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(tooltipTimer.current), []); + useEffect(() => { + if (!tooltip) return; + const hide = () => setTooltip(false); + window.addEventListener("scroll", hide, true); + return () => window.removeEventListener("scroll", hide, true); + }, [tooltip]); + const enterTooltip = () => { + clearTimeout(tooltipTimer.current); + tooltipTimer.current = setTimeout(() => setTooltip(true), TOOLTIP_DELAY); + }; + const leaveTooltip = () => { + clearTimeout(tooltipTimer.current); + tooltipTimer.current = setTimeout(() => setTooltip(false), 120); + }; const refs = c.refs.length > 0 && ( ); - const time = opts.commitTimestamp ? (c.commitTimeMs ?? c.timeMs) : c.timeMs; return (
- {commitRepo(c) && useVcs.getState().roots.length > 1 && ( + {opts.showRepository && commitRepo(c) && useVcs.getState().roots.length > 1 && ( {repoName(commitRepo(c)!)} )} - {graph && } + {graph && ( + onFragment(source ?? commitKey(c), target, expand)} + onNode={() => onFragment(commitKey(c), commitKey(c), false)} + /> + )} + {bookmarked && } {opts.refsOnLeft && refs} { + clearTimeout(tooltipTimer.current); + setTooltip(false); + }} > - {c.summary} + {!opts.refsOnLeft && refs} + setTooltip(false)} + className={styles.commitTooltip} + > +
clearTimeout(tooltipTimer.current)} onPointerLeave={leaveTooltip}> + +
+
- {opts.showAuthor && ( - `}> - {c.author} + {columns.map((column) => ( + ` : undefined} + > + {column.render(c)} - )} - {opts.showDate && {formatTime(time)}} - {opts.showHash && {c.shortId}} + ))}
); }); @@ -1181,44 +1962,136 @@ function seg(fromX: number, fromY: number, toX: number, toY: number) { return `M${fromX} ${fromY}C${fromX} ${mid} ${toX} ${mid} ${toX} ${toY}`; } -export function GraphCell({ row, head }: { row: GraphRow; head?: boolean }) { - const width = Math.min(row.width, 24) * LANE; +export function GraphCell({ + row, + head, + activeColor, + highlighted, + highlightedNodes, + onHover, + onNavigate, + onFragment, + targetTitle, + onNode, +}: { + row: GraphRow; + head?: boolean; + activeColor?: string | null; + highlighted?: ReadonlySet; + highlightedNodes?: ReadonlySet; + onHover?: (edge: string | null) => void; + onNavigate?: (index: number, key?: string) => void; + onFragment?: (target: string, expand: boolean, source?: string) => void; + targetTitle?: (index: number, key?: string) => string; + onNode?: () => void; +}) { const cy = ROW / 2; + const draw = (s: GraphSegment, i: number, up: boolean) => { + const target = (up ? row.upTargets : row.downTargets)?.[i]; + const canFragment = + !!target && + !!onFragment && + s.kind !== "filtered" && + s.kind !== "shallow" && + (!!s.source || (!up && s.from === row.lane)); + const canNavigate = s.targetRow != null && s.kind !== "shallow" && !!onNavigate; + const actionable = canNavigate || canFragment; + const label = + s.kind === "shallow" + ? tx("Shallow history boundary") + : s.targetRow != null + ? (targetTitle?.(s.targetRow, target) ?? tx("Go to connected commit")) + : s.dashed + ? tx("Expand this fragment") + : tx("Collapse this fragment"); + const activate = () => { + if (canNavigate) onNavigate?.(s.targetRow!, target); + else if (canFragment) onFragment?.(target!, !!s.dashed, s.source); + }; + const path = + seg(x(s.from), up ? 0 : cy, x(s.to), up ? cy : ROW) + + (s.arrow ? arrowHead(x(up ? s.from : s.to), up ? 0 : ROW, up ? -1 : 1) : ""); + return ( + onHover?.(s.edge ?? `${s.source ?? ""}\0${target ?? ""}`)} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + activate(); + }} + onKeyDown={(e) => { + if (actionable && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + e.stopPropagation(); + activate(); + } + }} + > + {label} + + + + + ); + }; return ( - - {row.up.map((s, i) => ( - onHover?.(null)} + > + {row.up.map((s, i) => draw(s, i, true))} + {row.down.map((s, i) => draw(s, i, false))} + e.stopPropagation()} + onMouseEnter={() => row.id && onHover?.(`node:${row.id}`)} + onClick={(e) => { + e.stopPropagation(); + onNode?.(); + }} + onKeyDown={(e) => { + if (onNode && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + e.stopPropagation(); + onNode(); + } + }} + > + + - ))} - {row.down.map((s, i) => ( - - ))} - {head ? ( - <> - - - - ) : ( - - )} + {head && } + ); } @@ -1268,6 +2141,10 @@ export function RefLabels({ * (IDEA), their combined changes with every selected commit listed below. */ export function CommitDetails({ commits }: { commits: Commit[] }) { + const scope = useContext(LogIdContext); + const shown = useGitLog((s) => s.shownLog); + const active = !scope || scope === shown; + const readOnly = useReadOnlyLog(); const selected = useGitLog((s) => s.selected); const selection = useGitLog((s) => s.selection); // Newest first (log order). @@ -1279,40 +2156,104 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { const many = chosen.length > 1; const idsKey = chosen.map(commitKey).join(" "); const commitRoot = commit ? commitRepo(commit) : null; - const root = useVcs((s) => s.root) ?? ""; - const [files, setFiles] = useState<(ChangedFile | RevisionChange)[] | null>(null); - const [error, setError] = useState(null); + const [result, setResult] = useState<{ + key: string; + files: LogChange[] | null; + error: string | null; + } | null>(null); const [expanded, setExpanded] = useState | null>(null); + const [parentIndex, setParentIndex] = useState(-1); + const fromParents = useLogOptions((s) => s.changesFromParents); + const onlyAffected = useLogOptions((s) => s.showOnlyAffected); + const preview = useLogOptions((s) => s.diffPreview); + const diffLocation = useLogOptions((s) => s.diffLocation); + const filters = useGitLog((s) => s.filters); + const filesKey = JSON.stringify([idsKey, fromParents, parentIndex, commitRoot]); + const files = result?.key === filesKey ? result.files : null; + const error = result?.key === filesKey ? result.error : null; useEffect(() => { if (!idsKey) return; - const ids = chosen.map((c) => c.id); let alive = true; - const load = - ids.length > 1 - ? call(commands.vcsCommitsChanges(commitRoot ?? currentRepo(), ids.reverse())) - : call(commands.vcsCommitFiles(commitRoot ?? currentRepo(), ids[0]!)); - load.then((f) => alive && setFiles(f)).catch((e) => alive && setError(e instanceof Error ? e.message : String(e))); + const load = (async () => { + if (many) { + const roots = [...new Set(chosen.map(commitRepo))]; + return ( + await Promise.all( + roots.map(async (repo) => { + const group = chosen.filter((c) => commitRepo(c) === repo); + const files = await call( + commands.vcsCommitsChanges(repo ?? currentRepo(), group.map((c) => c.id).reverse()), + ); + return toLogChanges(files, null, repo, readOnly); + }), + ) + ).flat(); + } + const c = chosen[0]!; + if (c.parents.length > 1) { + const groups = await Promise.all( + c.parents.map(async (id, index) => { + const files = await call(commands.vcsDiffRevisions(commitRoot ?? currentRepo(), id, c.id)); + return toLogChanges(files, { ...c, parents: [id] }, commitRoot, readOnly).map((f) => ({ + ...f, + group: `${tx("Parent")} ${index + 1}: ${id.slice(0, 8)}`, + })); + }), + ); + if (fromParents) return parentIndex < 0 ? groups.flat() : (groups[parentIndex] ?? []); + // A merge's combined changes are files changed against every parent. + return groups[0]! + .filter((f) => groups.every((group) => group.some((x) => x.path === f.path))) + .map((f) => ({ + ...f, + group: undefined, + mergeParents: c.parents + .slice(0, 2) + .map((rev, i) => ({ rev, path: groups[i]!.find((x) => x.path === f.path)?.oldPath ?? f.path })), + })); + } + const files = await call(commands.vcsCommitFiles(commitRoot ?? currentRepo(), c.id)); + return toLogChanges(files, c, commitRoot, readOnly); + })(); + load + .then((files) => alive && setResult({ key: filesKey, files, error: null })) + .catch( + (e) => alive && setResult({ key: filesKey, files: null, error: e instanceof Error ? e.message : String(e) }), + ); return () => { alive = false; }; - }, [idsKey]); + }, [idsKey, chosen, fromParents, parentIndex, commitRoot, filesKey, many, readOnly]); const groupByDirectory = useLogOptions((s) => s.groupByDirectory); const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); const treeRef = useRef(null); const [diffToReveal, setDiffToReveal] = useState<{ key: string } | null>(null); const syncingDiff = useRef(false); - const changes = useMemo( - () => (files && commit ? toLogChanges(files, many ? null : commit, commitRoot) : []), - [files, commit, many, commitRoot], + const changes = useMemo(() => { + const changes = files ?? []; + const paths = filters.paths?.length ? filters.paths : filters.path ? [filters.path] : []; + return onlyAffected && paths.length + ? changes.filter((c) => + paths.some((p) => { + const matches = (rel: string) => { + const abs = absolutePath(rel, c.repo); + return abs === p || abs.startsWith(`${p}/`) || (!many && commit?.historyPath === rel); + }; + return matches(c.path) || (c.oldPath != null && matches(c.oldPath)); + }), + ) + : changes; + }, [files, commit, many, filters.path, filters.paths, onlyAffected]); + const multipleRoots = new Set(chosen.map(commitRepo)).size > 1; + const changeTreePath = useCallback( + (f: LogChange) => `${multipleRoots ? `${repoName(f.repo ?? "")}/` : ""}${f.group ? `${f.group}/` : ""}${f.path}`, + [multipleRoots], ); const tree = useMemo( - () => - groupByDirectory - ? buildFileTree(changes, (f) => relTo(root, f.path)) - : flatFileNodes(changes, (f) => relTo(root, f.path)), - [changes, root, groupByDirectory], + () => (groupByDirectory ? buildFileTree(changes, changeTreePath) : flatFileNodes(changes, changeTreePath)), + [changes, groupByDirectory, changeTreePath], ); const allDirs = useMemo(() => dirKeys(tree), [tree]); const ordered = useMemo(() => filesUnder(tree), [tree]); @@ -1320,11 +2261,14 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { // Show Diff Preview: a single selected file follows into the editor's preview tab. const previewTimer = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(previewTimer.current), []); + useEffect(() => { + if (!active) clearTimeout(previewTimer.current); + return () => clearTimeout(previewTimer.current); + }, [active]); const previewSelection = (sel: ReadonlySet) => { clearTimeout(previewTimer.current); if (syncingDiff.current) return; - if (!logOptions().diffPreview || sel.size !== 1) return; + if (!logOptions().diffPreview || logOptions().diffLocation !== "editor" || sel.size !== 1) return; const file = fileNodeAt(tree, [...sel][0]!)?.file; // A preview that cannot open (no editor yet, the file went away) is just skipped. if (file) @@ -1340,7 +2284,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { const change = ordered.find((c) => diffInput(c.diff).key === input.key); if (!change) return; clearTimeout(previewTimer.current); - const rel = relTo(root, change.path); + const rel = changeTreePath(change); setExpanded((current) => { const next = new Set(current ?? allDirs); for (const key of allDirs) if (rel.startsWith(key.slice(2) + "/")) next.add(key); @@ -1350,7 +2294,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { }; followDiff(editor?.activeInput?.() ?? null); return editor?.onActiveInputChange?.(followDiff); - }, [ordered, allDirs, root]); + }, [ordered, allDirs, changeTreePath]); // Tree.reveal needs the expanded rows to have rendered. Suppress the reverse // preview update so it cannot reopen the diff and lose its requested chunk. @@ -1362,9 +2306,16 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { }, [diffToReveal]); useEffect(() => { + if (!active) return; useLogChanges.setState({ commits: chosen, all: ordered, selected: chosenChanges }); - }, [chosen, ordered, chosenChanges]); - useEffect(() => () => useLogChanges.setState({ commits: [], all: [], selected: [] }), []); + }, [chosen, ordered, chosenChanges, active]); + useEffect( + () => () => { + if (!scope || useGitLog.getState().shownLog === scope) + useLogChanges.setState({ commits: [], all: [], selected: [] }); + }, + [scope], + ); if (!commit) return ( @@ -1381,6 +2332,16 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { { kind: "action", action: "vcs.log.changes.groupByDirectory" }, { kind: "separator" }, { kind: "action", action: "vcs.log.changes.diffPreview" }, + toggle("Show Only Affected Changes", logOptions(), "showOnlyAffected"), + toggle("Show Changes from Parents", logOptions(), "changesFromParents"), + { + label: "Diff Preview Location", + submenu: (["editor", "right", "bottom"] as const).map((location) => ({ + label: location === "editor" ? "Editor" : location === "right" ? "Right" : "Bottom", + checked: diffLocation === location, + run: () => updateLogOptions({ diffLocation: location }), + })), + }, ]); const onFileMenu = (node: FileTreeNode | null, sel: ReadonlySet, e: ReactMouseEvent) => { @@ -1391,106 +2352,186 @@ export function CommitDetails({ commits }: { commits: Commit[] }) { showContextMenu(e, changesMenu(target)); }; + const details = ( + + +
+
+ + c.repo)).size > 1} + /> + + {!many && fromParents && commit.parents.length > 1 && ( + + )} + + {files ? fileCount(changes.length) : ""} + {many ? " " + tx`in ${chosen.length} commits` : ""} + + + setExpanded(new Set(allDirs))} + /> + setExpanded(new Set())} + /> +
+ {error &&
{error}
} + {!files && !error && ( +
+ +
+ )} + {files && ( + > + handleRef={treeRef} + roots={tree} + getKey={(n) => n.key} + getLabel={(n) => n.name} + getChildren={(n) => (n.file ? undefined : n.children)} + isExpandable={(n) => !n.file} + expanded={expanded ?? new Set(allDirs)} + onExpandedChange={(next) => setExpanded(next)} + selected={selectedKeys} + onSelectionChange={(sel) => { + setSelectedKeys(sel); + previewSelection(sel); + }} + renderNode={(n) => } + onActivate={(n) => n.file && void showChangesDiff([n.file])} + onContextMenu={onFileMenu} + aria-label="Changed files" + className={styles.fileTree} + /> + )} +
+
+ + +
+ {many ? ( + chosen.map((c) => ) + ) : ( + + )} +
+
+
+ ); + const embedded = preview && diffLocation !== "editor"; + const selectedChange = chosenChanges.length === 1 ? chosenChanges[0] : undefined; return (
- - -
-
- - - - - {files ? fileCount(files.length) : ""} - {many ? " " + tx`in ${chosen.length} commits` : ""} - - - setExpanded(new Set(allDirs))} - /> - setExpanded(new Set())} - /> -
- {error &&
{error}
} - {!files && !error && ( -
- -
- )} - {files && ( - > - handleRef={treeRef} - roots={tree} - getKey={(n) => n.key} - getLabel={(n) => n.name} - getChildren={(n) => (n.file ? undefined : n.children)} - isExpandable={(n) => !n.file} - expanded={expanded ?? new Set(allDirs)} - onExpandedChange={(next) => setExpanded(next)} - selected={selectedKeys} - onSelectionChange={(sel) => { - setSelectedKeys(sel); - previewSelection(sel); - }} - renderNode={(n) => } - onActivate={(n) => n.file && void showChangesDiff([n.file])} - onContextMenu={onFileMenu} - aria-label="Changed files" - className={styles.fileTree} - /> - )} -
-
- - -
- {many ? ( - chosen.map((c) => ) + {embedded ? ( + + + {details} + + + + {selectedChange ? ( + selectedChange.mergeParents?.length === 2 && selectedChange.after ? ( + + ) : ( + + ) ) : ( - + + Select a file to preview its changes + )} -
-
-
+ + + ) : ( + details + )}
); } /** A commit's message and metadata; `compact` (one of several selected): without the body. */ function CommitMessage({ commit, compact = false }: { commit: Commit; compact?: boolean }) { - const [subject, ...bodyLines] = commit.message.replace(/\s+$/, "").split("\n"); + const { details, error } = useCommitDetails(commit, !compact); + const repo = commitRepo(commit) ?? currentRepo(); + const message = details?.message || commit.message; + const [subject, ...bodyLines] = message.replace(/\s+$/, "").split("\n"); const body = compact ? "" : bodyLines.join("\n").replace(/^\n+/, ""); return (
- + + {/* Only a message with a body has more to show. */} + {body && ( + + )}
{body && (
- +
)}
@@ -1505,8 +2546,57 @@ function CommitMessage({ commit, compact = false }: { commit: Commit; compact?: {commit.author} <{commit.email}> {commit.timeMs ? " " + tx`on ${new Date(commit.timeMs).toLocaleString()}` : ""}
- {commit.parents.length > 1 && ( -
{tx`Merge of ${commit.parents.map((p) => p.slice(0, 8)).join(", ")}`}
+ {!compact && + commit.committer && + (commit.committer !== commit.author || + commit.committerEmail !== commit.email || + commit.commitTimeMs !== commit.timeMs) && ( +
+ {tx("Committed by")} {commit.committer} <{commit.committerEmail}>{" "} + {commit.commitTimeMs ? new Date(commit.commitTimeMs).toLocaleString() : ""} +
+ )} + {commit.parents.length > 0 && ( +
+ {tx(commit.parents.length > 1 ? "Merge of" : "Parent")}{" "} + {commit.parents.map((id, i) => ( + + {i > 0 ? ", " : ""} + + + ))} +
+ )} + {details && ( + <> +
+ {tx("Containing branches")}:{" "} + {details.containingBranches.length + ? details.containingBranches.map((branch, i) => ( + + {i > 0 ? ", " : ""} + + + )) + : tx("None")} +
+
+ {tx("Signature")}: {signatureLabel(details.signature)} + {details.signer ? ` · ${details.signer}` : ""} +
+ + )} + {error && ( +
+ {tx("Could not load containing branches or signature details")} +
)} {commit.refs.length > 0 && (
@@ -1517,30 +2607,87 @@ function CommitMessage({ commit, compact = false }: { commit: Commit; compact?: ); } +function signatureLabel(code: string) { + return tx( + ( + { + G: "Verified", + U: "Good signature with unknown trust", + B: "Bad signature", + E: "Could not verify", + N: "Unsigned", + X: "Expired signature", + Y: "Expired signing key", + R: "Revoked signing key", + } as Record + )[code] ?? "Could not verify", + ); +} + +function CommitSignature({ commit }: { commit: Commit }) { + const { details, error } = useCommitDetails(commit); + return ( + + {details ? signatureLabel(details.signature) : error ? tx("Could not verify") : tx("Loading…")} + + ); +} + +function CommitMessageText({ text, repo }: { text: string; repo: string | null | undefined }) { + return text.split(/\b([a-fA-F0-9]{7,64})\b/g).map((part, i) => + i % 2 ? ( + + ) : ( + + ), + ); +} + /** IDEA's changes browser context menu in the Git Log; `changes` = the files it acts on. */ export function changesMenu(changes: LogChange[]): MenuItem[] { const one = changes.length === 1 ? changes[0]! : null; return [ { kind: "action", action: "vcs.log.changes.showDiff", arg: changes }, - { kind: "action", action: "vcs.log.changes.compareWithLocal", arg: changes, disabled: !one?.after }, - { kind: "action", action: "vcs.log.changes.compareBeforeWithLocal", arg: changes, disabled: !one?.before }, + { kind: "action", action: "vcs.log.changes.compareWithLocal", arg: changes, disabled: !one?.after || one.readOnly }, + { + kind: "action", + action: "vcs.log.changes.compareBeforeWithLocal", + arg: changes, + disabled: !one?.before || one.readOnly, + }, { kind: "separator" }, { kind: "action", action: "vcs.log.changes.jumpToSource", arg: changes, - disabled: !changes.some((c) => c.after), + disabled: !changes.some((c) => c.after && !c.readOnly), }, { kind: "separator" }, - { kind: "action", action: "vcs.log.changes.revert", arg: changes }, - { kind: "action", action: "vcs.log.changes.getFromRevision", arg: changes }, - { kind: "separator" }, { kind: "action", - action: "vcs.showHistory", - arg: one ? absolutePath(one.path, one.repo) : undefined, - disabled: !one?.after, + action: "vcs.log.changes.revert", + arg: changes, + disabled: changes.some((c) => c.readOnly) || new Set(changes.map((c) => c.repo)).size > 1, }, + { + kind: "action", + action: "vcs.log.changes.getFromRevision", + arg: changes, + disabled: changes.some((c) => c.readOnly), + }, + { kind: "separator" }, + ...(one?.readOnly + ? [{ label: "Show History for File", run: () => openLogTab("history", absolutePath(one.path, one.repo)) }] + : [ + { + kind: "action", + action: "vcs.showHistory", + arg: one ? absolutePath(one.path, one.repo) : undefined, + disabled: !one?.after, + } satisfies MenuItem, + ]), ]; } diff --git a/src/features/vcs/GitMergeDiff.tsx b/src/features/vcs/GitMergeDiff.tsx new file mode 100644 index 00000000..abf66526 --- /dev/null +++ b/src/features/vcs/GitMergeDiff.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from "react"; +import { EditorState, Text } from "@codemirror/state"; +import { Decoration, EditorView } from "@codemirror/view"; +import { Chunk } from "@codemirror/merge"; +import { call, commands } from "@/platform/ipc"; +import type { EditorInput } from "@/workbench/contributions"; +import { languageFor, viewerBase } from "./cm"; +import { repositoryPath } from "./repositoryPaths"; +import styles from "./GitLog.module.css"; +import { syncMergeScroll } from "./mergeScroll"; + +/** Read-only parent / merge result / parent comparison, independent of the worktree. */ +export function GitMergeDiff({ input }: { input: EditorInput }) { + const data = input.data as { + repo: string; + revision: string; + path: string; + parents: Array<{ rev: string; path: string }>; + }; + const host = useRef(null); + const [error, setError] = useState(null); + useEffect(() => { + let alive = true; + const views: EditorView[] = []; + let stopScroll: (() => void) | undefined; + const sides = [data.parents[0]!, { rev: data.revision, path: data.path }, data.parents[1]!]; + void Promise.all([ + Promise.all(sides.map((s) => call(commands.vcsFileAt(repositoryPath(data.repo, s.path), s.rev)))), + languageFor(data.path), + ]) + .then(([texts, language]) => { + if (!alive || !host.current) return; + const docs = texts.map((text) => Text.of((text ?? "").split("\n"))); + const marks = docs.map(() => new Map()); + for (const parent of [0, 2]) + for (const chunk of Chunk.build(docs[parent]!, docs[1]!)) { + for (const [pane, from, to] of [ + [parent, chunk.fromA, chunk.toA], + [1, chunk.fromB, chunk.toB], + ]) { + const doc = docs[pane!]!; + for (let line = doc.lineAt(Math.min(from!, doc.length)); line.from < to!;) { + marks[pane!]!.set( + line.from, + chunk.fromA === chunk.toA ? "inserted" : chunk.fromB === chunk.toB ? "deleted" : "modified", + ); + if (line.number === doc.lines) break; + line = doc.line(line.number + 1); + } + } + } + for (const [i, text] of texts.entries()) { + const pane = document.createElement("div"); + pane.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column"; + const label = document.createElement("div"); + label.textContent = `${sides[i]!.rev.slice(0, 8)} · ${sides[i]!.path}`; + pane.append(label); + host.current.append(pane); + const view = new EditorView({ + parent: pane, + state: EditorState.create({ + doc: text ?? "", + extensions: [ + viewerBase({ readOnly: true }), + ...(language ? [language] : []), + EditorView.decorations.of( + Decoration.set( + [...marks[i]!] + .sort((a, b) => a[0] - b[0]) + .map(([from, kind]) => Decoration.line({ class: `cm-diffLine-${kind}` }).range(from)), + ), + ), + ], + }), + }); + views.push(view); + } + stopScroll = syncMergeScroll({ ours: views[0]!, result: views[1]!, theirs: views[2]! }); + }) + .catch((e) => alive && setError(String(e))); + const element = host.current; + return () => { + alive = false; + stopScroll?.(); + views.forEach((v) => v.destroy()); + element?.replaceChildren(); + }; + }, [data]); + return ( + <> +
{error}
+
+ + ); +} diff --git a/src/features/vcs/LogScope.tsx b/src/features/vcs/LogScope.tsx new file mode 100644 index 00000000..9d41c9e1 --- /dev/null +++ b/src/features/vcs/LogScope.tsx @@ -0,0 +1,37 @@ +import { useEffect, type ReactNode } from "react"; +import type { EditorInput } from "@/workbench/contributions"; +import { LogIdContext, LogOptionsContext } from "./logContext"; +import { rootLogOptions } from "./logOptions"; +import { ensureEditorLog, focusLogView, parkedLogOptions, useRootGitLog } from "./logStore"; +import { LogView } from "./GitLog"; +import styles from "./GitLog.module.css"; + +export function LogScope({ id, children }: { id: string; children: ReactNode }) { + const active = useRootGitLog((s) => s.shownLog); + const options = rootLogOptions(); + const focus = () => { + if (useRootGitLog.getState().shownLog !== id) focusLogView(id); + }; + return ( + + +
+ {children} +
+
+
+ ); +} + +export function GitLogEditor({ input }: { input: EditorInput }) { + const data = input.data as { id: string; label: string }; + useEffect(() => { + ensureEditorLog(data.id, data.label); + focusLogView(data.id); + }, [data.id, data.label]); + return ( + + + + ); +} diff --git a/src/features/vcs/PathFilterDialog.tsx b/src/features/vcs/PathFilterDialog.tsx new file mode 100644 index 00000000..5b498519 --- /dev/null +++ b/src/features/vcs/PathFilterDialog.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { Checkbox, Dialog, Input, Tree } from "@/ui"; +import { projectFs } from "@/features/project-tree/fs"; +import { openDialog } from "./dialogs"; +import { repoName } from "./store"; +import { call, commands } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; + +interface PathNode { + path: string; + name: string; + directory: boolean; +} + +export function chooseLogPaths(roots: string[], initial: string[], readOnly = false): Promise { + return new Promise((resolve) => { + const close = openDialog(() => ( + { + close(); + resolve(value); + }} + /> + )); + }); +} + +function PathFilterDialog({ + roots, + initial, + readOnly, + onDone, +}: { + roots: string[]; + initial: string[]; + readOnly: boolean; + onDone: (value: string[] | null) => void; +}) { + const [checked, setChecked] = useState>(() => new Set(initial)); + const [listings, setListings] = useState>(() => new Map()); + const [custom, setCustom] = useState(""); + const [error, setError] = useState(null); + const toggle = (path: string, on: boolean) => + setChecked((before) => { + const next = new Set(before); + if (on) next.add(path); + else next.delete(path); + return next; + }); + const apply = () => { + const added = custom + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + if ( + added.some( + (path) => + !roots.some((root) => path === root || path.startsWith(`${root}/`)) || path.split(/[\\/]/).includes(".."), + ) + ) { + setError(tx("Choose an absolute path inside a selected repository.")); + return; + } + onDone([...new Set([...checked, ...added])]); + }; + const nodes = roots.map((path) => ({ path, name: repoName(path), directory: true })); + const load = async (node: PathNode) => { + try { + const root = roots.find((r) => node.path === r || node.path.startsWith(`${r}/`)); + if (!root) return; + let entries: PathNode[]; + if (readOnly) { + const files = await call(commands.vcsRevisionFiles(root, "HEAD")); + const prefix = node.path === root ? "" : `${node.path.slice(root.length + 1)}/`; + const children = new Map(); + for (const file of files) + if (file.startsWith(prefix)) { + const [name, ...rest] = file.slice(prefix.length).split("/"); + if (name) children.set(name, { name, path: `${node.path}/${name}`, directory: rest.length > 0 }); + } + entries = [...children.values()].sort( + (a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name), + ); + } else + entries = (await projectFs(root).readDir(node.path)) + .filter((entry) => entry.name !== ".git") + .map((entry) => ({ name: entry.name, path: entry.path, directory: entry.kind === "dir" })); + setListings((before) => new Map(before).set(node.path, entries)); + setError(null); + } catch (e) { + setError(String(e)); + } + }; + return ( + onDone(null)} + buttons={[ + { label: "OK", role: "default", onClick: apply }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+ + roots={nodes} + getKey={(n) => n.path} + getLabel={(n) => n.name} + getChildren={(n) => (n.directory ? listings.get(n.path) : undefined)} + isExpandable={(n) => n.directory} + loadChildren={load} + renderNode={(n) => ( + toggle(n.path, on)} /> + )} + aria-label="Files and folders" + /> +
+ {checked.size > 0 && ( +
+ {[...checked].map((path) => ( + toggle(path, on)} /> + ))} +
+ )} + setCustom(e.target.value)} + placeholder="Additional absolute path" + aria-label="Additional absolute path" + /> + {error &&
{error}
} +
+ ); +} diff --git a/src/features/vcs/RevisionBrowser.tsx b/src/features/vcs/RevisionBrowser.tsx new file mode 100644 index 00000000..a5d3dbb8 --- /dev/null +++ b/src/features/vcs/RevisionBrowser.tsx @@ -0,0 +1,245 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { Commit } from "@/bindings"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; +import { + Dialog, + EmptyState, + IconButton, + SearchField, + Spinner, + SplitGroup, + SplitHandle, + SplitPanel, + Tree, + T, + copyText, + showContextMenu, +} from "@/ui"; +import { openDialog } from "./dialogs"; + +import { commitRepo, type RepoCommit } from "./multiRoot"; +import { currentRepoRoot, useVcs } from "./store"; +import { repositoryPath } from "./repositoryPaths"; +import { languageFor, viewerBase } from "./cm"; +import styles from "./GitLog.module.css"; +import { choosePath } from "@/features/settings/languages/api"; +import { openLogTab } from "./logStore"; + +interface RevisionData { + repo: string; + revision: string; + path: string; + external?: boolean; +} + +export async function openExternalLog() { + const picked = isTauri() + ? await ( + await import("@tauri-apps/plugin-dialog") + ).open({ title: tx("Open Git Log"), directory: true, multiple: true }) + : await choosePath({ title: "Open Git Log", directory: true }); + const paths = Array.isArray(picked) ? picked : picked ? [picked] : []; + if (!paths.length) return; + try { + const roots = await Promise.all(paths.map((path) => call(commands.vcsOpenExternalLog(path)))); + openLogTab("external", roots.join(", "), "editor", roots); + } catch (error) { + notifications().notify({ group: "vcs", level: "error", title: "Could not open Git log", body: String(error) }); + } +} + +export function browseRepositoryAt(commit: Commit) { + const repo = commitRepo(commit) ?? currentRepoRoot(); + if (!repo) return; + openDialog((close) => ( + + )); +} + +function RevisionBrowser({ + repo, + revision, + external, + onClose, +}: { + repo: string; + revision: string; + external?: boolean; + onClose: () => void; +}) { + const [directories, setDirectories] = useState>({}); + const files = directories[""] ?? null; + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + const [searchFiles, setSearchFiles] = useState(null); + const [selected, setSelected] = useState(null); + useEffect(() => { + let alive = true; + void call(commands.vcsRevisionChildren(repo, revision, "")) + .then((files) => { + if (alive) setDirectories({ "": files }); + }) + .catch((error) => { + if (alive) setError(String(error)); + }); + return () => { + alive = false; + }; + }, [repo, revision]); + useEffect(() => { + if (!query || searchFiles) return; + let alive = true; + const timer = setTimeout( + () => + void call(commands.vcsRevisionFiles(repo, revision)) + .then((files) => { + if (alive) setSearchFiles(files); + }) + .catch((error) => { + if (alive) setError(String(error)); + }), + 250, + ); + return () => { + alive = false; + clearTimeout(timer); + }; + }, [query, searchFiles, repo, revision]); + const tree = useMemo( + () => + (query ? (searchFiles ?? []) : (files ?? [])).filter( + (path) => !query || path.toLocaleLowerCase().includes(query.toLocaleLowerCase()), + ), + [files, query, searchFiles], + ); + const load = async (path: string) => { + const children = await call(commands.vcsRevisionChildren(repo, revision, path.replace(/\/$/, ""))); + setDirectories((current) => ({ ...current, [path]: children })); + }; + const open = (path: string) => { + if (useVcs.getState().root) { + const bytes = new TextEncoder().encode(JSON.stringify({ repo, revision, path })); + const encoded = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + void editorService().openFile(`git-revision:${encoded}/${encodeURIComponent(path.split("/").at(-1)!)}`, { + focus: true, + }); + } else + void editorService().openInput({ + kind: "git-revision", + key: `git-revision:${repo}\0${revision}\0${path}`, + data: { repo, revision, path, external } satisfies RevisionData, + }); + }; + return ( + +
+ + {error ? ( +
{error}
+ ) : !files ? ( + + ) : ( + + + n} + getLabel={(n) => n.replace(/\/$/, "").split("/").at(-1)!} + getChildren={(n) => directories[n]} + isExpandable={(n) => n.endsWith("/")} + loadChildren={load} + onSelectionChange={(_, n) => { + if (n && !n.endsWith("/")) setSelected(n); + }} + onActivate={(n) => { + if (!n.endsWith("/")) open(n); + }} + onContextMenu={(node, _selection, e) => { + if (!node) return; + e.preventDefault(); + showContextMenu(e, [ + { label: "Open Revision in Editor", disabled: node.endsWith("/"), run: () => open(node) }, + { label: "Copy Path", run: () => copyText(node) }, + { + label: "Show History", + run: () => openLogTab("history", repositoryPath(repo, node.replace(/\/$/, "")), "editor"), + }, + ]); + }} + aria-label="Repository files" + /> + + + + {selected ? ( +
+
+ {selected} + open(selected)} /> +
+ +
+ ) : ( + + Select a file to view its repository version + + )} +
+
+ )} +
+
+ ); +} + +export function GitRevisionEditor({ input }: { input: EditorInput }) { + return ; +} + +function RevisionSource({ repo, revision, path, external }: RevisionData) { + const host = useRef(null); + const key = `${repo}\0${revision}\0${path}`; + const [error, setError] = useState<{ key: string; message: string } | null>(null); + useEffect(() => { + let alive = true; + let view: EditorView | null = null; + const absolute = repositoryPath(repo, path); + const content = async () => { + if (external) await call(commands.vcsOpenExternalLog(repo)); + return call(commands.vcsFileAt(absolute, revision)); + }; + void Promise.all([content(), languageFor(path)]) + .then(([text, language]) => { + if (!alive || !host.current) return; + view = new EditorView({ + parent: host.current, + state: EditorState.create({ + doc: text ?? "", + extensions: [viewerBase({ readOnly: true }), ...(language ? [language] : [])], + }), + }); + }) + .catch((error) => { + if (alive) setError({ key, message: String(error) }); + }); + return () => { + alive = false; + view?.destroy(); + }; + }, [repo, revision, path, external, key]); + return ( +
+ {error?.key === key &&
{error.message}
} +
+
+ ); +} diff --git a/src/features/vcs/__tests__/GitMergeDiff.test.tsx b/src/features/vcs/__tests__/GitMergeDiff.test.tsx new file mode 100644 index 00000000..3f980f31 --- /dev/null +++ b/src/features/vcs/__tests__/GitMergeDiff.test.tsx @@ -0,0 +1,41 @@ +import { render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + commands: { + ...real.commands, + vcsFileAt: async (_path: string, rev: string) => ({ + status: "ok", + data: `const value = ${rev === "merge" ? 3 : rev === "left" ? 1 : 2};\n`, + }), + }, + }; +}); +import { GitMergeDiff } from "../GitMergeDiff"; +describe("historical merge comparison", () => { + it("shows and highlights both parents and the immutable result without an editable worktree pane", async () => { + const { container, unmount } = render( + , + ); + await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(3)); + expect(container.querySelectorAll('.cm-content[contenteditable="false"]')).toHaveLength(3); + expect(container.querySelectorAll(".cm-diffLine-modified").length).toBeGreaterThanOrEqual(3); + unmount(); + }); +}); diff --git a/src/features/vcs/__tests__/logDetails.test.tsx b/src/features/vcs/__tests__/logDetails.test.tsx index 1ca27ea1..ca3c4158 100644 --- a/src/features/vcs/__tests__/logDetails.test.tsx +++ b/src/features/vcs/__tests__/logDetails.test.tsx @@ -1,83 +1,94 @@ -import { commitKey } from "../multiRoot"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Commit } from "@/bindings"; - -const vcsCommitFiles = vi.fn(); -const vcsCommitsChanges = vi.fn(); - -vi.mock("@/platform/ipc", async (importOriginal) => { - const real = await importOriginal(); +const api = vi.hoisted(() => ({ batch: vi.fn(), cancel: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); return { ...real, commands: { ...real.commands, - vcsCommitFiles: (repo: unknown, id: string) => vcsCommitFiles(repo, id), - vcsCommitsChanges: (repo: unknown, ids: string[]) => vcsCommitsChanges(repo, ids), + vcsLogDetailsBatch: (...args: unknown[]) => api.batch(...args), + cancelRequest: (...args: unknown[]) => api.cancel(...args), }, }; }); - -import { CommitDetails } from "../GitLog"; +import { loadLogDetails } from "../logDetails"; +import { useVcs } from "../store"; import { useGitLog } from "../logStore"; - -const commit = (summary: string, d: string): Commit => ({ - id: d.repeat(40), - shortId: d.repeat(8), +const commit = (id: string): Commit => ({ + id, + shortId: id, parents: [], - author: "A", - email: "a@example.com", - timeMs: 0, - summary, - message: `${summary}\n\nBody of ${summary}`, refs: [], + author: "A", + email: "a@x", + summary: id, + message: id, + timeMs: 0, }); - -// Log order: newest first. -const log = [commit("Third", "3"), commit("Second", "2"), commit("First", "1")]; - -afterEach(() => { - useGitLog.setState({ commits: [], selection: [], selected: null }); - vcsCommitFiles.mockReset(); - vcsCommitsChanges.mockReset(); +beforeEach(() => { + api.batch.mockReset().mockImplementation(async (_repo: string, ids: string[]) => ({ + status: "ok", + data: ids.map((id) => ({ + message: id, + containingBranches: [], + signature: "N", + signer: "", + key: "", + fingerprint: "", + })), + })); + api.cancel.mockReset().mockResolvedValue(undefined); + useVcs.setState((s) => ({ repo: "/repo", root: "/repo", version: s.version + 1 })); }); - -describe("Git Log commit details", () => { - it("shows one commit's files and full message", async () => { - vcsCommitFiles.mockResolvedValue({ status: "ok", data: [{ path: "a.ts", kind: "modified", oldPath: null }] }); - useGitLog.setState({ commits: log, selected: log[1]!.id, selection: [log[1]!.id] }); - render(); - expect(await screen.findByText("1 file")).toBeInTheDocument(); - expect(vcsCommitFiles).toHaveBeenCalledWith(null, log[1]!.id); - expect(screen.getByText("Body of Second")).toBeInTheDocument(); +describe("commit detail jobs", () => { + it("retries immediately after the last consumer cancels without losing the replacement cache entry", async () => { + let rejectOld!: (error: Error) => void; + api.batch.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectOld = reject; + }), + ); + const controller = new AbortController(); + const old = loadLogDetails(commit("retry-cancelled"), controller.signal).catch((error: unknown) => error); + await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(1)); + controller.abort(); + const replacement = loadLogDetails(commit("retry-cancelled")); + await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(2)); + await expect(replacement).resolves.toMatchObject({ message: "retry-cancelled" }); + rejectOld(new Error("Cancelled")); + await old; + await loadLogDetails(commit("retry-cancelled")); + expect(api.batch).toHaveBeenCalledTimes(2); }); - - it("combines the changes of several selected commits, oldest first, and lists them", async () => { - vcsCommitsChanges.mockResolvedValue({ - status: "ok", - data: [ - { path: "a.ts", kind: "modified", oldPath: null, before: "0".repeat(40), after: log[0]!.id }, - { path: "b.ts", kind: "added", oldPath: null, before: null, after: log[2]!.id }, - ], - }); - // Selected in click order (Cmd-click), not log order; non-contiguous. - useGitLog.setState({ commits: log, selected: log[0]!.id, selection: [log[2]!.id, log[0]!.id] }); - render(); - expect(await screen.findByText(/2 files\s*in 2 commits/)).toBeInTheDocument(); - expect(vcsCommitsChanges).toHaveBeenCalledWith(null, [log[2]!.id, log[0]!.id]); - expect(vcsCommitFiles).not.toHaveBeenCalled(); - expect(screen.getByText("First")).toBeInTheDocument(); - expect(screen.getByText("Third")).toBeInTheDocument(); - expect(screen.queryByText("Second")).toBeNull(); + it("batches rows and reuses results across log generations", async () => { + const [a, b] = await Promise.all([loadLogDetails(commit("batch-a")), loadLogDetails(commit("batch-b"))]); + expect([a.message, b.message]).toEqual(["batch-a", "batch-b"]); + expect(api.batch).toHaveBeenCalledTimes(1); + expect(api.batch.mock.calls[0]![1]).toEqual(["batch-a", "batch-b"]); + useGitLog.setState((s) => ({ generation: s.generation + 1 })); + await loadLogDetails(commit("batch-a")); + expect(api.batch).toHaveBeenCalledTimes(1); }); - it("loads the selected root even when two repositories contain the same SHA", async () => { - const front = { ...commit("Front", "1"), repo: "/front" }; - const back = { ...commit("Back", "1"), repo: "/back" }; - vcsCommitFiles.mockResolvedValue({ status: "ok", data: [{ path: "same.ts", kind: "modified", oldPath: null }] }); - useGitLog.setState({ commits: [front, back], selected: commitKey(back), selection: [commitKey(back)] }); - render(); - expect(await screen.findByText("1 file")).toBeInTheDocument(); - expect(vcsCommitFiles).toHaveBeenCalledWith("/back", back.id); - expect(screen.getByText("Body of Back")).toBeInTheDocument(); + it("cancels running native work only when its last consumer leaves", async () => { + let finish!: (value: unknown) => void; + api.batch.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const a = new AbortController(), + b = new AbortController(); + const first = loadLogDetails(commit("shared"), a.signal); + const second = loadLogDetails(commit("shared"), b.signal); + await vi.waitFor(() => expect(api.batch).toHaveBeenCalledTimes(1)); + a.abort(); + expect(api.cancel).not.toHaveBeenCalled(); + b.abort(); + expect(api.cancel).toHaveBeenCalledWith(api.batch.mock.calls[0]![2]); + finish({ status: "ok", data: [{ message: "shared" }] }); + await Promise.all([first, second]); }); }); diff --git a/src/features/vcs/__tests__/logGraphCorrectness.test.tsx b/src/features/vcs/__tests__/logGraphCorrectness.test.tsx new file mode 100644 index 00000000..0c63ca81 --- /dev/null +++ b/src/features/vcs/__tests__/logGraphCorrectness.test.tsx @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { Commit } from "@/bindings"; +import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "../graph"; +import { commitKey, graphCommit } from "../multiRoot"; +import { GraphCell } from "../GitLog"; +import { toLogChanges } from "../logChanges"; + +const commit = (id: string, parents: string[] = [], refs: string[] = []): Commit => ({ + id, + shortId: id, + parents, + refs, + author: "Author", + email: "author@example.com", + summary: id, + message: id, + timeMs: 0, +}); + +describe("graph projections", () => { + it("draws a filtered edge without changing the revision used for a file diff", () => { + const c = { ...commit("tip", ["hidden"]), repo: "/repo", graphParents: ["base"], graphDashed: [true] }; + const graph = graphCommit(c); + expect(graph.parents).toEqual([commitKey({ id: "base", repo: "/repo" })]); + expect(computeGraph([graph]).rows[0]!.down[0]!.dashed).toBe(true); + const [file] = toLogChanges([{ path: "a.ts", kind: "modified", oldPath: null }], c); + expect(file!.before).toBe("hidden"); + expect(c.parents).toEqual(["hidden"]); + }); + + it("preserves filter dashes even when collapse hides no additional commits", () => { + const c = { ...commit("tip", ["base"]), dashed: [true] }; + const collapsed = collapseLinear([c, commit("base")], new Set(["tip"])); + expect(collapsed.graph[0]!.dashed).toEqual([true]); + }); + + it("keeps selected commits and collapses only the requested fragment", () => { + const chain = [ + commit("tip", ["selected"]), + commit("selected", ["middle"]), + commit("middle", ["base"]), + commit("base"), + ]; + const result = collapseLinear(chain, new Set(["selected"]), new Set(["selected", "middle"])); + expect(result.commits.map((c) => c.id)).toEqual(["tip", "selected", "base"]); + expect(result.graph[1]).toMatchObject({ parents: ["base"], dashed: [true] }); + }); + + it("retains every node and lane beyond twenty-four simultaneous branches", () => { + const commits = Array.from({ length: 36 }, (_, i) => commit(`tip${i}`, [`base${i}`])); + const first = computeGraph(commits); + const next = computeGraph( + Array.from({ length: 36 }, (_, i) => commit(`base${i}`)), + first.state, + ); + expect(first.rows.at(-1)!.width).toBe(36); + expect(next.rows.map((r) => r.lane)).toEqual(Array.from({ length: 36 }, (_, i) => i)); + expect(next.state.lanes).toEqual([]); + const { container } = render(); + expect(Number(container.querySelector("svg")!.getAttribute("width"))).toBeGreaterThan(24 * 10); + }); + + it("makes long-edge arrows navigate to the correct endpoints by keyboard", () => { + const chain = Array.from({ length: 40 }, (_, i) => commit(`x${i}`, i < 39 ? [`x${i + 1}`] : [])); + const rows = hideLongEdges(computeGraph([commit("tip", ["base"]), ...chain, commit("base")]).rows); + const down = rows.find((r) => r.down.some((s) => s.arrow === "down"))!; + const up = rows.find((r) => r.up.some((s) => s.arrow === "up"))!; + const navigate = vi.fn(); + const { rerender } = render( `Go to ${i}`} />); + fireEvent.keyDown(screen.getByRole("button", { name: "Go to 41" }), { key: "Enter" }); + expect(navigate).toHaveBeenLastCalledWith(41, "base"); + rerender( `Go to ${i}`} />); + fireEvent.keyDown(screen.getByRole("button", { name: "Go to 0" }), { key: " " }); + expect(navigate).toHaveBeenLastCalledWith(0, "base"); + }); + + it("keeps a referenced branch's colour when an unrelated head is inserted ahead of it", () => { + const main = commit("main-tip", ["base"], ["HEAD", "main"]); + const old = computeGraph([main, commit("base")]).rows[0]!; + const next = computeGraph([commit("tag-tip", [], ["tag: release"]), main, commit("base")]).rows[1]!; + expect(next.color).toBe(old.color); + expect(next.lane).toBe(old.lane); + }); + + it("targets the exact incoming fragment instead of the row or its reused colour", () => { + const graph = computeGraph([ + { ...commit("tip", ["base"]), dashed: [true], edgeTypes: ["collapsed" as const] }, + commit("other"), + commit("base"), + ]); + const fragment = vi.fn(); + render(); + fireEvent.click(screen.getAllByRole("button", { name: "Expand this fragment" })[0]!); + expect(fragment).toHaveBeenCalledWith("base", true, "tip"); + }); +}); + +describe("linear merge presentation", () => { + const commits = [ + commit("merge", ["main", "feature"]), + commit("feature", ["feature-base"]), + commit("feature-base", ["base"]), + commit("main", ["base"]), + commit("base"), + ]; + + it("serializes a contained incoming block and expands back to the actual graph", () => { + const original = structuredClone(commits); + const linear = linearizeMerges(commits); + expect(linear.graph.map((c) => c.id)).toEqual(commits.map((c) => c.id)); + expect(linear.graph[0]!.parents).toEqual(["feature"]); + expect(linear.graph[2]).toMatchObject({ parents: ["main"], dashed: [true] }); + expect(linear.fragments.get("feature-base\0main")).toBe("merge"); + expect(linearizeMerges(commits, new Set(["merge"])).graph.map((c) => c.parents)).toEqual( + commits.map((c) => c.parents), + ); + expect(commits).toEqual(original); + }); + + it("linearizes shared fragments while preserving their other incoming edges", () => { + const shared = [commit("other", ["feature"]), ...commits]; + expect(linearizeMerges(shared).graph[1]!.parents).toEqual(["feature"]); + expect(linearizeMerges(shared).graph[0]!.parents).toEqual(["feature"]); + expect(linearizeMerges(commits.slice(0, 3)).graph[0]!.parents).toEqual(["main", "feature"]); + }); + + it("expands nested merge fragments together with their containing merge", () => { + const nested = [ + commit("outer", ["main", "nested"]), + commit("nested", ["feature", "topic"]), + commit("topic", ["base"]), + commit("feature", ["base"]), + commit("main", ["base"]), + commit("base"), + ]; + const collapsed = linearizeMerges(nested); + expect(collapsed.graph[0]!.parents).toEqual(["nested"]); + expect(collapsed.graph[1]!.parents).toEqual(["topic"]); + expect(linearizeMerges(nested, new Set(["outer"])).graph.map((c) => c.parents)).toEqual( + nested.map((c) => c.parents), + ); + }); +}); diff --git a/src/features/vcs/__tests__/logLoading.test.ts b/src/features/vcs/__tests__/logLoading.test.ts new file mode 100644 index 00000000..a331dbb6 --- /dev/null +++ b/src/features/vcs/__tests__/logLoading.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Commit, LogQuery } from "@/bindings"; + +const api = vi.hoisted(() => ({ log: vi.fn(), refs: vi.fn(), cancel: vi.fn(), choose: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + commands: { + ...real.commands, + vcsLog: (repo: string | null, query: LogQuery) => api.log(repo, query), + vcsLogRefs: () => api.refs(), + cancelRequest: (id: number) => api.cancel(id), + vcsBranches: () => Promise.resolve({ status: "ok", data: [] }), + vcsBranchInfo: () => Promise.resolve({ status: "ok", data: { upstream: null } }), + vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }), + vcsOpenExternalLog: (repo: string) => Promise.resolve({ status: "ok", data: repo }), + }, + }; +}); +vi.mock("../CommitChooser", () => ({ chooseLogCommit: (commits: Commit[]) => api.choose(commits) })); + +import { commitKey } from "../multiRoot"; +import { useVcs } from "../store"; +import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions"; +import { + displayedLogGraph, + emptyFilters, + extendCommitSelection, + goToChild, + goToParent, + goToRef, + logState, + openLogTab, + selectGitTab, + PAGE, + reloadLog, + setLogBranches, + toggleCommitSelection, + updateLogOptions, + useGitLog, +} from "../logStore"; + +const c = (id: string, parents: string[] = [], refs: string[] = []): Commit => ({ + id, + shortId: id, + parents, + refs, + author: "A", + email: "a@example.com", + timeMs: 0, + summary: id, + message: id, +}); +const ok = (data: T) => ({ status: "ok" as const, data }); + +beforeEach(() => { + api.log.mockReset().mockResolvedValue(ok([])); + api.refs.mockReset().mockResolvedValue(ok({})); + api.cancel.mockReset().mockResolvedValue(undefined); + api.choose.mockReset().mockImplementation(async (commits: Commit[]) => commits[1]); + useVcs.setState({ root: "/repo", repo: "/repo", roots: ["/repo"], branches: [] }); + useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS }); + useGitLog.setState({ + shownLog: "log", + tab: "log", + logTabs: [], + filters: emptyFilters(), + commits: [], + rows: [], + selection: [], + selected: null, + done: false, + fragments: { expanded: new Set(), collapsed: new Set(), merges: new Set() }, + generation: useGitLog.getState().generation + 1, + }); +}); + +afterEach(async () => { + api.log.mockResolvedValue(ok([])); + await reloadLog(); +}); + +describe("incremental log refresh", () => { + it("lets an inactive visible scope finish its own request without cancelling the active scope", async () => { + let finish!: (value: ReturnType>) => void; + api.log.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const main = reloadLog(); + await vi.waitFor(() => expect(finish).toBeDefined()); + const request = api.log.mock.lastCall![1].request; + const other = openLogTab("log", "Other"); + api.log.mockResolvedValue(ok([c("other")])); + await reloadLog(); + finish(ok([c("main")])); + await main; + expect(api.cancel).not.toHaveBeenCalledWith(request); + expect(logState("log").commits[0]!.id).toBe("main"); + expect(logState(other).commits[0]!.id).toBe("other"); + selectGitTab("log"); + expect(useGitLog.getState().commits[0]!.id).toBe("main"); + }); + it("joins a proven linear prefix and refreshes decorations without dropping loaded history", async () => { + useGitLog.setState({ filters: { ...emptyFilters(), branches: ["HEAD"] } }); + api.log.mockResolvedValue(ok([c("old", ["base"], ["HEAD", "main"]), c("base")])); + await reloadLog(); + api.log.mockResolvedValue(ok([c("new", ["old"], ["HEAD", "main"]), c("old", ["base"])])); + api.refs.mockResolvedValue(ok({ HEAD: "new", main: "new", "tag: v1": "base" })); + await reloadLog({ preserve: true }); + expect(api.log.mock.lastCall?.[1]).toMatchObject({ stopAt: "old", skip: 0 }); + expect(useGitLog.getState().commits.map((c) => [c.id, c.refs])).toEqual([ + ["new", ["HEAD", "main"]], + ["old", []], + ["base", ["tag: v1"]], + ]); + expect(useGitLog.getState().done).toBe(true); + }); + + it("fetches a full replacement when the prefix introduces a merge", async () => { + useGitLog.setState({ filters: { ...emptyFilters(), branches: ["HEAD"] } }); + api.log.mockResolvedValue(ok([c("old"), c("stale")])); + await reloadLog(); + const replacement = [c("merge", ["old", "side"]), c("side", ["old"]), c("old")]; + api.log.mockResolvedValue(ok(replacement)); + await reloadLog({ preserve: true }); + expect(api.log.mock.calls.slice(-2).map((call) => call[1].stopAt)).toEqual(["old", null]); + expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["merge", "side", "old"]); + }); + + it("intersects repository and path filters and clears rows for an empty intersection", async () => { + useVcs.setState({ roots: ["/repo/front", "/repo/back"] }); + useGitLog.setState({ + filters: { ...emptyFilters(), paths: ["/repo/front/a", "/repo/back/b"], repositories: ["/repo/back"] }, + }); + api.log.mockResolvedValue(ok([c("back")])); + await reloadLog(); + expect(api.log.mock.lastCall).toEqual([ + "/repo/back", + expect.objectContaining({ paths: ["/repo/back/b"], authorsExact: true }), + ]); + api.log.mockClear(); + useGitLog.setState({ filters: { ...emptyFilters(), path: "/repo/front/a", repositories: ["/repo/back"] } }); + await reloadLog({ preserve: true }); + expect(api.log).not.toHaveBeenCalled(); + expect(useGitLog.getState().commits).toEqual([]); + expect(useGitLog.getState().error).toBeNull(); + }); + + it("keeps multi-path filters scoped to their owning external repositories", async () => { + useVcs.setState({ root: null, repo: null, roots: [] }); + openLogTab("external", "/a", "toolWindow", ["/a", "/b", "/c"]); + useGitLog.setState({ filters: { ...emptyFilters(), paths: ["/a/src", "/b/test"] } }); + await reloadLog(); + expect(api.log.mock.calls).toEqual([ + ["/a", expect.objectContaining({ paths: ["/a/src"] })], + ["/b", expect.objectContaining({ paths: ["/b/test"] })], + ]); + api.log.mockClear(); + useGitLog.setState({ filters: { ...emptyFilters(), path: "/outside/file" } }); + await reloadLog(); + expect(api.log).not.toHaveBeenCalled(); + expect(useGitLog.getState().commits).toEqual([]); + }); + + it("cancels a superseded page and ignores its later response", async () => { + let finish!: (value: ReturnType>) => void; + api.log.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const old = reloadLog(); + await vi.waitFor(() => expect(finish).toBeDefined()); + const request = api.log.mock.lastCall![1].request; + api.log.mockResolvedValue(ok([c("fresh")])); + await reloadLog(); + expect(api.cancel).toHaveBeenCalledWith(request); + finish(ok([c("stale")])); + await old; + expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["fresh"]); + }); +}); + +describe("commit navigation", () => { + it("resolves references in the repositories of a standalone multi-root log", async () => { + useVcs.setState({ root: null, repo: null, roots: [] }); + openLogTab("external", "/a", "toolWindow", ["/a", "/b"]); + api.log.mockImplementation(async (repo: string) => ok(repo ? [c("target")] : [])); + expect(await goToRef("main")).toBe(true); + expect(api.choose).toHaveBeenCalledWith([ + expect.objectContaining({ repo: "/a" }), + expect.objectContaining({ repo: "/b" }), + ]); + expect(useGitLog.getState().selected).toBe(commitKey({ ...c("target"), repo: "/b" })); + expect(api.log.mock.calls.every(([repo]) => repo === "/a" || repo === "/b")).toBe(true); + }); + + it("jumps to an indexed window beyond thirty pages without paging through the prefix", async () => { + const history = Array.from({ length: PAGE * 31 + 7 }, (_, i) => c(`commit${i}`)); + const target = history.at(-1)!; + api.log.mockImplementation(async (_repo: string, q: LogQuery) => + ok(q.branch === target.id || q.seek === target.id ? [target] : history.slice(q.skip, q.skip + q.limit)), + ); + expect(await goToRef(target.id)).toBe(true); + expect(useGitLog.getState().selected).toBe(commitKey({ ...target, repo: "/repo" })); + expect(api.log.mock.calls.some((call) => call[1].seek === target.id)).toBe(true); + expect(api.log.mock.calls.length).toBeLessThan(5); + }); + + it("navigates the displayed collapsed adjacency in both directions", async () => { + const commits = [c("tip", ["middle"]), c("middle", ["base"]), c("base")]; + useGitLog.setState({ commits, done: true }); + updateLogOptions({ collapseLinear: true }); + await goToParent(commits[0]!); + expect(useGitLog.getState().selected).toBe("base"); + await goToChild(commits[2]!); + expect(useGitLog.getState().selected).toBe("tip"); + }); + + it("expands merges in linearized mode and retains expansion across new pages", () => { + const commits = [c("merge", ["main", "side"]), c("side", ["base"]), c("main", ["base"]), c("base")]; + useGitLog.setState({ commits }); + useLogOptions.setState({ linearizeMerges: true }); + expect(displayedLogGraph().graph[0]!.parents).toEqual(["side"]); + updateLogOptions({ collapseLinear: false }); + expect(displayedLogGraph().graph[0]!.parents).toEqual(["main", "side"]); + updateLogOptions({ collapseLinear: true }); + expect(displayedLogGraph().graph[0]!.parents).toEqual(["side"]); + }); + + it("keeps selections and ranges spanning repository roots", () => { + const commits = [ + { ...c("a"), repo: "/a" }, + { ...c("b"), repo: "/b" }, + ]; + useGitLog.setState({ commits, selection: [commitKey(commits[0]!)] }); + toggleCommitSelection(commitKey(commits[1]!)); + expect(useGitLog.getState().selection).toEqual(commits.map(commitKey)); + extendCommitSelection(commitKey(commits[1]!), commits); + expect(useGitLog.getState().selection).toEqual(commits.map(commitKey)); + }); + + it("sends branches and independent ranges without dropping either", async () => { + setLogBranches(["main", "a..b", "c..d"]); + await reloadLog(); + expect(api.log.mock.lastCall?.[1]).toMatchObject({ branches: ["main"], range: null, ranges: ["a..b", "c..d"] }); + }); + + it("offers both merge parents and selects the chosen repository-scoped commit", async () => { + const commits = [c("merge", ["main", "side"]), c("main"), c("side")].map((c) => ({ ...c, repo: "/repo" })); + useGitLog.setState({ commits, done: true }); + await goToParent(commits[0]!); + expect(api.choose).toHaveBeenCalledWith(commits.slice(1)); + expect(useGitLog.getState().selected).toBe(commitKey(commits[2]!)); + }); + + it("does not navigate after a reference lookup is superseded by a filter change", async () => { + let finish!: (value: ReturnType>) => void; + api.log.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const navigate = goToRef("target"); + await vi.waitFor(() => expect(finish).toBeDefined()); + api.log.mockResolvedValue(ok([c("filtered")])); + await reloadLog(); + finish(ok([c("target")])); + expect(await navigate).toBe(false); + expect(useGitLog.getState().selected).toBeNull(); + expect(useGitLog.getState().commits[0]!.id).toBe("filtered"); + }); +}); diff --git a/src/features/vcs/__tests__/logOptions.test.ts b/src/features/vcs/__tests__/logOptions.test.ts index 56be32da..284de06d 100644 --- a/src/features/vcs/__tests__/logOptions.test.ts +++ b/src/features/vcs/__tests__/logOptions.test.ts @@ -37,8 +37,8 @@ describe("Collapse Linear Branches", () => { ]; const { commits: shown, graph } = collapseLinear(commits, new Set()); expect(shown.map((x) => x.id)).toEqual(["t", "m", "base"]); - expect(graph.find((g) => g.id === "m")).toEqual({ id: "m", parents: ["base"], dashed: [true] }); - expect(graph.find((g) => g.id === "t")).toEqual({ id: "t", parents: ["m"] }); + expect(graph.find((g) => g.id === "m")).toMatchObject({ id: "m", parents: ["base"], dashed: [true] }); + expect(graph.find((g) => g.id === "t")).toMatchObject({ id: "t", parents: ["m"] }); const { rows } = computeGraph(graph); expect(rows[1]!.down.some((s) => s.dashed)).toBe(true); expect(rows[2]!.up.some((s) => s.dashed)).toBe(true); @@ -52,7 +52,12 @@ describe("Collapse Linear Branches", () => { c("x", [], ["tag: v1"]), ]; const { graph } = collapseLinear(commits, new Set()); - expect(graph.find((g) => g.id === "y")).toEqual({ id: "y", parents: ["x"], dashed: [true] }); + expect(graph.find((g) => g.id === "y")).toMatchObject({ + id: "y", + parents: ["x"], + dashed: [true], + edgeTypes: ["filtered"], + }); expect(computeGraph(commits).rows[1]!.down.some((s) => s.dashed)).toBe(true); }); diff --git a/src/features/vcs/__tests__/logSearch.test.ts b/src/features/vcs/__tests__/logSearch.test.ts new file mode 100644 index 00000000..e936b6ac --- /dev/null +++ b/src/features/vcs/__tests__/logSearch.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import type { Commit } from "@/bindings"; +import { nextSpeedMatch, speedMatches, visibleCommitText } from "../logSearch"; +import { DEFAULT_LOG_OPTIONS } from "../logOptions"; +const c = (id: string): Commit => ({ + id, + shortId: id, + summary: "Synchronize client", + message: "body-only", + author: "Unique author", + email: "hidden@example.com", + refs: ["main"], + parents: [], + timeMs: 0, +}); +describe("log speed search", () => { + it("matches fuzzy words within visible columns and cycles in both directions", () => { + const commits = [c("one"), c("two")]; + expect(speedMatches("snc clt", visibleCommitText(commits[0]!, DEFAULT_LOG_OPTIONS))).toBe(true); + expect(nextSpeedMatch(commits, "client", DEFAULT_LOG_OPTIONS, 0)).toBe(1); + expect(nextSpeedMatch(commits, "client", DEFAULT_LOG_OPTIONS, 1)).toBe(0); + expect(nextSpeedMatch(commits, "client", DEFAULT_LOG_OPTIONS, 0, -1)).toBe(1); + expect(speedMatches("Unique", visibleCommitText(commits[0]!, { ...DEFAULT_LOG_OPTIONS, showAuthor: false }))).toBe( + false, + ); + expect(speedMatches("body-only", visibleCommitText(commits[0]!, DEFAULT_LOG_OPTIONS))).toBe(false); + }); +}); diff --git a/src/features/vcs/__tests__/logUiFeatures.test.tsx b/src/features/vcs/__tests__/logUiFeatures.test.tsx new file mode 100644 index 00000000..2159cf57 --- /dev/null +++ b/src/features/vcs/__tests__/logUiFeatures.test.tsx @@ -0,0 +1,205 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Commit } from "@/bindings"; +import { ContextMenuHost, hideContextMenu } from "@/ui"; + +const api = vi.hoisted(() => ({ commits: [] as Commit[], details: vi.fn(), diff: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); + return { + ...real, + commands: { + ...real.commands, + vcsLog: () => ok(api.commits), + vcsLogRefs: () => ok({}), + vcsBranches: () => ok([]), + vcsTags: () => ok([]), + vcsBranchInfo: () => ok({ upstream: null }), + vcsConfigGet: () => ok(null), + vcsCommitFiles: () => ok([{ path: "src/a.ts", kind: "modified", oldPath: null }]), + vcsLogDetails: () => api.details(), + vcsLogDetailsBatch: async (_repo: string, ids: string[]) => ({ + 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), + }, + }; +}); + +import { CommitDetails, LogView } from "../GitLog"; +import { useVcs } from "../store"; +import { commitKey } from "../multiRoot"; +import { emptyFilters, openLogTab, reloadLog, selectGitTab, useGitLog } from "../logStore"; +import { LogScope } from "../LogScope"; +import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions"; +import { VcsDialogHost } from "../dialogs"; + +const c = (id: string, parents: string[] = [], refs: string[] = []): Commit => ({ + id, + shortId: id, + parents, + refs, + author: "Author", + email: "a@example.com", + summary: id, + message: id, + timeMs: 0, +}); + +beforeEach(() => { + api.commits = [c("tip", ["middle"], ["HEAD"]), c("middle", ["base"]), c("base")]; + api.details.mockReset().mockResolvedValue({ + status: "ok", + data: { + message: "tip\n\nFull body loaded on demand", + containingBranches: ["main"], + signature: "G", + signer: "Author", + key: "key", + fingerprint: "fingerprint", + }, + }); + api.diff.mockReset().mockResolvedValue({ + status: "ok", + data: [ + { path: "src/a.ts", kind: "modified", oldPath: null, before: "p1", after: "merge" }, + { path: "elsewhere/b.ts", kind: "renamed", oldPath: "src/b.ts", before: "p1", after: "merge" }, + { path: "unrelated/c.ts", kind: "added", oldPath: null, before: null, after: "merge" }, + ], + }); + useVcs.setState({ + root: "/repo", + repo: "/repo", + roots: ["/repo"], + available: true, + branchesLoaded: true, + branches: [], + branch: { head: "main", headCommit: "tip", upstream: null, ahead: 0, behind: 0, operation: null }, + }); + useGitLog.setState({ + shownLog: "log", + tab: "log", + logTabs: [], + commits: [], + rows: [], + filters: emptyFilters(), + selection: [], + selected: null, + done: false, + branchScope: "/repo", + generation: useGitLog.getState().generation + 1, + }); + useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS, showBranches: false, showDetails: false }); +}); + +afterEach(async () => { + hideContextMenu(); + api.commits = []; + await reloadLog(); +}); + +describe("log controls", () => { + it("keeps a pending text edit with its original view when focus changes before the debounce", async () => { + const other = openLogTab("log", "Other"); + selectGitTab("log"); + render( + <> + + + + + + + , + ); + const [main, second] = screen.getAllByRole("textbox", { name: "Filter by message" }); + fireEvent.change(main!, { target: { value: "pending" } }); + fireEvent.pointerDown(second!); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 350)); + }); + expect(useGitLog.getState().shownLog).toBe(other); + expect(useGitLog.getState().filters.text).toBe(""); + fireEvent.pointerDown(main!); + await waitFor(() => expect(useGitLog.getState().filters.text).toBe("pending")); + }); + + it("expands a single collapsed fragment from its node without changing the global option", async () => { + useLogOptions.setState({ collapseLinear: true }); + render(); + const list = await screen.findByRole("listbox", { name: "Commits" }); + await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(2)); + const tip = within(list).getAllByRole("option")[0]!; + fireEvent.click(within(tip).getByRole("button", { name: "Collapse or expand this branch" })); + await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3)); + expect(useLogOptions.getState().collapseLinear).toBe(true); + }); + + it("keeps the selected middle commit visible under collapse and supports speed search", async () => { + useLogOptions.setState({ collapseLinear: true }); + useGitLog.setState({ selected: "/repo\0middle", selection: ["/repo\0middle"] }); + render(); + const list = await screen.findByRole("listbox", { name: "Commits" }); + await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3)); + fireEvent.keyDown(list, { key: "b" }); + expect(useGitLog.getState().selected).toBe("/repo\0base"); + expect(screen.getByRole("status")).toHaveTextContent("b"); + }); + + it("resizes and reorders columns using the keyboard", async () => { + render(); + const author = await screen.findByRole("separator", { name: "Resize Author column" }); + fireEvent.keyDown(author, { key: "ArrowRight" }); + expect(useLogOptions.getState().columnWidths.author).toBe(140); + fireEvent.keyDown(author, { key: "ArrowRight", altKey: true }); + expect(useLogOptions.getState().columnOrder).toEqual(["date", "author"]); + }); +}); + +describe("commit details", () => { + it("loads a full body, containing branches, and signature only when details are visible", async () => { + useGitLog.setState({ selected: "/repo\0tip", selection: ["/repo\0tip"] }); + render( + <> + + + , + ); + await screen.findByRole("listbox", { name: "Commits" }); + expect(api.details).not.toHaveBeenCalled(); + await act(async () => { + useLogOptions.setState({ showDetails: true }); + }); + expect(await screen.findByText("Full body loaded on demand")).toBeInTheDocument(); + expect(screen.getByText(/Signature.*Verified/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Expand Commit Message" })); + expect(await screen.findByRole("dialog", { name: "Commit tip" })).toHaveTextContent("Full body loaded on demand"); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + }); + + it("compares real merge parents and retains renamed files under affected-path filtering", async () => { + const merge = { ...c("merge", ["p1", "p2"]), repo: "/repo", graphParents: ["filtered-base"] }; + useGitLog.setState({ + commits: [merge], + selected: commitKey(merge), + selection: [commitKey(merge)], + filters: { ...emptyFilters(), paths: ["/repo/src"] }, + }); + useLogOptions.setState({ changesFromParents: true, showOnlyAffected: true }); + render( + <> + + + , + ); + expect(await screen.findByText("4 files")).toBeInTheDocument(); + expect(api.diff).toHaveBeenCalledWith("/repo", "p1", "merge"); + expect(screen.queryByText("c.ts")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "All Parents" })); + fireEvent.click(await screen.findByRole("menuitemcheckbox", { name: "Parent 2: p2" })); + await waitFor(() => expect(api.diff).toHaveBeenLastCalledWith("/repo", "p2", "merge")); + }); +}); diff --git a/src/features/vcs/__tests__/logViews.test.tsx b/src/features/vcs/__tests__/logViews.test.tsx new file mode 100644 index 00000000..d32a19e7 --- /dev/null +++ b/src/features/vcs/__tests__/logViews.test.tsx @@ -0,0 +1,142 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + const empty = () => Promise.resolve({ status: "ok" as const, data: [] }); + return { + ...real, + commands: { + ...real.commands, + vcsLog: empty, + vcsBranches: empty, + vcsBranchInfo: () => Promise.resolve({ status: "ok", data: { upstream: null } }), + vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }), + }, + }; +}); + +import { LogScope } from "../LogScope"; +import { DEFAULT_LOG_OPTIONS, useLogOptions } from "../logOptions"; +import { + closeLogTab, + emptyFilters, + installGitLog, + openLogTab, + recentLogFilters, + reloadLog, + selectGitTab, + setLogBranches, + setLogFilters, + updateLogOptions, + useGitLog, +} from "../logStore"; +import { useVcs } from "../store"; + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + useVcs.setState({ root: "/repo", repo: "/repo", roots: ["/repo"] }); + useGitLog.setState({ + shownLog: "log", + tab: "log", + logTabs: [], + commits: [], + rows: [], + filters: emptyFilters(), + selection: [], + selected: null, + done: true, + generation: useGitLog.getState().generation + 1, + }); + useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS }); +}); + +afterEach(async () => { + for (const tab of useGitLog.getState().logTabs) closeLogTab(tab.id); + await reloadLog(); +}); + +function Probe({ name }: { name: string }) { + const text = useGitLog((s) => s.filters.text); + const hash = useLogOptions((s) => s.showHash); + return ; +} + +describe("independent log views", () => { + it("keeps both scopes visible and activates the target before applying an edit", async () => { + setLogFilters({ text: "main" }); + const other = openLogTab("log", "Other"); + setLogFilters({ text: "other" }); + updateLogOptions({ showHash: true }); + selectGitTab("log"); + render( + <> + + + + + + + , + ); + expect(screen.getByText("main: main / hash false")).toBeInTheDocument(); + expect(screen.getByText("other: other / hash true")).toBeInTheDocument(); + const button = screen.getByText("other: other / hash true"); + await act(async () => { + fireEvent.pointerDown(button); + fireEvent.click(button); + }); + expect(useGitLog.getState().shownLog).toBe(other); + expect(screen.getByText("main: main / hash false")).toBeInTheDocument(); + expect(screen.getByText("other: other edited / hash true")).toBeInTheDocument(); + }); + + it("restores each tab's filters and options after leaving and reopening the project", async () => { + const off = installGitLog(); + setLogFilters({ text: "main" }); + const other = openLogTab("log", "Other"); + setLogFilters({ paths: ["/repo/src"], branches: ["feature/.*"] }); + useGitLog.setState({ + fragments: { + expanded: new Set(["/repo\0expanded"]), + collapsed: new Set(["/repo\0collapsed"]), + merges: new Set(["/repo\0merge"]), + }, + selected: "/repo\0expanded", + selection: ["/repo\0expanded"], + }); + updateLogOptions({ showHash: true, diffLocation: "bottom" }); + selectGitTab("log"); + await reloadLog(); + useVcs.setState({ root: "/another", repo: "/another", roots: ["/another"] }); + useVcs.setState({ root: "/repo", repo: "/repo", roots: ["/repo"] }); + expect(useGitLog.getState().filters.text).toBe("main"); + expect(useGitLog.getState().fragments.expanded.size).toBe(0); + selectGitTab(other); + expect(useGitLog.getState().filters).toMatchObject({ paths: ["/repo/src"], branches: ["feature/.*"] }); + expect(useLogOptions.getState()).toMatchObject({ showHash: true, diffLocation: "bottom" }); + expect([...useGitLog.getState().fragments.expanded]).toEqual(["/repo\0expanded"]); + expect([...useGitLog.getState().fragments.collapsed]).toEqual(["/repo\0collapsed"]); + expect([...useGitLog.getState().fragments.merges]).toEqual(["/repo\0merge"]); + expect(useGitLog.getState().selection).toEqual(["/repo\0expanded"]); + off(); + }); + + it("remembers branch-only filters and clears a range when a named branch replaces it", async () => { + setLogBranches(["main..feature"]); + await reloadLog(); + expect(useGitLog.getState().filters).toMatchObject({ range: "main..feature", branches: [] }); + setLogFilters({ branches: ["main"] }); + await reloadLog(); + expect(useGitLog.getState().filters.range).toBeNull(); + setLogBranches(["feature"]); + await reloadLog(); + expect(recentLogFilters()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ branches: ["main"] }), + expect.objectContaining({ range: "main..feature" }), + ]), + ); + }); +}); diff --git a/src/features/vcs/__tests__/multiRoot.test.ts b/src/features/vcs/__tests__/multiRoot.test.ts index 06a9ced8..b77558d6 100644 --- a/src/features/vcs/__tests__/multiRoot.test.ts +++ b/src/features/vcs/__tests__/multiRoot.test.ts @@ -71,6 +71,16 @@ beforeEach(() => { }); describe("multi-root Git log", () => { + it("keeps filtered edge hints from both graph metadata formats", () => { + const row = { ...commit("tip"), repo: "/a", parents: ["real"], dashed: [true] }; + expect(graphCommit(row)).toMatchObject({ parents: ["/a\0real"], dashed: [true], edgeTypes: ["filtered"] }); + expect(graphCommit({ ...row, graphParents: ["visible"], graphDashed: [false] })).toMatchObject({ + parents: ["/a\0visible"], + dashed: [false], + edgeTypes: ["normal"], + }); + }); + it("merges streams by time without violating each root's topological order", () => { const child = { ...commit("child", 1), repo: "a", parents: ["parent"] }; const parent = { ...commit("parent", 100), repo: "a" }; @@ -86,7 +96,7 @@ describe("multi-root Git log", () => { selectCommit(commitKey(front)); expect(selectedCommits()).toEqual([front]); toggleCommitSelection(commitKey(back)); - expect(selectedCommits()).toEqual([back]); + expect(selectedCommits()).toEqual([front, back]); expect(commitFileDiffData(back, { path: "main.ts", oldPath: null, kind: "added" }).path).toBe( "/project/back/main.ts", ); @@ -115,6 +125,30 @@ describe("multi-root Git log", () => { expect(rows.map((c) => c.timeMs)).toEqual([...rows.map((c) => c.timeMs)].sort((a, b) => b! - a!)); }); + it("does not mark native root pages complete before every fetched commit has been emitted", async () => { + const data = Object.fromEntries( + useVcs.getState().roots.map((repo, root) => [ + repo, + Array.from({ length: 180 }, (_, i) => ({ + ...commit(`${root}-${i}`, 10000 - i * 2 - root), + logPosition: i, + logTotal: 180, + })), + ]), + ); + log.mockImplementation(async (repo: string, q: LogQuery) => ({ + status: "ok", + data: data[repo]!.slice(q.skip, q.skip + q.limit), + })); + await reloadLog(); + expect(useGitLog.getState().commits).toHaveLength(PAGE); + expect(useGitLog.getState().done).toBe(false); + await loadMore(); + expect(useGitLog.getState().commits).toHaveLength(360); + expect(useGitLog.getState().done).toBe(true); + expect(new Set(useGitLog.getState().commits.map(commitKey)).size).toBe(360); + }); + it("filters repositories and keeps history tabs pinned when the selected root changes", async () => { log.mockImplementation(async () => ({ status: "ok", data: [commit("same")] })); useGitLog.setState({ filters: { ...emptyFilters(), repositories: ["/project/back"] } }); diff --git a/src/features/vcs/gitTabs.ts b/src/features/vcs/gitTabs.ts index 4e5de84c..1e914c82 100644 --- a/src/features/vcs/gitTabs.ts +++ b/src/features/vcs/gitTabs.ts @@ -54,7 +54,9 @@ export function gitTabs(input: GitTabsInput): ContentTab[] { return [ ...(input.modal ? [{ id: "changes", label: "Local Changes" }, ...stashTabs(input)] : []), { id: "log", label: "Log" }, - ...input.logTabs.map((t) => ({ id: t.id, label: logTabLabel(t), closable: true })), + ...input.logTabs + .filter((t) => t.location !== "editor") + .map((t) => ({ id: t.id, label: logTabLabel(t), closable: true })), { id: "console", label: "Console" }, { id: "worktrees", label: "Worktrees" }, ]; diff --git a/src/features/vcs/graph.ts b/src/features/vcs/graph.ts index 9659a81d..075dbdae 100644 --- a/src/features/vcs/graph.ts +++ b/src/features/vcs/graph.ts @@ -4,7 +4,12 @@ * transformations of the log's Graph / View options. */ +export type EdgeKind = "normal" | "filtered" | "collapsed" | "shallow" | "unloaded"; + export interface GraphSegment { + edge?: string; + kind?: EdgeKind; + source?: string; /** Lane at the segment's start (top edge for `up`, node level for `down`). */ from: number; /** Lane at the segment's end (node level for `up`, bottom edge for `down`). */ @@ -14,9 +19,12 @@ export interface GraphSegment { dashed?: true; /** A long edge continues out of sight in this direction (Show Long Edges off). */ arrow?: "up" | "down"; + /** Destination row of a long-edge arrow in the displayed graph. */ + targetRow?: number; } export interface GraphRow { + id?: string; /** Lane of the commit's node. */ lane: number; color: number; @@ -26,10 +34,15 @@ export interface GraphRow { down: GraphSegment[]; /** Number of lane slots this row spans. */ width: number; + /** Graph ids corresponding to each half-edge, for fragment actions. */ + upTargets?: string[]; + downTargets?: string[]; } interface Lane { id: string; + source?: string; + kind?: EdgeKind; color: number; dashed?: true; } @@ -44,6 +57,9 @@ export interface GraphCommit { parents: readonly string[]; /** Per parent: the link skips collapsed commits. */ dashed?: readonly boolean[]; + refs?: readonly string[]; + remoteNames?: readonly string[]; + edgeTypes?: readonly ("normal" | "filtered" | "collapsed" | "shallow" | "unloaded")[]; } export const emptyGraphState = (): GraphState => ({ lanes: [], nextColor: 0 }); @@ -68,6 +84,21 @@ export function computeGraph( state: GraphState = emptyGraphState(), ): { rows: GraphRow[]; state: GraphState } { const lanes = [...state.lanes]; + const track = commits.some((c) => c.refs !== undefined); + const positions = new Map(commits.map((c, i) => [c.id, i])); + const layout = graphLayout(commits); + const refAware = commits.some((c) => c.refs?.length); + const stableColor = (id: string, fallback: number) => { + if (!refAware) return fallback; + const index = positions.get(id); + const owner = index == null ? id : layout.owners[index]!; + let hash = 2166136261; + for (const char of owner) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619); + return hash >>> 0; + }; + const describe = (s: GraphSegment, lane: Lane) => + track ? { ...s, edge: `${lane.source ?? ""}\0${lane.id}`, source: lane.source, kind: lane.kind } : s; + let nextColor = state.nextColor; const rows: GraphRow[] = []; @@ -82,48 +113,89 @@ export function computeGraph( lane = matching[0]!; color = lanes[lane]!.color; } else { - lane = firstFree(lanes); - color = nextColor++; + const preferred = refAware ? Math.max(0, layout.layout[positions.get(c.id)!]! - 1) : firstFree(lanes); + lane = !lanes[preferred] ? preferred : firstFree(lanes); + color = stableColor(c.id, nextColor++); } const widthBefore = lanes.length; const up: GraphSegment[] = []; + const upTargets: string[] = []; lanes.forEach((l, i) => { if (!l) return; - up.push(seg(i, l.id === c.id ? lane : i, l.color, l.dashed)); + up.push(describe(seg(i, l.id === c.id ? lane : i, l.color, l.dashed), l)); + upTargets.push(l.id); }); for (const m of matching) lanes[m] = null; if (lane >= lanes.length) lanes.length = lane + 1; lanes[lane] = null; const down: GraphSegment[] = []; + const downTargets: string[] = []; lanes.forEach((l, i) => { - if (l) down.push(seg(i, i, l.color, l.dashed)); + if (l) { + down.push(describe(seg(i, i, l.color, l.dashed), l)); + downTargets.push(l.id); + } }); c.parents.forEach((p, k) => { const dashed = !!c.dashed?.[k]; + const kind = c.edgeTypes?.[k] ?? (dashed ? "filtered" : "normal"); + const info = { + id: p, + source: c.id, + kind: !positions.has(p) && kind !== "shallow" ? ("unloaded" as const) : kind, + }; + if (track && kind === "shallow") { + down.push({ + ...describe(seg(lane, lane, color, dashed), { ...info, color }), + kind: kind === "shallow" ? "shallow" : "unloaded", + arrow: "down", + targetRow: -1, + }); + downTargets.push(p); + return; + } // The first parent continues this lane even if another lane also leads to it // (both lines then converge at the parent's node, like `git log --graph`). if (k === 0) { - lanes[lane] = laneOf(p, color, dashed); - down.push(seg(lane, lane, color, dashed)); + lanes[lane] = { ...laneOf(p, color, dashed), ...info }; + down.push(describe(seg(lane, lane, color, dashed), { ...info, color })); + downTargets.push(p); return; } const existing = lanes.findIndex((l) => l?.id === p); if (existing >= 0) { - down.push(seg(lane, existing, lanes[existing]!.color, dashed)); + down.push( + describe(seg(lane, existing, lanes[existing]!.color, dashed), { ...info, color: lanes[existing]!.color }), + ); + downTargets.push(p); return; } const slot = firstFree(lanes); - const col = nextColor++; - lanes[slot] = laneOf(p, col, dashed); - down.push(seg(lane, slot, col, dashed)); + const col = stableColor(p, nextColor++); + lanes[slot] = { ...laneOf(p, col, dashed), ...info }; + down.push(describe(seg(lane, slot, col, dashed), { ...info, color: col })); + downTargets.push(p); }); while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop(); - const widthAfter = Math.max(0, ...down.map((s) => s.to + 1)); - rows.push({ lane, color, up, down, width: Math.max(widthBefore, widthAfter, lane + 1) }); + const widthAfter = down.reduce((width, segment) => Math.max(width, segment.to + 1), 0); + rows.push({ + ...(track ? { id: c.id } : {}), + lane, + color, + up, + down, + upTargets, + downTargets, + width: Math.max(widthBefore, widthAfter, lane + 1), + }); + } + if (track && rows.length) { + const last = rows.at(-1)!; + last.down = last.down.map((s) => (s.kind === "unloaded" ? { ...s, arrow: "down", targetRow: -1 } : s)); } return { rows, state: { lanes, nextColor } }; } @@ -134,13 +206,28 @@ export function computeGraph( * linked by dashed edges. Commits in `keep` (HEAD) stay visible. */ export function collapseLinear< - C extends { id: string; parents: readonly string[]; refs: readonly string[]; dashed?: readonly boolean[] }, ->(commits: readonly C[], keep: ReadonlySet): { commits: C[]; graph: GraphCommit[] } { + C extends { + id: string; + parents: readonly string[]; + refs: readonly string[]; + dashed?: readonly boolean[]; + edgeTypes?: readonly EdgeKind[]; + }, +>( + commits: readonly C[], + keep: ReadonlySet, + only?: ReadonlySet, +): { commits: C[]; graph: GraphCommit[] } { const byId = new Map(commits.map((c) => [c.id, c])); const children = new Map(); for (const c of commits) for (const p of c.parents) children.set(p, (children.get(p) ?? 0) + 1); const hidden = (c: C | undefined): boolean => - !!c && c.parents.length === 1 && children.get(c.id) === 1 && !c.refs.length && !keep.has(c.id); + !!c && + c.parents.length === 1 && + children.get(c.id) === 1 && + !c.refs.length && + !keep.has(c.id) && + (!only || only.has(c.id)); const visible: C[] = []; const graph: GraphCommit[] = []; @@ -148,14 +235,16 @@ export function collapseLinear< if (hidden(c)) continue; const dashed: boolean[] = []; const parents: string[] = []; + const edgeTypes: EdgeKind[] = []; for (const [i, p] of c.parents.entries()) { let cur = p; - // A filter may already link past hidden commits. - let skipped = c.dashed?.[i] ?? false; + let skipped = !!c.dashed?.[i]; + let kind = c.edgeTypes?.[i] ?? (skipped ? "filtered" : "normal"); for (let next = byId.get(cur), guard = 0; next && guard < commits.length && hidden(next); guard++) { cur = next.parents[0]!; next = byId.get(cur); skipped = true; + kind = "collapsed"; } // Two collapsed lines can lead to the same commit: draw one edge. const seen = parents.indexOf(cur); @@ -163,10 +252,11 @@ export function collapseLinear< else { parents.push(cur); dashed.push(skipped); + edgeTypes.push(kind); } } visible.push(c); - graph.push(dashed.some(Boolean) ? { id: c.id, parents, dashed } : { id: c.id, parents }); + graph.push({ ...c, id: c.id, parents, dashed, edgeTypes }); } return { commits: visible, graph }; } @@ -183,8 +273,184 @@ export function reachableFrom( return out; } -/** Edges running straight through at least this many rows are long. */ -export const LONG_EDGE = 30; +/** Ref-priority DFS layout, shared by Bek fragment recognition and lane colours. + * Adapted from IntelliJ GraphLayoutBuilder (JetBrains contributors, Apache-2.0). */ +export function graphLayout(commits: readonly GraphCommit[]) { + const positions = new Map(commits.map((c, i) => [c.id, i])); + const parents = commits.map((c) => + c.parents.flatMap((id) => { + const i = positions.get(id); + return i == null ? [] : [i]; + }), + ); + const children = commits.map(() => [] as number[]); + parents.forEach((ps, i) => ps.forEach((p) => children[p]!.push(i))); + const refRank = (c: GraphCommit, ref: string) => + `${ref === "HEAD" ? 0 : ref === "main" || ref === "master" ? 1 : ref.startsWith("tag: ") ? 4 : c.remoteNames?.includes(ref) ? 3 : 2}${ref}`; + const priority = (i: number) => + commits[i]!.refs?.length ? commits[i]!.refs!.map((r) => refRank(commits[i]!, r)).sort()[0]! : `5${commits[i]!.id}`; + const heads = commits + .flatMap((c, i) => (!children[i]!.length || c.refs?.length ? [i] : [])) + .sort((a, b) => priority(a).localeCompare(priority(b))); + const layout = commits.map(() => 0); + const owners = commits.map(() => ""); + let next = 1; + for (const head of heads) { + if (layout[head]) continue; + const owner = + commits[head]!.refs?.filter((r) => r !== "HEAD").sort((a, b) => + refRank(commits[head]!, a).localeCompare(refRank(commits[head]!, b)), + )[0] ?? (commits[head]!.refs?.includes("HEAD") ? "HEAD" : commits[head]!.id); + const startLayout = next; + const stack = [head]; + while (stack.length) { + const node = stack.at(-1)!; + const first = !layout[node]; + if (first) { + layout[node] = next; + owners[node] = `${owner}\0${next - startLayout}`; + } + const parent = parents[node]!.find((p) => !layout[p]); + if (parent != null) stack.push(parent); + else { + if (first) next++; + stack.pop(); + } + } + } + return { layout, owners, parents, children }; +} + +/** LinearBek fragment recognition and nested expansion. + * Adapted from JetBrains LinearBekGraphBuilder, Copyright 2000-2025, Apache-2.0. */ +export function linearizeMerges(commits: readonly GraphCommit[], expanded: ReadonlySet = new Set()) { + const positions = new Map(commits.map((c, i) => [c.id, i])); + const { layout } = graphLayout(commits); + const expand = new Set(expanded); + const build = () => { + const graph = commits.map((c) => ({ + ...c, + parents: [...c.parents], + dashed: c.parents.map((_, i) => !!c.dashed?.[i]), + })); + const fragments = new Map(); + const nodes = new Map>(); + for (let merge = graph.length - 1; merge >= 0; merge--) { + const down = graph[merge]!.parents.flatMap((p) => { + const i = positions.get(p); + return i == null ? [] : [i]; + }).sort((a, b) => a - b); + if (down.length !== 2 || graph[merge]!.parents.length !== 2) continue; + const [right, left] = down as [number, number]; + const body = new Set(), + tails = new Set(); + const tailEdges = new Map>(); + const queue: Array<{ up: number; down: number | undefined; id: string }> = []; + const add = (up: number) => graph[up]!.parents.forEach((id) => queue.push({ up, down: positions.get(id), id })); + const magic = new Set(); + const pending = [...graph[left]!.parents]; + while (pending.length) { + const id = pending.pop()!, + i = positions.get(id); + if (i == null || i > left + LONG_EDGE || magic.has(i)) continue; + magic.add(i); + pending.push(...graph[i]!.parents); + } + let rows = 1, + size = 1, + old = false, + valid = true; + const visited = new Set(); + add(right); + while (queue.length && valid) { + queue.sort((a, b) => (b.down ?? Infinity) - (a.down ?? Infinity)); + const e = queue.pop()!; + const key = `${e.up}\0${e.id}`; + if (visited.has(key)) continue; + visited.add(key); + const next = e.down; + if (next == null) { + if (!body.has(e.up)) tails.add(e.up); + continue; + } + if (next === left) { + if (!body.has(e.up)) tails.add(e.up); + old = true; + } else if (next >= right + rows && next < left) { + rows = next - right + 1; + size++; + add(next); + body.add(e.up); + } else if (next > left) { + const li = layout[next]!, + ll = layout[left]!, + rl = layout[right]!; + let allowed: boolean; + if (ll > rl && !old) allowed = next <= left + LONG_EDGE && magic.has(next); + else if ((li > ll && li < rl) || li === ll) allowed = true; + else if (li >= rl) allowed = false; + else allowed = next > left + LONG_EDGE ? tailEdges.has(e.up) || body.has(e.up) : magic.has(next); + if (!allowed) { + valid = false; + break; + } + if (!body.has(e.up)) { + tails.add(e.up); + tailEdges.set(e.up, new Set([...(tailEdges.get(e.up) ?? []), e.id])); + } + } + if (size >= 200) valid = false; + } + if (!valid || !tails.size) continue; + const id = graph[merge]!.id; + nodes.set(id, new Set([merge, left, right, ...body, ...tails].map((i) => graph[i]!.id))); + if (expand.has(id)) continue; + for (const [up, removed] of tailEdges) { + const c = graph[up]!; + c.dashed = c.dashed.filter((_, i) => !removed.has(c.parents[i]!)); + c.parents = c.parents.filter((p) => !removed.has(p)); + } + for (const tail of tails) { + const c = graph[tail]!, + target = graph[left]!.id; + const index = c.parents.indexOf(target); + if (index < 0) { + c.parents.push(target); + c.dashed.push(true); + } else c.dashed[index] = true; + fragments.set(`${c.id}\0${target}`, id); + } + const c = graph[merge]!, + target = graph[left]!.id; + c.dashed = c.dashed.filter((_, i) => c.parents[i] !== target); + c.parents = c.parents.filter((p) => p !== target); + } + for (const c of graph) { + const original = commits[positions.get(c.id)!]!; + c.edgeTypes = c.parents.map((p, i) => + fragments.has(`${c.id}\0${p}`) + ? "collapsed" + : (original.edgeTypes?.[original.parents.indexOf(p)] ?? (c.dashed[i] ? "filtered" : "normal")), + ); + } + return { graph, fragments, nodes }; + }; + let result = build(); + // Expanding a containing merge restores its nested fragments as well. + for (;;) { + let changed = false; + for (const id of [...expand]) + for (const node of result.nodes.get(id) ?? []) + if (node !== id && result.nodes.has(node) && !expand.has(node)) { + expand.add(node); + changed = true; + } + if (!changed) return result; + result = build(); + } +} + +const LONG_EDGE = 30; /** Rows of a hidden long edge still drawn next to each of its ends. */ const LONG_EDGE_STUB = 2; @@ -198,7 +464,7 @@ const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLan */ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): GraphRow[] { const out = rows.slice(); - const width = Math.max(0, ...rows.map((r) => r.width)); + const width = rows.reduce((max, row) => Math.max(max, row.width), 0); const edit = (r: number, fn: (row: GraphRow) => GraphRow) => { out[r] = fn(out[r]!); }; @@ -214,10 +480,22 @@ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): const from = start + LONG_EDGE_STUB; const to = r - 1 - LONG_EDGE_STUB; for (let k = from; k <= to; k++) { - edit(k, (row) => ({ ...row, up: row.up.filter((s) => !lane(s)), down: row.down.filter((s) => !lane(s)) })); + edit(k, (row) => ({ + ...row, + up: row.up.filter((s) => !lane(s)), + down: row.down.filter((s) => !lane(s)), + upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)), + downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)), + })); } - edit(from - 1, (row) => ({ ...row, down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down" } : s)) })); - edit(to + 1, (row) => ({ ...row, up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up" } : s)) })); + edit(from - 1, (row) => ({ + ...row, + down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: r } : s)), + })); + edit(to + 1, (row) => ({ + ...row, + up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)), + })); } start = -1; } diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index a45f689b..4becc6a3 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -1,3 +1,4 @@ +import { parseCommitBookmark } from "./logBookmarks"; import { commitKey, commitRepo } from "./multiRoot"; /** * Actions for operations in progress, Git Log commits, change lists, the shelf @@ -23,7 +24,7 @@ import { useChangelists } from "./changelists"; import { commitWindowFocused } from "./actions"; import { useCommit } from "./commitStore"; import { openCommit } from "./commitUi"; -import { goToChild, goToParent, goToRef, selectedCommits, useGitLog } from "./logStore"; +import { goToChild, goToParent, goToRef, openLogTab, selectedCommits, useGitLog } from "./logStore"; import { chooseRevision } from "./RevisionChooser"; import { cherryPick, @@ -59,9 +60,10 @@ import { unshelve, } from "./shelf"; import { currentRepo, focusRepo, useVcs } from "./store"; +import { openExternalLog } from "./RevisionBrowser"; const VCS = "vcsAvailable"; -const IN_GIT_LOG = "vcsAvailable && activeToolWindow == git"; +const IN_GIT_LOG = "(vcsAvailable || projectOpen) && (activeToolWindow == git || vcsLogFocus)"; const IN_COMMIT = "vcsAvailable && activeToolWindow == commit"; /** The commit an action refers to: its argument (commit or id), else the Git Log selection. */ @@ -84,16 +86,18 @@ export function commitsArg(arg: unknown): Commit[] { return selection.length ? selection : [commitArg(undefined)].filter((c): c is Commit => !!c); } -function withCommits(fn: (cs: Commit[]) => unknown) { +function withCommits(fn: (cs: Commit[]) => unknown, allowReadOnly = false) { return async ({ arg }: { arg?: unknown }) => { const cs = commitsArg(arg); if (!cs.length) { notifications().notify({ group: "vcs", level: "info", title: "Select a commit in the Git log" }); return; } - if (new Set(cs.map(commitRepo)).size > 1) return; + if (!allowReadOnly && new Set(cs.map(commitRepo)).size > 1) return; + const readOnly = cs.some((c) => "readOnly" in c && c.readOnly === true); + if (readOnly && !allowReadOnly) return; const repo = commitRepo(cs[0]!); - if (repo) await focusRepo(repo); + if (repo && !readOnly) await focusRepo(repo); void fn(cs); }; } @@ -108,7 +112,7 @@ async function openCommitInBrowser(c: Commit) { else notifications().notify({ group: "vcs", level: "warning", title: `No web URL for remote '${remote}'` }); } -function withCommit(fn: (c: Commit) => unknown) { +function withCommit(fn: (c: Commit) => unknown, allowReadOnly = false) { return async ({ arg }: { arg?: unknown }) => { const c = commitArg(arg); if (!c) { @@ -116,7 +120,9 @@ function withCommit(fn: (c: Commit) => unknown) { return; } const repo = commitRepo(c); - if (repo) await focusRepo(repo); + const readOnly = "readOnly" in c && c.readOnly === true; + if (readOnly && !allowReadOnly) return; + if (repo && !readOnly) await focusRepo(repo); void fn(c); }; } @@ -154,6 +160,21 @@ export function trackOperationContext(): () => void { export function registerHistoryActions(): () => void { const actions: Action[] = [ + { + id: "vcs.log.revealBookmark", + title: "Show Commit Bookmark", + category: "Git Log", + run: async ({ arg }) => { + const target = typeof arg === "string" ? parseCommitBookmark(arg) : null; + if (target) { + if (target.repo && !useVcs.getState().roots.includes(target.repo)) { + const root = await call(commands.vcsOpenExternalLog(target.repo)); + openLogTab("external", root, "editor", [root]); + } + await goToRef(target.id, target.repo || null); + } + }, + }, // --- operation in progress (Git › Continue Rebase…) --- { id: "vcs.rebase.continue", @@ -225,19 +246,28 @@ export function registerHistoryActions(): () => void { }, // --- Git Log toolbar --- + { + id: "vcs.log.openExternal", + title: "Open External Git Log…", + category: "Git Log", + run: openExternalLog, + }, { id: "vcs.log.goToRef", title: "Go to Hash/Branch/Tag", category: "Git Log", icon: "search", - when: VCS, + when: "vcsAvailable || projectOpen || vcsLogFocus", keyWhen: IN_GIT_LOG, keywords: ["find commit", "jump"], run: async () => { - toolWindowService().show("git", true); + const log = useGitLog.getState(); + const tab = log.logTabs.find((t) => t.id === log.shownLog); + if (tab?.location !== "editor") toolWindowService().show("git", true); const rev = await chooseRevision("Go to Hash/Branch/Tag", { tags: true, placeholder: "Enter hash or branch/tag name", + repositories: tab?.repo ? [tab.repo] : log.filters.repositories, }); if (rev && !(await goToRef(rev))) notifications().notify({ group: "vcs", level: "warning", title: `'${rev}' was not found in the log` }); @@ -250,9 +280,9 @@ export function registerHistoryActions(): () => void { title: "Copy Revision Number", category: "Git Log", icon: "copy", - when: VCS, + when: "vcsAvailable || projectOpen || vcsLogFocus", keyWhen: IN_GIT_LOG, - run: withCommits((cs) => navigator.clipboard?.writeText(cs.map((c) => c.id).join(" "))), + run: withCommits((cs) => navigator.clipboard?.writeText(cs.map((c) => c.id).join(" ")), true), }, { id: "vcs.log.createPatch", @@ -382,16 +412,16 @@ export function registerHistoryActions(): () => void { title: "Go to Child Commit", category: "Git Log", icon: "arrow-left", - when: VCS, - run: withCommit(goToChild), + when: "vcsAvailable || projectOpen || vcsLogFocus", + run: withCommit(goToChild, true), }, { id: "vcs.log.goToParent", title: "Go to Parent Commit", category: "Git Log", icon: "arrow-right", - when: VCS, - run: withCommit(goToParent), + when: "vcsAvailable || projectOpen || vcsLogFocus", + run: withCommit(goToParent, true), }, { id: "vcs.log.openInBrowser", diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 64f1bd3f..750bfded 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -44,7 +44,7 @@ import { activate as activateConflictMarkers } from "./conflictMarkers"; import { MergeRebaseWidget } from "./MergeRebaseWidget"; import { installFileConfirmations } from "./confirmations"; import { installIncomingCheck } from "./incoming"; -import { installGitLog, selectGitTab, useGitLog } from "./logStore"; +import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore"; import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store"; import { tx } from "@/platform/i18n"; @@ -154,6 +154,7 @@ export { activateComparisonWindow, ComparisonWindow } from "./ComparisonWindow"; export function activateDiffViewer(): () => void { return registerEditorKind({ kind: DIFF_KIND, + standalone: true, component: lazyComponent(() => import("./diff-editor-tab").then((m) => m.DiffEditorTab)), title: (input) => diffTitle(input.data as DiffData), icon: () => "diff", @@ -241,6 +242,34 @@ export function activate(): () => void { component: lazyComponent(() => import("./GitLog").then((m) => m.GitToolWindow)), headerContent: lazyComponent(() => import("./GitLog").then((m) => m.GitTabs)), }), + registerEditorKind({ + kind: "git-revision", + standalone: true, + component: lazyComponent(() => import("./RevisionBrowser").then((m) => m.GitRevisionEditor)), + title: (input) => { + const data = input.data as { path: string; revision: string }; + return `${baseName(data.path)} (${data.revision.slice(0, 8)})`; + }, + icon: () => "file", + restorable: true, + }), + registerEditorKind({ + kind: "git-log", + standalone: true, + component: lazyComponent(() => import("./LogScope").then((m) => m.GitLogEditor)), + title: (input) => (input.data as { label: string }).label, + icon: () => "toolwindow-git", + restorable: true, + onClose: (input) => closeLogTab((input.data as { id: string }).id), + }), + registerEditorKind({ + kind: "git-merge-diff", + standalone: true, + component: lazyComponent(() => import("./GitMergeDiff").then((m) => m.GitMergeDiff)), + title: (input) => (input.data as { path: string }).path, + icon: () => "diff", + restorable: true, + }), activateDiffViewer(), registerEditorKind({ kind: MERGE_KIND, diff --git a/src/features/vcs/logBookmarks.ts b/src/features/vcs/logBookmarks.ts new file mode 100644 index 00000000..51169943 --- /dev/null +++ b/src/features/vcs/logBookmarks.ts @@ -0,0 +1,16 @@ +import type { Commit } from "@/bindings"; +import { commitRepo } from "./multiRoot"; + +/** Stable virtual bookmark target; uses the platform's groups, mnemonic and description model. */ +export const commitBookmarkPath = (commit: Commit) => + `git-log:${encodeURIComponent(commitRepo(commit) ?? "")}#${commit.id}`; +export function parseCommitBookmark(path: string): { repo: string; id: string } | null { + if (!path.startsWith("git-log:")) return null; + const [repo, id] = path.slice(8).split("#"); + if (!id) return null; + try { + return { repo: decodeURIComponent(repo!), id }; + } catch { + return null; + } +} diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index c292d945..7a07b3c1 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -14,6 +14,7 @@ import { baseName } from "./model"; import { readWorkingText, runOp, saveAll, writeFileText } from "./ops"; import { reportApply } from "./shelf"; import { currentRepoRoot } from "./store"; +import { commitRepo } from "./multiRoot"; import { repositoryPath } from "./repositoryPaths"; import { confirmDialog } from "./dialogs"; @@ -29,6 +30,9 @@ export interface LogChange { /** `null`: the change deleted the file. */ after: string | null; diff: DiffData; + readOnly?: boolean; + group?: string; + mergeParents?: Array<{ rev: string; path: string }>; } /** One commit's files (`commit` given) or several commits' combined changes. */ @@ -36,12 +40,14 @@ export function toLogChanges( files: readonly (ChangedFile | RevisionChange)[], commit: Commit | null, repo = currentRepoRoot(), + readOnly = false, ): LogChange[] { return files.map((f) => { if (commit) { const parent = commit.parents[0] ?? null; return { repo, + readOnly, path: f.path, oldPath: f.oldPath, kind: f.kind, @@ -53,6 +59,7 @@ export function toLogChanges( const r = f as RevisionChange; return { repo, + readOnly, path: r.path, oldPath: r.oldPath, kind: r.kind, @@ -138,6 +145,22 @@ export function changesArg(arg: unknown): LogChange[] { export async function showChangesDiff(changes: readonly LogChange[], preview = false) { const first = changes[0]; if (!first) return; + if (first.mergeParents?.length === 2 && first.after) { + await editorService().openInput( + { + kind: "git-merge-diff", + key: `git-merge-diff:${first.repo}:${first.after}:${first.path}`, + data: { + repo: first.repo ?? currentRepoRoot(), + revision: first.after, + path: first.path, + parents: first.mergeParents, + }, + }, + { preview, focus: !preview }, + ); + return; + } const all = useLogChanges.getState().all; const chain = all.includes(first) ? all : [...changes]; await openDiff( @@ -151,6 +174,7 @@ export async function showChangesDiff(changes: readonly LogChange[], preview = f /** Compare with Local (`side` after) / Compare Before with Local: the revision ↔ the working file. */ export async function compareChangeWithLocal(change: LogChange, side: "after" | "before" = "after") { + if (change.readOnly) return; const rev = side === "after" ? change.after : change.before; if (!rev) return; const revPath = side === "before" && change.oldPath ? absolutePath(change.oldPath, change.repo) : undefined; @@ -163,6 +187,7 @@ export async function compareChangeWithLocal(change: LogChange, side: "after" | /** Jump to Source: the working file. */ export async function openChangeSource(change: LogChange) { + if (change.readOnly) return; await editorService().openFile(absolutePath(change.path, change.repo), { focus: true }); } @@ -171,6 +196,7 @@ export async function openChangeSource(change: LogChange) { * change deleted is deleted. */ export async function getFromRevision(changes: readonly LogChange[]) { + if (changes.some((c) => c.readOnly)) return; if (!changes.length) return; const ok = await confirmDialog({ title: "Get from Revision", @@ -205,10 +231,17 @@ export async function getFromRevision(changes: readonly LogChange[]) { /** Revert Selected Changes: applies the reverse of the selected files' changes to the working tree. */ export async function revertSelectedChanges(changes: readonly LogChange[], commits: readonly Commit[]) { + if (changes.some((c) => c.readOnly)) return; if (!changes.length || !commits.length) return; + const repo = changes[0]!.repo ?? currentRepoRoot(); + if (changes.some((c) => (c.repo ?? currentRepoRoot()) !== repo)) return; await saveAll(); // Oldest first; the backend reverses the order for a reversed patch. - const ids = [...commits].reverse().map((c) => c.id); + const ids = [...commits] + .reverse() + .filter((c) => (commitRepo(c) ?? currentRepoRoot()) === repo) + .map((c) => c.id); + if (!ids.length) return; const paths = new Set(changes.flatMap((c) => (c.oldPath ? [c.path, c.oldPath] : [c.path]))); const outcome = await runOp( tx("Revert selected changes"), diff --git a/src/features/vcs/logContext.ts b/src/features/vcs/logContext.ts new file mode 100644 index 00000000..bf8ea851 --- /dev/null +++ b/src/features/vcs/logContext.ts @@ -0,0 +1,5 @@ +import { createContext } from "react"; +import type { LogOptions } from "./logOptions"; + +export const LogIdContext = createContext(null); +export const LogOptionsContext = createContext(null); diff --git a/src/features/vcs/logDetails.ts b/src/features/vcs/logDetails.ts new file mode 100644 index 00000000..4d5974ca --- /dev/null +++ b/src/features/vcs/logDetails.ts @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import type { Commit, LogDetails } from "@/bindings"; +import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc"; +import { commitRepo } from "./multiRoot"; +import { currentRepo, useVcs } from "./store"; +import { create } from "zustand"; + +const refVersions = create>(() => ({})); +export function invalidateLogDetails(roots: readonly string[]) { + refVersions.setState((s) => Object.fromEntries(roots.map((root) => [root, (s[root] ?? 0) + 1]))); +} + +interface Entry { + key: string; + repo: string | null; + id: string; + promise: Promise; + resolve: (details: LogDetails) => void; + reject: (error: unknown) => void; + users: number; + request?: RequestId; + settled: boolean; +} +const cache = new Map(); +const pending: Entry[] = []; +const batches = new Map(); +let scheduled = false; +let running = 0; + +function forget(entry: Entry) { + if (cache.get(entry.key) === entry) cache.delete(entry.key); +} + +function flush() { + scheduled = false; + while (pending.length && running < 4) { + const first = pending.shift()!; + if (!first.users) { + forget(first); + first.reject(new Error("Commit details request cancelled")); + continue; + } + const entries = [first]; + for (let i = 0; i < pending.length && entries.length < 64;) { + if (!pending[i]!.users) { + const cancelled = pending.splice(i, 1)[0]!; + forget(cancelled); + cancelled.reject(new Error("Commit details request cancelled")); + } else if (pending[i]!.repo === first.repo) entries.push(...pending.splice(i, 1)); + else i++; + } + const request = nextRequestId(); + entries.forEach((entry) => { + entry.request = request; + }); + batches.set(request, entries); + running++; + void call( + commands.vcsLogDetailsBatch( + first.repo, + entries.map((e) => e.id), + request, + ), + ) + .then( + (details) => + entries.forEach((entry, i) => { + if (details[i]) { + entry.settled = true; + entry.resolve(details[i]!); + } else { + forget(entry); + entry.reject(new Error("Commit details were not returned")); + } + }), + (error) => + entries.forEach((entry) => { + forget(entry); + entry.reject(error); + }), + ) + .finally(() => { + running--; + batches.delete(request); + flush(); + }); + } +} + +/** Batches visible rows by root. Ref-dependent fields refresh on version changes; + * the backend caches immutable messages and signatures independently. */ +export function loadLogDetails(commit: Commit, signal?: AbortSignal): Promise { + const repo = commitRepo(commit) ?? currentRepo(); + const key = `${repo}\0${commit.id}\0${useVcs.getState().version}\0${refVersions.getState()[repo ?? ""] ?? 0}`; + let entry = cache.get(key); + if (!entry) { + let resolve!: Entry["resolve"], reject!: Entry["reject"]; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + entry = { key, repo, id: commit.id, promise, resolve, reject, users: 0, settled: false }; + if (cache.size >= 10000) cache.delete(cache.keys().next().value!); + cache.set(key, entry); + pending.push(entry); + } + entry.users++; + const own = entry; + const abort = () => { + own.users = Math.max(0, own.users - 1); + if (!own.settled && !own.users) forget(own); + if (own.settled || !own.request) return; + const batch = batches.get(own.request); + if (batch?.every((e) => !e.users)) void commands.cancelRequest(own.request).catch(() => undefined); + }; + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + if (!scheduled) { + scheduled = true; + queueMicrotask(flush); + } + return entry.promise; +} + +export function useCommitDetails(commit: Commit, enabled = true) { + const version = useVcs((s) => s.version); + const repo = commitRepo(commit) ?? currentRepo(); + const refs = refVersions((s) => s[repo ?? ""] ?? 0); + const key = `${repo}\0${commit.id}\0${version}\0${refs}`; + const [result, setResult] = useState<{ key: string; details: LogDetails | null; error: boolean } | null>(null); + useEffect(() => { + if (!enabled) return; + const controller = new AbortController(); + void loadLogDetails(commit, controller.signal) + .then((details) => { + if (!controller.signal.aborted) setResult({ key, details, error: false }); + }) + .catch(() => { + if (!controller.signal.aborted) setResult({ key, details: null, error: true }); + }); + return () => controller.abort(); + }, [commit, key, enabled, version]); + return { details: result?.key === key ? result.details : null, error: result?.key === key && result.error }; +} diff --git a/src/features/vcs/logIndex.tsx b/src/features/vcs/logIndex.tsx new file mode 100644 index 00000000..33a0a73f --- /dev/null +++ b/src/features/vcs/logIndex.tsx @@ -0,0 +1,110 @@ +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/features/vcs/logOptions.ts b/src/features/vcs/logOptions.ts index 611ec3a6..4e594a83 100644 --- a/src/features/vcs/logOptions.ts +++ b/src/features/vcs/logOptions.ts @@ -3,8 +3,10 @@ * persisted across sessions. */ import { create } from "zustand"; +import { useContext } from "react"; +import { LogOptionsContext } from "./logContext"; -export type LogSort = "date" | "topo"; +export type LogSort = "date" | "topo" | "standard"; export interface LogOptions { sort: LogSort; @@ -19,6 +21,7 @@ export interface LogOptions { showAuthor: boolean; showDate: boolean; showHash: boolean; + showSignature: boolean; highlightMine: boolean; highlightMerges: boolean; highlightCurrentBranch: boolean; @@ -28,6 +31,14 @@ export interface LogOptions { groupByDirectory: boolean; /** Selecting a changed file shows its diff in the editor's preview tab. */ diffPreview: boolean; + showDetails: boolean; + showRepository: boolean; + showOnlyAffected: boolean; + changesFromParents: boolean; + diffLocation: "editor" | "right" | "bottom"; + columnOrder: string[]; + columnWidths: Record; + linearizeMerges: boolean; } /** IDEA's defaults. */ @@ -44,12 +55,21 @@ export const DEFAULT_LOG_OPTIONS: LogOptions = { showAuthor: true, showDate: true, showHash: false, + showSignature: false, highlightMine: true, highlightMerges: true, highlightCurrentBranch: true, showBranches: true, groupByDirectory: true, diffPreview: true, + showDetails: true, + showRepository: true, + showOnlyAffected: false, + changesFromParents: false, + diffLocation: "editor", + columnOrder: ["author", "date", "hash"], + columnWidths: {}, + linearizeMerges: false, }; /** Options that change which commits the backend returns (a reload is needed). */ @@ -61,7 +81,21 @@ export function parseLogOptions(raw: unknown): LogOptions { const o = raw && typeof raw === "object" ? (raw as Record) : {}; const out = { ...DEFAULT_LOG_OPTIONS }; for (const k of Object.keys(out) as Array) { - if (k === "sort") out.sort = o.sort === "topo" ? "topo" : o.sort === "date" ? "date" : DEFAULT_LOG_OPTIONS.sort; + if (k === "sort") + out.sort = o.sort === "standard" || o.sort === "topo" || o.sort === "date" ? o.sort : DEFAULT_LOG_OPTIONS.sort; + else if (k === "diffLocation") + out.diffLocation = o.diffLocation === "right" || o.diffLocation === "bottom" ? o.diffLocation : "editor"; + else if (k === "columnOrder") + out.columnOrder = Array.isArray(o.columnOrder) + ? [...new Set(o.columnOrder.filter((v): v is string => typeof v === "string"))] + : [...DEFAULT_LOG_OPTIONS.columnOrder]; + else if (k === "columnWidths" && o.columnWidths && typeof o.columnWidths === "object") + out.columnWidths = Object.fromEntries( + Object.entries(o.columnWidths).filter( + (entry): entry is [string, number] => + typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] >= 40 && entry[1] <= 800, + ), + ); else if (typeof o[k] === "boolean") (out as Record)[k] = o[k]; } return out; @@ -76,7 +110,13 @@ function load(): LogOptions { } } -export const useLogOptions = create(() => load()); +export const rootLogOptions = create(() => load()); +export const useLogOptions = Object.assign(function useScopedLogOptions( + selector?: (state: LogOptions) => T, +): T { + const scoped = useContext(LogOptionsContext); + return rootLogOptions((state) => (selector ? selector(scoped ?? state) : ((scoped ?? state) as T))); +}, rootLogOptions); // Settings › Version Control › Log edits them from the Settings window too. if (typeof window !== "undefined") diff --git a/src/features/vcs/logPaneActions.ts b/src/features/vcs/logPaneActions.ts index 95393948..093fdaa4 100644 --- a/src/features/vcs/logPaneActions.ts +++ b/src/features/vcs/logPaneActions.ts @@ -21,6 +21,7 @@ import { logOptions } from "./logOptions"; import { goToRef, updateLogOptions } from "./logStore"; const VCS = "vcsAvailable"; +const LOG = "vcsAvailable || projectOpen || vcsLogFocus"; /** A ref selected in the Branches pane. */ export interface BranchTarget { @@ -62,7 +63,7 @@ export function registerLogPaneActions(): () => void { id: "vcs.log.showBranches", title: "Show Branches", category: "Git Log", - when: VCS, + when: LOG, toggled: () => logOptions().showBranches, run: () => { updateLogOptions({ showBranches: !logOptions().showBranches }); @@ -74,7 +75,7 @@ export function registerLogPaneActions(): () => void { title: "Collapse Linear Branches", category: "Git Log", icon: "collapse-all", - when: VCS, + when: LOG, run: () => updateLogOptions({ collapseLinear: true }), }, { @@ -82,7 +83,7 @@ export function registerLogPaneActions(): () => void { title: "Expand Linear Branches", category: "Git Log", icon: "expand-all", - when: VCS, + when: LOG, run: () => updateLogOptions({ collapseLinear: false }), }, @@ -135,12 +136,11 @@ export function registerLogPaneActions(): () => void { title: "Navigate Log to Selected Branch", category: "Git Log", icon: "locate", - when: VCS, + when: LOG, hidden: true, run: async ({ arg }) => { const t = branchArg(arg); - if (t?.repo) await focusRepo(t.repo); - if (t && !(await goToRef(t.ref))) + if (t && !(await goToRef(t.ref, t.repo))) notifications().notify({ group: "vcs", level: "warning", title: `'${t.ref}' was not found in the log` }); }, }, @@ -151,7 +151,7 @@ export function registerLogPaneActions(): () => void { title: "Show Diff", category: "Git Log", icon: "diff", - when: VCS, + when: LOG, hidden: true, run: ({ arg }) => void showChangesDiff(changesArg(arg)), }, @@ -159,7 +159,7 @@ export function registerLogPaneActions(): () => void { id: "vcs.log.changes.compareWithLocal", title: "Compare with Local", category: "Git Log", - when: VCS, + when: LOG, hidden: true, run: ({ arg }) => { const c = changesArg(arg)[0]; @@ -170,7 +170,7 @@ export function registerLogPaneActions(): () => void { id: "vcs.log.changes.compareBeforeWithLocal", title: "Compare Before with Local", category: "Git Log", - when: VCS, + when: LOG, hidden: true, run: ({ arg }) => { const c = changesArg(arg)[0]; @@ -182,7 +182,7 @@ export function registerLogPaneActions(): () => void { title: "Jump to Source", category: "Git Log", icon: "edit", - when: VCS, + when: LOG, hidden: true, run: ({ arg }) => { for (const c of changesArg(arg)) if (c.after) void openChangeSource(c); @@ -209,7 +209,7 @@ export function registerLogPaneActions(): () => void { id: "vcs.log.changes.groupByDirectory", title: "Group by Directory", category: "Git Log", - when: VCS, + when: LOG, toggled: () => logOptions().groupByDirectory, run: () => updateLogOptions({ groupByDirectory: !logOptions().groupByDirectory }), }, @@ -218,7 +218,7 @@ export function registerLogPaneActions(): () => void { title: "Show Diff Preview", category: "Git Log", description: "Selecting a changed file shows its diff in the editor's preview tab", - when: VCS, + when: LOG, toggled: () => logOptions().diffPreview, run: () => updateLogOptions({ diffPreview: !logOptions().diffPreview }), }, diff --git a/src/features/vcs/logPresentation.ts b/src/features/vcs/logPresentation.ts new file mode 100644 index 00000000..b9d82228 --- /dev/null +++ b/src/features/vcs/logPresentation.ts @@ -0,0 +1,30 @@ +import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges } from "./graph"; +import { commitKey, graphCommit, type RepoCommit } from "./multiRoot"; +import type { LogOptions } from "./logOptions"; + +/** The same displayed adjacency drives painting, navigation and fragment actions. */ +export function presentLogGraph( + all: readonly RepoCommit[], + options: LogOptions, + fragments: { expanded: ReadonlySet; collapsed: ReadonlySet; merges: ReadonlySet }, + keep: ReadonlySet, +) { + const source = all.map(graphCommit); + const collapsed = + options.collapseLinear || fragments.collapsed.size + ? collapseLinear( + source, + new Set([...keep, ...fragments.expanded]), + options.collapseLinear ? undefined : fragments.collapsed, + ) + : null; + const expanded = fragments.merges.has("*") + ? new Set(source.filter((c) => !fragments.merges.has(c.id)).map((c) => c.id)) + : fragments.merges; + const linear = options.linearizeMerges ? linearizeMerges(collapsed?.graph ?? source, expanded) : null; + const graph = linear?.graph ?? collapsed?.graph ?? source; + const byId = new Map(all.map((c) => [commitKey(c), c])); + const commits = graph.map((c) => byId.get(c.id)!); + const laid = computeGraph(graph).rows; + return { commits, graph, rows: options.longEdges ? laid : hideLongEdges(laid), linear, collapsed }; +} diff --git a/src/features/vcs/logSearch.ts b/src/features/vcs/logSearch.ts new file mode 100644 index 00000000..f5869fff --- /dev/null +++ b/src/features/vcs/logSearch.ts @@ -0,0 +1,44 @@ +import type { Commit } from "@/bindings"; +import type { LogOptions } from "./logOptions"; +import { commitRepo } from "./multiRoot"; +import { formatTime } from "./model"; + +export function visibleCommitText(c: Commit, options: LogOptions): string[] { + return [ + c.summary, + c.refs.join(" "), + ...(options.showRepository ? [commitRepo(c) ?? ""] : []), + ...(options.showAuthor ? [c.author] : []), + ...(options.showDate ? [formatTime(options.commitTimestamp ? (c.commitTimeMs ?? c.timeMs) : c.timeMs)] : []), + ...(options.showHash ? [c.shortId] : []), + ]; +} + +/** Case-insensitive word/subsequence matching; columns never form an artificial word. */ +export function speedMatches(query: string, columns: readonly string[]): boolean { + return query + .toLocaleLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((word) => + columns.some((column) => { + let next = 0; + for (const c of column.toLocaleLowerCase()) if (c === word[next]) next++; + return next === word.length; + }), + ); +} + +export function nextSpeedMatch( + commits: readonly Commit[], + query: string, + options: LogOptions, + from: number, + direction = 1, +) { + for (let step = 1; step <= commits.length; step++) { + const i = (from + direction * step + commits.length * 2) % commits.length; + if (speedMatches(query, visibleCommitText(commits[i]!, options))) return i; + } + return -1; +} diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index b226fa80..1cb26a4e 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -1,13 +1,21 @@ /** Git log tool window state: filters, paged commits, graph, selection. */ import { branchesByRepo, commitKey, commitRepo, graphCommit, mergeRepoLogs, type RepoCommit } from "./multiRoot"; import { create } from "zustand"; -import type { Branch, BranchInfo, Commit } from "@/bindings"; -import { call, commands } from "@/platform/ipc"; +import { useContext } from "react"; +import { LogIdContext } from "./logContext"; +import { setContext } from "@/platform/context"; +import { maybeEditorService } from "@/workbench/contributions"; +import type { Branch, BranchInfo, Commit, LogQuery } from "@/bindings"; +import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; -import { collapseLinear, computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph"; -import { logOptions, setLogOptions, type LogOptions } from "./logOptions"; +import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph"; +import { logOptions, parseLogOptions, setLogOptions, useLogOptions, type LogOptions } from "./logOptions"; import { clearConsole, errorMessage } from "./ops"; import { currentRepo, currentRepoRoot, repoName, repoOf, useVcs } from "./store"; +import { chooseLogCommit } from "./CommitChooser"; +import { presentLogGraph } from "./logPresentation"; +import { toggleBookmark, updateBookmarks, useBookmarks } from "@/features/search/bookmarks/store"; +import { commitBookmarkPath, parseCommitBookmark } from "./logBookmarks"; import { stripSlash } from "./model"; /** Date filter: commits made (committer time) after / before the given times (epoch ms). */ @@ -32,6 +40,8 @@ export interface LogFilters { matchCase: boolean; /** Absolute path (file or directory). */ path: string | null; + paths?: string[]; + range?: string | null; date: LogDateFilter | null; } @@ -63,15 +73,37 @@ interface LogState { /** The current user (`user.email`, else `user.name`), for View Options › Highlight › My Commits. */ user: string | null; generation: number; + viewsVersion: number; + seek: { repo: string | null; id: string } | null; + bookmarks: Record; + fragments: LogFragments; +} + +interface LogFragments { + expanded: ReadonlySet; + collapsed: ReadonlySet; + merges: ReadonlySet; +} + +const emptyFragments = (): LogFragments => ({ expanded: new Set(), collapsed: new Set(), merges: new Set() }); + +function parseFragments(value: unknown): LogFragments { + const raw = value && typeof value === "object" ? (value as Record) : {}; + const ids = (value: unknown) => + new Set((Array.isArray(value) ? (value as unknown[]) : []).filter((id): id is string => typeof id === "string")); + return { expanded: ids(raw.expanded), collapsed: ids(raw.collapsed), merges: ids(raw.merges) }; } /** A log tab of its own (IDEA's "History: file" / "Log: branch" tabs): its own filters and view. */ export interface LogTab { id: string; - kind: "history" | "branch"; + kind: "history" | "branch" | "log" | "external"; /** What it shows (the file path or the branch); opening the same again selects the tab. */ target: string; repo?: string | null; + repositories?: string[]; + location?: "toolWindow" | "editor"; + readOnly?: boolean; } export const PAGE = 300; @@ -94,7 +126,16 @@ export function toggleRepository(selected: readonly string[] | undefined, repo: /** Any filter other than the text field's toggles is set. */ export function hasFilters(f: LogFilters): boolean { - return !!(f.repositories?.length || f.branches.length || f.authors.length || f.text || f.path || f.date); + return !!( + f.repositories?.length || + f.branches.length || + f.authors.length || + f.text || + f.path || + f.paths?.length || + f.range || + f.date + ); } /** `ME` replaced by the current user's email (or name); duplicates and blanks dropped. */ @@ -134,8 +175,9 @@ function currentUser(): Promise { return me.value; } -const initialLog = (): Omit => ({ +const initialLog = (): Omit => ({ filters: emptyFilters(), + seek: null, commits: [], rows: [], graph: emptyGraphState(), @@ -152,9 +194,15 @@ const initialLog = (): Omit => ({ branchScope: null, revealTick: 0, user: null, + bookmarks: {}, + fragments: emptyFragments(), }); -export const useGitLog = create(() => ({ ...initialLog(), generation: 0 })); +export const useRootGitLog = create(() => ({ ...initialLog(), generation: 0, viewsVersion: 0 })); +export const useGitLog = Object.assign(function useScopedGitLog(selector: (state: LogState) => T): T { + const id = useContext(LogIdContext); + return useRootGitLog((state) => selector(id && state.shownLog !== id ? { ...state, ...parked.get(id) } : state)); +}, useRootGitLog); /** * The refs the log shows: the remembered ones that still exist, else `HEAD` (the @@ -208,41 +256,129 @@ export function initLogBranchFilter(): boolean { /** The Branch filter's setter: a choice in the main Log is remembered for the repository. */ export function setLogBranches(branches: readonly string[]) { + useGitLog.setState({ seek: null }); + const ranges = branches.filter((b) => b.includes("..")); const s = useGitLog.getState(); + rememberRecentFilters(s.filters); const repo = s.shownLog === "log" ? currentRepoRoot() : null; useGitLog.setState({ - filters: { ...s.filters, branches: [...branches] }, + filters: { + ...s.filters, + branches: branches.filter((b) => !b.includes("..")), + range: ranges.length ? ranges.join("\n") : null, + }, ...(repo ? { branchScope: repo } : {}), }); if (repo) rememberLogBranches(repo, branches); void reloadLog(); } -let loadPromise: Promise | null = null; +const loadPromises = new Map>(); +const pageRequests = new Map>(); +let generationSequence = 0; +const nextGeneration = () => (generationSequence = Math.max(generationSequence, useGitLog.getState().generation) + 1); + +function cancelLogRequests(id?: string) { + for (const [view, requests] of pageRequests) { + if (id && view !== id) continue; + for (const request of requests) void commands.cancelRequest(request).catch(() => undefined); + requests.clear(); + loadPromises.delete(view); + } +} /** What a log tab shows: the store fields swapped in and out when another log tab is selected. */ type LogView = Pick< LogState, - "filters" | "commits" | "rows" | "graph" | "loading" | "done" | "error" | "selected" | "selection" | "revealTick" ->; + | "filters" + | "seek" + | "commits" + | "rows" + | "graph" + | "loading" + | "done" + | "error" + | "selected" + | "selection" + | "revealTick" + | "fragments" + | "generation" +> & { options: LogOptions }; function freshView(filters: LogFilters): LogView { - const { commits, rows, graph, loading, done, error, selected, selection, revealTick } = initialLog(); - return { filters, commits, rows, graph, loading, done, error, selected, selection, revealTick }; + const { commits, rows, graph, loading, done, error, selected, selection, revealTick, fragments } = initialLog(); + return { + filters, + seek: null, + commits, + rows, + graph, + loading, + done, + error, + selected, + selection, + revealTick, + fragments, + options: logOptions(), + generation: nextGeneration(), + }; } /** Views of the log tabs not shown now. */ const parked = new Map(); function currentView(): LogView { - const { filters, commits, rows, graph, done, error, selected, selection, revealTick } = useGitLog.getState(); - // A page still loading is dropped (the generation changes): loaded again when shown. - return { filters, commits, rows, graph, loading: false, done, error, selected, selection, revealTick }; + const { + filters, + commits, + rows, + graph, + done, + error, + selected, + selection, + revealTick, + fragments, + loading, + generation, + seek, + } = useGitLog.getState(); + return { + filters, + commits, + rows, + graph, + loading, + seek, + generation, + done, + error, + selected, + selection, + revealTick, + fragments, + options: logOptions(), + }; } export const isLogTab = (id: string, tabs: readonly LogTab[] = useGitLog.getState().logTabs) => id === "log" || tabs.some((t) => t.id === id); +export function logState(id = useGitLog.getState().shownLog): LogState { + const state = useGitLog.getState(); + return id === state.shownLog ? state : { ...state, ...parked.get(id), shownLog: id }; +} + +function setViewState(id: string, patch: Partial | ((state: LogState) => Partial)) { + const update = typeof patch === "function" ? patch(logState(id)) : patch; + if (id === useGitLog.getState().shownLog) useGitLog.setState(update); + else if (parked.has(id)) { + parked.set(id, { ...parked.get(id)!, ...update }); + useGitLog.setState((s) => ({ viewsVersion: s.viewsVersion + 1 })); + } +} + /** Puts the view of log tab `id` into the store, parking the one shown. */ function showLogView(id: string) { const s = useGitLog.getState(); @@ -250,11 +386,21 @@ function showLogView(id: string) { parked.set(s.shownLog, currentView()); const view = parked.get(id) ?? freshView(emptyFilters()); parked.delete(id); - loadPromise = null; - useGitLog.setState({ ...view, shownLog: id, generation: s.generation + 1 }); + const { options, ...state } = view; + setLogOptions(options); + useGitLog.setState({ ...state, shownLog: id }); if (!view.commits.length) void reloadLog(); } +export const focusLogView = (id: string) => showLogView(id); +export const parkedLogOptions = (id: string): LogOptions | undefined => parked.get(id)?.options; + +export function ensureEditorLog(id: string, label: string) { + if (useGitLog.getState().logTabs.some((t) => t.id === id)) return; + parked.set(id, freshView(emptyFilters())); + useGitLog.setState((s) => ({ logTabs: [...s.logTabs, { id, kind: "log", target: label, location: "editor" }] })); +} + /** Selects a content tab of the Git tool window; a log tab brings its own filters and commits. */ export function selectGitTab(id: string) { if (isLogTab(id)) showLogView(id); @@ -278,18 +424,68 @@ function historyRepo(target: string): string | null { * Opens (or selects) a log tab of its own, leaving the main Log and its filters as * they are: Show History for a file, Show Git Log for a branch. */ -export function openLogTab(kind: LogTab["kind"], target: string): string { - const repo = kind === "history" ? historyRepo(target) : currentRepoRoot(); - const existing = useGitLog.getState().logTabs.find((t) => t.kind === kind && t.target === target && t.repo === repo); +export function openLogTab( + kind: LogTab["kind"], + target: string, + location: LogTab["location"] = "toolWindow", + repositories?: string[], +): string { + const active = useGitLog.getState().logTabs.find((t) => t.id === useGitLog.getState().shownLog); + repositories ??= + (kind === "log" || kind === "branch") && active?.readOnly + ? (active.repositories ?? (active.repo ? [active.repo] : undefined)) + : undefined; + const repo = + kind === "external" + ? target + : kind === "history" + ? (historyRepo(target) ?? + active?.repositories?.find((repo) => target === repo || target.startsWith(`${repo}/`)) ?? + (active?.repo && target.startsWith(`${active.repo}/`) ? active.repo : null)) + : (active?.repo ?? (kind === "log" ? null : currentRepoRoot())); + const existing = + kind !== "log" && + useGitLog + .getState() + .logTabs.find( + (t) => + t.kind === kind && + (kind === "external" && repositories?.length + ? JSON.stringify([...(t.repositories ?? (t.repo ? [t.repo] : []))].sort()) === + JSON.stringify([...repositories].sort()) + : t.target === target && t.repo === repo), + ); if (existing) { selectGitTab(existing.id); return existing.id; } const id = `${kind}:${++tabSeq}`; - const filters = { ...emptyFilters(), ...(kind === "history" ? { path: target } : { branches: [target] }) }; + const filters = + kind === "log" + ? { ...useGitLog.getState().filters } + : { + ...emptyFilters(), + ...(kind === "history" ? { path: target } : kind === "branch" ? { branches: [target] } : {}), + }; parked.set(id, freshView(filters)); - useGitLog.setState((s) => ({ logTabs: [...s.logTabs, { id, kind, target, repo }] })); - selectGitTab(id); + useGitLog.setState((s) => ({ + logTabs: [ + ...s.logTabs, + { + id, + kind, + target, + repo: repositories?.length ? null : repo, + repositories, + location, + readOnly: kind === "external" || active?.readOnly === true, + }, + ], + })); + if (location === "editor" && maybeEditorService()) { + showLogView(id); + void maybeEditorService()!.openInput({ kind: "git-log", key: `git-log:${id}`, data: { id, label: target } }); + } else selectGitTab(id); return id; } @@ -298,6 +494,7 @@ export function closeLogTab(id: string) { const s = useGitLog.getState(); if (!s.logTabs.some((t) => t.id === id)) return; if (s.shownLog === id) showLogView("log"); + cancelLogRequests(id); parked.delete(id); useGitLog.setState((l) => ({ logTabs: l.logTabs.filter((t) => t.id !== id), @@ -310,48 +507,279 @@ export function closeLogTab(id: string) { * and console. Repository selection does not change the log or its history tabs. */ export function installGitLog(): () => void { - return useVcs.subscribe((s, prev) => { + const trackFocus = (event: FocusEvent) => + setContext("vcsLogFocus", event.target instanceof Element && !!event.target.closest("[data-git-log]")); + document.addEventListener("focusin", trackFocus); + restoreLogSession(useVcs.getState().root); + const unsubscribeLog = useGitLog.subscribe(() => saveLogSession(useVcs.getState().root)); + const unsubscribeOptions = useLogOptions.subscribe(() => saveLogSession(useVcs.getState().root)); + const migratedBookmarks = new Set(); + const syncBookmarks = () => { + const root = useVcs.getState().root; + if (useBookmarks.getState().scope !== root) return; + if (root && !migratedBookmarks.has(root)) { + migratedBookmarks.add(root); + for (const [key, text] of Object.entries(useGitLog.getState().bookmarks)) { + const split = key.lastIndexOf("\0"); + const repo = split < 0 ? root : key.slice(0, split), + id = key.slice(split + 1); + const path = `git-log:${encodeURIComponent(repo)}#${id}`; + if (!useBookmarks.getState().items.some((b) => b.path === path)) + updateBookmarks((items) => toggleBookmark(items, path, 0, text)); + } + } + useGitLog.setState({ + bookmarks: Object.fromEntries( + useBookmarks.getState().items.flatMap((b) => { + const c = parseCommitBookmark(b.path); + return c ? [[commitKey({ repo: c.repo, id: c.id }), b.description || b.text]] : []; + }), + ), + }); + }; + syncBookmarks(); + const unsubscribeBookmarks = useBookmarks.subscribe(syncBookmarks); + const unsubscribeVcs = useVcs.subscribe((s, prev) => { if (s.root !== prev.root) { - loadPromise = null; + saveLogSession(prev.root); + restoring = true; + cancelLogRequests(); + loadPromises.clear(); parked.clear(); useGitLog.setState((l) => ({ ...initialLog(), + bookmarks: {}, tab: isLogTab(l.tab, l.logTabs) ? "log" : l.tab, generation: l.generation + 1, })); clearConsole(); + restoreLogSession(s.root); + syncBookmarks(); + restoring = false; } else if (s.roots !== prev.roots) { - for (const [id, view] of parked) parked.set(id, freshView(view.filters)); + for (const [id, view] of parked) parked.set(id, { ...freshView(view.filters), options: view.options }); void reloadLog(); } }); + return () => { + saveLogSession(useVcs.getState().root); + cancelLogRequests(); + unsubscribeLog(); + unsubscribeOptions(); + unsubscribeBookmarks(); + unsubscribeVcs(); + document.removeEventListener("focusin", trackFocus); + setContext("vcsLogFocus", false); + }; +} + +let restoring = false; +const sessionKey = (root: string) => `openidea.vcs.logSession.v1:${root}`; + +function parseFilters(value: unknown): LogFilters { + if (!value || typeof value !== "object") return emptyFilters(); + const raw = value as Record; + const strings = (v: unknown): string[] => + Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; + const filters = { + ...emptyFilters(), + branches: strings(raw.branches), + authors: strings(raw.authors), + repositories: strings(raw.repositories), + paths: strings(raw.paths), + text: typeof raw.text === "string" ? raw.text : "", + path: typeof raw.path === "string" ? raw.path : null, + range: typeof raw.range === "string" ? raw.range : null, + regex: raw.regex === true, + matchCase: raw.matchCase === true, + }; + if (raw.date && typeof raw.date === "object") { + const date = raw.date as Record; + if ( + (date.after == null || (typeof date.after === "number" && Number.isFinite(date.after))) && + (date.before == null || (typeof date.before === "number" && Number.isFinite(date.before))) + ) + filters.date = { + after: (date.after as number | null) ?? null, + before: (date.before as number | null) ?? null, + label: typeof date.label === "string" ? date.label : "", + }; + } + return filters; +} + +function saveLogSession(root: string | null) { + if (!root || restoring) return; + const state = useGitLog.getState(); + const views = new Map(parked).set(state.shownLog, currentView()); + try { + localStorage.setItem( + sessionKey(root), + JSON.stringify({ + version: 1, + active: state.shownLog, + tabs: state.logTabs, + bookmarks: state.bookmarks, + views: [...views].map(([id, view]) => ({ + id, + filters: view.filters, + options: view.options, + fragments: Object.fromEntries(Object.entries(view.fragments).map(([key, ids]) => [key, [...ids]])), + selected: view.selected, + selection: view.selection, + })), + }), + ); + } catch { + /* A full or unavailable store leaves this session usable. */ + } +} + +function restoreLogSession(root: string | null) { + if (!root) return; + restoring = true; + try { + const raw: unknown = JSON.parse(localStorage.getItem(sessionKey(root)) ?? "null"); + if (!raw || typeof raw !== "object" || !("version" in raw) || raw.version !== 1) return; + const value = raw as { tabs?: unknown; views?: unknown; active?: unknown; bookmarks?: unknown }; + const tabs = Array.isArray(value.tabs) + ? value.tabs.filter( + (tab): tab is LogTab => + !!tab && + typeof tab === "object" && + typeof tab.id === "string" && + typeof tab.target === "string" && + ["history", "branch", "log", "external"].includes(tab.kind) && + (tab.repo == null || typeof tab.repo === "string"), + ) + : []; + for (const tab of tabs) tabSeq = Math.max(tabSeq, Number(tab.id.split(":").at(-1)) || 0); + const ids = new Set(["log", ...tabs.map((t) => t.id)]); + if (Array.isArray(value.views)) + for (const view of value.views) { + if (view && typeof view === "object" && typeof view.id === "string" && ids.has(view.id)) + parked.set(view.id, { + ...freshView(parseFilters(view.filters)), + options: parseLogOptions(view.options), + fragments: parseFragments(view.fragments), + selected: typeof view.selected === "string" ? view.selected : null, + selection: Array.isArray(view.selection) + ? (view.selection as unknown[]).filter((id): id is string => typeof id === "string") + : [], + }); + } + const id = typeof value.active === "string" && ids.has(value.active) ? value.active : "log"; + const view = parked.get(id) ?? freshView(emptyFilters()); + parked.delete(id); + const { options, ...state } = view; + setLogOptions(options); + const bookmarks = + value.bookmarks && typeof value.bookmarks === "object" + ? Object.fromEntries( + Object.entries(value.bookmarks).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ) + : {}; + useGitLog.setState({ ...state, tab: id, shownLog: id, logTabs: tabs, branchScope: root, bookmarks }); + } catch { + /* Invalid or newer state starts with a fresh log. */ + } finally { + restoring = false; + } +} + +export function toggleCommitBookmark(commit: Commit) { + updateBookmarks((items) => toggleBookmark(items, commitBookmarkPath(commit), 0, commit.summary)); + useGitLog.setState((state) => { + const bookmarks = { ...state.bookmarks }; + const key = commitKey(commit); + if (useBookmarks.getState().items.some((b) => b.path === commitBookmarkPath(commit))) + bookmarks[key] = commit.summary; + else delete bookmarks[key]; + return { bookmarks }; + }); +} + +export function recentFilterValues(name: K): LogFilters[K][] { + try { + return JSON.parse( + localStorage.getItem(`openidea.vcs.logRecent.v1:${useVcs.getState().root}:${name}`) ?? "[]", + ) as LogFilters[K][]; + } catch { + return []; + } +} + +export function recentLogFilters(): LogFilters[] { + try { + const raw: unknown = JSON.parse( + localStorage.getItem(`openidea.vcs.logFilters.v1:${useVcs.getState().root}`) ?? "[]", + ); + return Array.isArray(raw) ? raw.slice(0, 10).map(parseFilters) : []; + } catch { + return []; + } +} + +function rememberRecentFilters(before: LogFilters) { + for (const name of Object.keys(before) as Array) { + const value = before[name]; + if (value == null || value === "" || (Array.isArray(value) && !value.length)) continue; + try { + localStorage.setItem( + `openidea.vcs.logRecent.v1:${useVcs.getState().root}:${name}`, + JSON.stringify( + [value, ...recentFilterValues(name).filter((v) => JSON.stringify(v) !== JSON.stringify(value))].slice(0, 10), + ), + ); + } catch { + /* Storage can be unavailable. */ + } + } + if (hasFilters(before)) { + try { + const recent = [before, ...recentLogFilters().filter((f) => JSON.stringify(f) !== JSON.stringify(before))].slice( + 0, + 10, + ); + localStorage.setItem(`openidea.vcs.logFilters.v1:${useVcs.getState().root}`, JSON.stringify(recent)); + } catch { + /* Recent filters are optional session state. */ + } + } } export function setLogFilters(patch: Partial) { - useGitLog.setState((s) => ({ filters: { ...s.filters, ...patch } })); + useGitLog.setState({ seek: null }); + rememberRecentFilters(useGitLog.getState().filters); + useGitLog.setState((s) => ({ filters: { ...s.filters, ...("branches" in patch ? { range: null } : {}), ...patch } })); // History of a file in another repository: that repository's log. void reloadLog(); } /** Reloads history, optionally keeping the current view until its replacement arrives. */ -export function reloadLog({ preserve = false }: { preserve?: boolean } = {}): Promise { - useGitLog.setState((s) => ({ +export function reloadLog( + { preserve = false }: { preserve?: boolean } = {}, + id = useGitLog.getState().shownLog, +): Promise { + cancelLogRequests(id); + const next = nextGeneration(); + setViewState(id, () => ({ ...(preserve ? {} : { commits: [], rows: [], graph: emptyGraphState(), done: false }), loading: true, error: null, - generation: s.generation + 1, + generation: next, })); - loadPromise = null; - void loadOutgoing(); - const generation = useGitLog.getState().generation; + loadPromises.delete(id); + void loadOutgoing(id); + const generation = logState(id).generation; void currentUser().then((user) => { - if (useGitLog.getState().generation === generation) useGitLog.setState({ user }); + if (logState(id).generation === generation) setViewState(id, { user }); }); - return loadPage(true); + return loadPage(true, id); } -async function loadOutgoing() { - const generation = useGitLog.getState().generation; +async function loadOutgoing(id: string) { + const generation = logState(id).generation; const roots = useVcs.getState().roots.length ? useVcs.getState().roots : [currentRepo()]; const outgoing = new Set(); const tracked = new Set(); @@ -378,7 +806,7 @@ async function loadOutgoing() { } }), ); - if (useGitLog.getState().generation === generation) useGitLog.setState({ outgoing, tracked }); + if (logState(id).generation === generation) setViewState(id, { outgoing, tracked }); } /** The commit is on the upstream of the current branch (rewriting it needs a force push). */ @@ -388,38 +816,60 @@ export function isPushed(id: string): boolean { return !!c && tracked.has(commitRepo(c) ?? "") && !!outgoing && !outgoing.has(id); } -export function loadMore(): Promise { - return loadPage(false); +export function loadMore(id = useGitLog.getState().shownLog): Promise { + return loadPage(false, id); } -function loadPage(replace: boolean): Promise { - const s = useGitLog.getState(); - if (!replace && s.done) return Promise.resolve(); - if (loadPromise) return loadPromise; +export function loadNewer(id = useGitLog.getState().shownLog): Promise { + return loadPage(false, id, true); +} + +function loadPage(replace: boolean, id: string, before = false): Promise { + const s = logState(id); + if (!replace && !before && s.done) return Promise.resolve(); + const existing = loadPromises.get(id); + if (existing) return existing; const gen = s.generation; const f = s.filters; // Settings › Version Control › Limit history to N rows (file / folder history). const vcs = workflowSettings().vcs; - const cap = f.path && vcs.limitHistory ? vcs.historyLimit : Infinity; + const cap = (f.path || f.paths?.length) && vcs.limitHistory ? vcs.historyLimit : Infinity; const loaded = replace ? 0 : s.commits.length; if (loaded >= cap) { - useGitLog.setState({ done: true, loading: false }); + setViewState(id, { done: true, loading: false }); return Promise.resolve(); } - useGitLog.setState({ loading: true }); + setViewState(id, { loading: true }); const p = (async () => { try { - const o = logOptions(); + const o = id === useGitLog.getState().shownLog ? logOptions() : parked.get(id)!.options; const tab = s.logTabs.find((t) => t.id === s.shownLog); - const roots = tab?.repo - ? [tab.repo] - : f.path - ? [repoOf(f.path)] - : f.repositories?.length - ? f.repositories - : useVcs.getState().roots.length - ? useVcs.getState().roots - : [currentRepo()]; + if (tab?.readOnly) + for (const repo of tab.repositories ?? (tab.repo ? [tab.repo] : [])) + await call(commands.vcsOpenExternalLog(repo)); + const tabRoots = tab?.repositories ?? (tab?.repo ? [tab.repo] : []); + const ownerOf = (path: string) => + tabRoots + .filter((repo) => path === repo || path.startsWith(`${repo}/`)) + .sort((a, b) => b.length - a.length)[0] ?? repoOf(path); + const pathRoots = f.path ? [ownerOf(f.path)] : f.paths?.length ? [...new Set(f.paths.map(ownerOf))] : null; + const roots = ( + tab?.repositories?.length + ? tab.repositories + : tab?.repo + ? [tab.repo] + : pathRoots + ? pathRoots + : f.repositories?.length + ? f.repositories + : useVcs.getState().roots.length + ? useVcs.getState().roots + : [currentRepo()] + ).filter( + (repo) => + (!pathRoots || pathRoots.includes(repo)) && + (!f.repositories?.length || (!!repo && f.repositories.includes(repo))), + ); const limit = Math.min(replace ? Math.max(PAGE, s.commits.length) : PAGE, cap - loaded); const refs = await branchesByRepo(roots.filter((r): r is string => !!r)); const knownRefs = new Set( @@ -427,6 +877,7 @@ function loadPage(replace: boolean): Promise { .flat() .map((b) => b.name), ); + let joined = false; const results = await Promise.allSettled( roots.map(async (repo) => { const branches = f.branches.filter( @@ -434,68 +885,163 @@ function loadPage(replace: boolean): Promise { ); if (f.branches.length && !branches.length) return []; const config = (key: string) => call(commands.vcsConfigGet(repo, key)).catch(() => null); - const user = (await config("user.email")) || (await config("user.name")); - const ownAuthors = resolveAuthors(f.authors, user); + const [userEmail, userName] = await Promise.all([config("user.email"), config("user.name")]); + const user = userEmail || userName; + const ownAuthors = [ + ...new Set([...resolveAuthors(f.authors, user), ...(f.authors.includes(ME) && userName ? [userName] : [])]), + ]; if (f.authors.length && !ownAuthors.length) return []; - const page = await call( - commands.vcsLog(repo, { + if (logState(id).generation !== gen) return []; + const previous = s.commits.filter((c) => commitRepo(c) === repo); + const boundary = + replace && + roots.length === 1 && + previous.length && + branches.length === 1 && + branches[0] === "HEAD" && + !f.text && + !f.authors.length && + !f.path && + !f.paths?.length && + !f.range && + !f.date && + !o.noMerges && + !o.firstParent + ? previous[0]!.id + : null; + const firstPosition = previous[0]?.logPosition ?? 0; + if (before && !firstPosition) return []; + const fetchPage = (stopAt: string | null) => { + const request = nextRequestId(); + const requests = pageRequests.get(id) ?? new Set(); + pageRequests.set(id, requests); + requests.add(request); + const query: LogQuery = { branch: null, branches, authors: ownAuthors, + authorsExact: true, path: f.path, + paths: f.paths?.filter((path) => ownerOf(path) === repo), + follow: tab?.kind === "history", author: null, text: f.text.trim() || null, textRegex: f.regex, textMatchCase: f.matchCase, range: null, - skip: replace ? 0 : s.commits.filter((c) => commitRepo(c) === repo).length, - limit, + ranges: f.range?.split(/\s+/).filter(Boolean) ?? [], + request, + summaryOnly: true, + stopAt, + retainUntil: replace ? (previous.at(-1)?.id ?? null) : null, + skip: before + ? Math.max(0, firstPosition - PAGE) + : replace + ? 0 + : previous.at(-1)?.logPosition != null + ? previous.at(-1)!.logPosition! + 1 + : previous.length, + seek: replace ? (s.seek?.repo === repo ? s.seek.id : null) : null, + limit: before ? Math.min(PAGE, firstPosition) : limit, order: o.sort, firstParent: o.firstParent, noMerges: o.noMerges, - // The date filter runs in the backend so the graph stays connected. sinceMs: f.date?.after ?? null, untilMs: f.date?.before ?? null, - }), - ); + }; + return call(commands.vcsLog(repo, query)).finally(() => requests.delete(request)); + }; + let page: RepoCommit[] = await fetchPage(boundary); + if (boundary && page.at(-1)?.id === boundary) { + const linear = page + .slice(0, -1) + .every((c, i) => c.parents.length === 1 && c.parents[0] === page[i + 1]!.id); + if (linear) { + const decorations = await call(commands.vcsLogRefs(repo)).catch(() => null); + if (decorations) { + const labels = new Map(); + for (const [name, id] of Object.entries(decorations)) + if (id) labels.set(id, [...(labels.get(id) ?? []), name]); + const old = previous.map((c) => { + const refs = labels.get(c.id) ?? []; + return refs.length === c.refs.length && refs.every((ref) => c.refs.includes(ref)) + ? c + : { ...c, refs }; + }); + page = [...page.slice(0, -1), ...old]; + joined = true; + } else page = await fetchPage(null); + } else page = await fetchPage(null); + } return page.map((c) => ({ ...c, repo, user, + userName, + readOnly: tab?.readOnly === true, remoteNames: repo ? refs[repo]?.filter((b) => b.remote).map((b) => b.name) : undefined, })); }), ); - const streams = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); + const streams = results.map((r, i) => + r.status === "fulfilled" ? r.value : replace ? s.commits.filter((c) => commitRepo(c) === roots[i]) : [], + ); const failures = results.flatMap((r, i) => r.status === "rejected" ? [`${roots[i] ? repoName(roots[i]!) : "Git"}: ${errorMessage(r.reason)}`] : [], ); - const page = mergeRepoLogs(streams, limit); - const cur = useGitLog.getState(); + const currentStreams = before ? roots.map((repo) => s.commits.filter((c) => commitRepo(c) === repo)) : []; + const page = mergeRepoLogs( + before ? streams.map((stream, i) => [...stream, ...(currentStreams[i] ?? [])]) : streams, + before || joined || s.seek || (replace && s.commits.length) ? Number.MAX_SAFE_INTEGER : limit, + ); + const cur = logState(id); if (cur.generation !== gen) return; - if (replace && failures.length === results.length && cur.commits.length) { - useGitLog.setState({ error: failures.join("\n") }); + if (replace && results.length > 0 && failures.length === results.length && cur.commits.length) { + setViewState(id, { error: failures.join("\n") }); return; } - const { rows, state } = computeGraph(page.map(graphCommit), replace ? emptyGraphState() : cur.graph); - useGitLog.setState({ - commits: replace ? page : cur.commits.concat(page), - rows: replace ? rows : cur.rows.concat(rows), + const commits = + replace || before + ? page + : mergeRepoLogs( + roots.map((repo) => [ + ...cur.commits.filter((c) => commitRepo(c) === repo), + ...page.filter((c) => commitRepo(c) === repo), + ]), + Number.MAX_SAFE_INTEGER, + ); + const { rows, state } = computeGraph(commits.map(graphCommit)); + setViewState(id, { + commits, + rows, graph: state, - done: page.length < limit || loaded + page.length >= cap, + done: before + ? cur.done + : commits.length >= cap + ? true + : page.length && page.every((c) => c.logTotal != null) + ? roots.every((repo, i) => { + const last = commits.findLast((c) => commitRepo(c) === repo); + return ( + !streams[i]?.length || (last != null && last.logPosition! + 1 >= streams[i]!.at(-1)!.logTotal!) + ); + }) + : joined + ? s.done + : page.length < limit || loaded + page.length >= cap, error: failures.join("\n") || null, }); } catch (e) { - if (useGitLog.getState().generation === gen) - useGitLog.setState({ error: errorMessage(e), done: replace && s.commits.length ? s.done : true }); + if (logState(id).generation === gen) + setViewState(id, { error: errorMessage(e), done: replace && s.commits.length ? s.done : true }); } finally { - if (useGitLog.getState().generation === gen) { - useGitLog.setState({ loading: false }); - loadPromise = null; + if (logState(id).generation === gen) { + setViewState(id, { loading: false }); + loadPromises.delete(id); } } })(); - loadPromise = p; + loadPromises.set(id, p); return p; } @@ -519,8 +1065,7 @@ export function selectCommit(id: string | null, reveal = false) { /** Cmd/Ctrl-click: toggles `id` in the selection. */ export function toggleCommitSelection(id: string) { useGitLog.setState((s) => { - const repo = commitRepo(s.commits.find((c) => commitKey(c) === id)!); - const existing = s.selection.filter((key) => commitRepo(s.commits.find((c) => commitKey(c) === key)!) === repo); + const existing = s.selection; const on = existing.includes(id); const selection = on ? existing.filter((x) => x !== id) : [...existing, id]; return { selection, selected: on ? (selection[selection.length - 1] ?? null) : id }; @@ -535,10 +1080,7 @@ export function extendCommitSelection(id: string, visible: readonly Commit[]) { const b = visible.findIndex((c) => commitKey(c) === id); if (a < 0 || b < 0) return { selected: id, selection: [id] }; const [from, to] = a <= b ? [a, b] : [b, a]; - const range = visible - .slice(from, to + 1) - .filter((c) => commitRepo(c) === commitRepo(visible[b]!)) - .map(commitKey); + const range = visible.slice(from, to + 1).map(commitKey); // Keep the anchor first so further extensions pivot on it. return { selected: id, @@ -554,11 +1096,20 @@ export function selectedCommits(): Commit[] { return commits.filter((c) => ids.has(commitKey(c))); } -const MAX_SEARCH_PAGES = 30; +/** Stop on completion, failure, or a superseding view; depth is never a limit. */ +async function pageUntil(find: () => boolean) { + const generation = useGitLog.getState().generation; + while (!find() && !useGitLog.getState().done && useGitLog.getState().generation === generation) { + const before = useGitLog.getState().commits.length; + await loadMore(); + if (useGitLog.getState().commits.length <= before) break; + } +} /** Selects `id` in the log, clearing filters and paging until it is loaded. */ -export async function revealCommit(id: string, repo = currentRepo()) { - selectGitTab("log"); +export async function revealCommit(id: string, repo = currentRepo(), tab = "log") { + if (useGitLog.getState().logTabs.find((t) => t.id === tab)?.location === "editor") showLogView(tab); + else selectGitTab(tab); const f = useGitLog.getState().filters; const filtered = hasFilters(f); const find = () => @@ -567,36 +1118,81 @@ export async function revealCommit(id: string, repo = currentRepo()) { .commits.find((c) => (!commitRepo(c) || commitRepo(c) === repo) && (c.id === id || c.id.startsWith(id))); if (filtered) { useGitLog.setState({ filters: emptyFilters() }); - await reloadLog(); + const load = reloadLog(); + const generation = useGitLog.getState().generation; + await load; + if (useGitLog.getState().generation !== generation) return; } else if (!useGitLog.getState().commits.length) { - await reloadLog(); + const load = reloadLog(); + const generation = useGitLog.getState().generation; + await load; + if (useGitLog.getState().generation !== generation) return; } - for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore(); + const generation = useGitLog.getState().generation; + await pageUntil(() => !!find()); + if (useGitLog.getState().generation !== generation) return; const hit = find(); if (hit) selectCommit(commitKey(hit), true); } -/** Go to Parent Commit: selects the first parent, paging the log until it is loaded. */ +/** Navigate visible graph parents, offering a choice at a merge. */ +export function displayedLogGraph() { + const s = useGitLog.getState(); + const heads = useVcs.getState().heads; + return presentLogGraph( + s.commits, + logOptions(), + s.fragments, + new Set([ + ...s.selection, + ...Object.keys(s.bookmarks), + ...s.commits + .filter((c) => c.refs.includes("HEAD") || c.id === heads[commitRepo(c) ?? ""]?.headCommit) + .map(commitKey), + ]), + ); +} + export async function goToParent(c: Commit) { - const parent = c.parents[0]; - if (!parent) return; - const find = () => useGitLog.getState().commits.find((x) => x.id === parent && commitRepo(x) === commitRepo(c)); - for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore(); - if (find()) selectCommit(commitKey(find()!), true); + const generation = useGitLog.getState().generation; + const parents = () => displayedLogGraph().graph.find((x) => x.id === commitKey(c))?.parents ?? []; + if (!parents().length) return; + const find = () => displayedLogGraph().commits.filter((x) => parents().includes(commitKey(x))); + await pageUntil(() => find().length === parents().length); + if (useGitLog.getState().generation !== generation) return; + const candidates = find(); + const chosen = candidates.length > 1 ? await chooseLogCommit(candidates, "Select Parent Commit") : candidates[0]; + if (chosen && useGitLog.getState().generation === generation) selectCommit(commitKey(chosen), true); } /** Go to Child Commit: the nearest newer loaded commit having `c` as a parent. */ -export function goToChild(c: Commit) { - const { commits } = useGitLog.getState(); - const index = commits.findIndex((x) => commitKey(x) === commitKey(c)); - for (let i = index - 1; i >= 0; i--) { - if (commitRepo(commits[i]!) === commitRepo(c) && commits[i]!.parents.includes(c.id)) - return selectCommit(commitKey(commits[i]!), true); - } +export async function goToChild(c: Commit) { + const { commits, generation } = useGitLog.getState(); + const children = new Set( + displayedLogGraph() + .graph.filter((x) => x.parents.includes(commitKey(c))) + .map((x) => x.id), + ); + const candidates = commits.filter((x) => children.has(commitKey(x))); + const chosen = candidates.length > 1 ? await chooseLogCommit(candidates, "Select Child Commit") : candidates[0]; + if (chosen && useGitLog.getState().generation === generation) selectCommit(commitKey(chosen), true); } /** Graph / View Options change; options that change the query reload the log. */ export function updateLogOptions(patch: Partial) { + if ("collapseLinear" in patch && logOptions().linearizeMerges) { + useGitLog.setState({ fragments: { ...emptyFragments(), merges: new Set(patch.collapseLinear ? [] : ["*"]) } }); + setLogOptions({ ...patch, collapseLinear: false }); + return; + } + if ("collapseLinear" in patch) + useGitLog.setState({ + fragments: { + ...emptyFragments(), + merges: new Set(patch.collapseLinear ? [] : ["*"]), + }, + }); + else if ("linearizeMerges" in patch) useGitLog.setState({ fragments: emptyFragments() }); if (setLogOptions(patch)) void reloadLog(); } @@ -604,16 +1200,68 @@ export function updateLogOptions(patch: Partial) { export function isMine(c: Commit, user: string | null): boolean { if ("user" in c) user = (c as RepoCommit).user ?? null; if (!user) return false; - const u = user.toLowerCase(); - return c.email.toLowerCase() === u || c.author.toLowerCase() === u; + const normalize = (value: string) => value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, ""); + const name = (c as RepoCommit).userName; + return ( + c.email.toLowerCase() === user.toLowerCase() || + normalize(c.author) === normalize(name ?? user) || + normalize(c.email.split("@")[0]!) === normalize(user.split("@")[0]!) + ); } /** * Go to Hash/Branch/Tag: selects the commit `rev` names, paging the log until it is * loaded (and clearing the filters when they hide it). False when there is no such commit. */ -export async function goToRef(rev: string): Promise { - const repo = currentRepo(); +export async function goToRef(rev: string, repository?: string | null): Promise { + const state = useGitLog.getState(); + let generation = state.generation; + const valid = () => + useGitLog.getState().generation === generation && useGitLog.getState().shownLog === state.shownLog; + const reload = async () => { + const load = reloadLog(); + generation = useGitLog.getState().generation; + await load; + }; + const logTab = state.logTabs.find((t) => t.id === state.shownLog); + const repos = + repository !== undefined + ? [repository] + : logTab?.repositories?.length + ? logTab.repositories + : logTab?.repo + ? [logTab.repo] + : state.filters.repositories?.length + ? state.filters.repositories + : useVcs.getState().roots.length + ? useVcs.getState().roots + : [currentRepo()]; + if (repos.length > 1) { + const candidates = ( + await Promise.all( + repos.map(async (repo) => { + try { + const [commit] = await call( + commands.vcsLog(repo, { branch: rev.trim(), path: null, author: null, text: null, skip: 0, limit: 1 }), + ); + return commit ? [{ ...commit, repo }] : []; + } catch { + return []; + } + }), + ) + ).flat(); + if (!valid()) return false; + const chosen = + candidates.length > 1 + ? await chooseLogCommit( + candidates.map((c) => ({ ...c, summary: `${repoName(c.repo ?? "")} · ${c.summary}` })), + "Select Repository Commit", + ) + : candidates[0]; + return chosen && valid() ? goToRef(rev, commitRepo(chosen)) : false; + } + const repo = repos[0] ?? null; const target = rev.trim(); if (!target) return false; let id: string | undefined; @@ -633,25 +1281,29 @@ export async function goToRef(rev: string): Promise { } catch { id = undefined; } - if (!id) return false; - const tab = useGitLog.getState().tab; - selectGitTab(isLogTab(tab) ? tab : "log"); + if (!id || !valid()) return false; + const tab = useGitLog.getState().shownLog; + showLogView(isLogTab(tab) ? tab : "log"); const find = () => useGitLog.getState().commits.some((c) => c.id === id && (!commitRepo(c) || commitRepo(c) === repo)); - if (!useGitLog.getState().commits.length) await reloadLog(); - for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore(); - if (!find()) await revealCommit(id, repo); - if (!find()) return false; - // A commit inside a collapsed linear branch: expand them (IDEA does the same). - if (logOptions().collapseLinear) { - const commits = useGitLog.getState().commits; - const heads = useVcs.getState().heads; - const headKeys = new Set(commits.filter((c) => c.id === heads[commitRepo(c) ?? ""]?.headCommit).map(commitKey)); - const shown = collapseLinear(commits.map(graphCommit), headKeys).commits; - if (!shown.some((c) => c.id === commitKey({ id: id!, repo }))) { - setLogOptions({ collapseLinear: false }); - } + if (!useGitLog.getState().commits.length) await reload(); + if (!valid()) return false; + if (!find()) { + useGitLog.setState({ seek: { repo, id } }); + await reload(); } + if (!valid()) return false; + if (!find() && hasFilters(useGitLog.getState().filters)) { + useGitLog.setState({ filters: emptyFilters(), seek: { repo, id } }); + await reload(); + if (!valid()) return false; + } + if (!valid()) return false; + if (!find() && !useGitLog.getState().error) { + useGitLog.setState({ filters: { ...emptyFilters(), branches: [id], ...(repo ? { repositories: [repo] } : {}) } }); + await reload(); + } + if (!valid() || !find()) return false; const hit = useGitLog.getState().commits.find((c) => c.id === id && (!commitRepo(c) || commitRepo(c) === repo)); if (!hit) return false; selectCommit(commitKey(hit), true); diff --git a/src/features/vcs/multiRoot.ts b/src/features/vcs/multiRoot.ts index 5057f3b2..6ce6faae 100644 --- a/src/features/vcs/multiRoot.ts +++ b/src/features/vcs/multiRoot.ts @@ -1,14 +1,28 @@ /** Repository identity for log rows and shared repository queries. */ import type { Branch, Commit } from "@/bindings"; import { call, commands } from "@/platform/ipc"; +import type { GraphCommit } from "./graph"; -export type RepoCommit = Commit & { repo?: string | null; user?: string | null; remoteNames?: string[] }; +export type RepoCommit = Commit & { + repo?: string | null; + user?: string | null; + userName?: string | null; + remoteNames?: string[]; + readOnly?: boolean; +}; export const commitRepo = (c: Commit | undefined): string | null | undefined => (c as RepoCommit | undefined)?.repo; export const commitKey = (c: Pick): string => (c.repo ? `${c.repo}\0${c.id}` : c.id); -export const graphCommit = (c: Commit): Commit => ({ +export const graphCommit = (c: Commit): Commit & GraphCommit => ({ ...c, id: commitKey(c), - parents: c.parents.map((id) => commitKey({ ...c, id })), + parents: [...(c.graphParents ?? c.parents), ...(c.graphBoundaryParents ?? [])].map((id) => commitKey({ ...c, id })), + dashed: c.graphDashed ?? c.dashed, + edgeTypes: [ + ...(c.graphParents ?? c.parents).map((_, i) => + (c.graphDashed ?? c.dashed)?.[i] ? ("filtered" as const) : ("normal" as const), + ), + ...(c.graphBoundaryParents ?? []).map(() => "shallow" as const), + ], }); /** Merge ordered streams without breaking any repository's topological order. */ diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 6516a35d..af9d85ae 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -787,7 +787,6 @@ "File name with extension": "Nom du fichier avec extension", "File name without extension": "Nom du fichier sans extension", "File not found": "Fichier introuvable", - "File or folder": "Fichier ou dossier", "File patterns": "Motifs de fichiers", "File templates": "Modèles de fichiers", "File:": "Fichier :", @@ -803,7 +802,6 @@ "Filter TODO Items": "Filtrer les éléments TODO", "Filter by Branch": "Filtrer par branche", "Filter by Date": "Filtrer par date", - "Filter by Path": "Filtrer par chemin", "Filter by User": "Filtrer par utilisateur", "Filter by message": "Filtrer par message", "Filter by name, PID or command line": "Filtrer par nom, PID ou ligne de commande", @@ -1393,7 +1391,6 @@ "Other File Types": "Autres types de fichiers", "Other hints (chaining, closing labels…)": "Autres indications (chaînage, libellés de fermeture…)", "Other names or emails (comma-separated):": "Autres noms ou e-mails (séparés par des virgules) :", - "Other revisions (comma-separated):": "Autres révisions (séparées par des virgules) :", "Otherwise line breakpoints can only be set in files of languages that have a debugger.": "Sinon, les points d'arrêt de ligne ne peuvent être posés que dans les fichiers de langages dotés d'un débogueur.", "Outgoing commits": "Commits sortants", "Output": "Sortie", @@ -4344,7 +4341,6 @@ "partial": "partiel", "editable": "modifiable", "on {date}": "le {date}", - "Merge of {parents}": "Fusion de {parents}", "renamed from {name}": "renommé depuis {name}", "Label '{label}'": "Étiquette '{label}'", "{n} file recorded before it": "{n} fichier enregistré avant", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 1a2f5948..9748ea52 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -847,7 +847,6 @@ "File name with extension": "확장자를 포함한 파일 이름", "File name without extension": "확장자를 제외한 파일 이름", "File not found": "파일을 찾을 수 없습니다", - "File or folder": "파일 또는 폴더", "File patterns": "파일 패턴", "File templates": "파일 템플릿", "File:": "파일:", @@ -863,7 +862,6 @@ "Filter TODO Items": "TODO 항목 필터링", "Filter by Branch": "브랜치별 필터링", "Filter by Date": "날짜별 필터링", - "Filter by Path": "경로별 필터링", "Filter by User": "사용자별 필터링", "Filter by message": "메시지별 필터링", "Filter by name, PID or command line": "이름, PID 또는 명령줄로 필터링", @@ -1453,7 +1451,6 @@ "Other File Types": "기타 파일 유형", "Other hints (chaining, closing labels…)": "기타 힌트(체이닝, 닫는 레이블…)", "Other names or emails (comma-separated):": "기타 이름 또는 이메일(쉼표로 구분):", - "Other revisions (comma-separated):": "기타 리비전(쉼표로 구분):", "Otherwise line breakpoints can only be set in files of languages that have a debugger.": "그렇지 않으면 디버거가 있는 언어의 파일에서만 줄 중단점을 설정할 수 있습니다.", "Outgoing commits": "나가는 커밋", "Output": "출력", @@ -4461,7 +4458,6 @@ "partial": "부분", "editable": "편집 가능", "on {date}": "{date}에", - "Merge of {parents}": "{parents} 병합", "renamed from {name}": "{name}에서 이름 변경됨", "Label '{label}'": "레이블 '{label}'", "{n} file recorded before it": "이전에 기록된 파일 {n}개", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index b850972d..743f14f5 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -848,7 +848,6 @@ "File name with extension": "Nome do arquivo com extensão", "File name without extension": "Nome do arquivo sem extensão", "File not found": "Arquivo não encontrado", - "File or folder": "Arquivo ou pasta", "File patterns": "Padrões de arquivo", "File templates": "Modelos de arquivo", "File:": "Arquivo:", @@ -864,7 +863,6 @@ "Filter TODO Items": "Filtrar itens de TODO", "Filter by Branch": "Filtrar por branch", "Filter by Date": "Filtrar por data", - "Filter by Path": "Filtrar por caminho", "Filter by User": "Filtrar por usuário", "Filter by message": "Filtrar por mensagem", "Filter by name, PID or command line": "Filtrar por nome, PID ou linha de comando", @@ -1455,7 +1453,6 @@ "Other File Types": "Outros tipos de arquivo", "Other hints (chaining, closing labels…)": "Outras dicas (encadeamento, rótulos de fechamento…)", "Other names or emails (comma-separated):": "Outros nomes ou e-mails (separados por vírgula):", - "Other revisions (comma-separated):": "Outras revisões (separadas por vírgula):", "Otherwise line breakpoints can only be set in files of languages that have a debugger.": "Caso contrário, pontos de interrupção de linha só podem ser definidos em arquivos de linguagens que possuem um depurador.", "Outgoing commits": "Commits enviados", "Output": "Saída", @@ -4466,7 +4463,6 @@ "partial": "parcial", "editable": "editável", "on {date}": "em {date}", - "Merge of {parents}": "Mesclagem de {parents}", "renamed from {name}": "renomeado de {name}", "Label '{label}'": "Rótulo '{label}'", "{n} file recorded before it": "{n} arquivo gravado antes dele", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index fb094739..55ff76b9 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -852,7 +852,6 @@ "File name with extension": "带扩展名的文件名", "File name without extension": "不带扩展名的文件名", "File not found": "未找到文件", - "File or folder": "文件或文件夹", "File patterns": "文件模式", "File templates": "文件模板", "File:": "文件:", @@ -868,7 +867,6 @@ "Filter TODO Items": "过滤 TODO 项", "Filter by Branch": "按分支过滤", "Filter by Date": "按日期过滤", - "Filter by Path": "按路径过滤", "Filter by User": "按用户过滤", "Filter by message": "按消息过滤", "Filter by name, PID or command line": "按名称、PID 或命令行过滤", @@ -1461,7 +1459,6 @@ "Other File Types": "其他文件类型", "Other hints (chaining, closing labels…)": "其他提示(链式调用、结束标签…)", "Other names or emails (comma-separated):": "其他名称或电子邮件(逗号分隔):", - "Other revisions (comma-separated):": "其他修订(逗号分隔):", "Otherwise line breakpoints can only be set in files of languages that have a debugger.": "否则只能在有调试器的语言文件中设置行断点。", "Outgoing commits": "传出的提交", "Output": "输出", @@ -4491,7 +4488,6 @@ "partial": "部分", "editable": "可编辑", "on {date}": "于 {date}", - "Merge of {parents}": "合并自 {parents}", "renamed from {name}": "已从 {name} 重命名", "Label '{label}'": "标签“{label}”", "{n} file recorded before it": "在此之前记录了 {n} 个文件", @@ -6082,6 +6078,67 @@ "Keep abstract": "保留抽象声明", "Methods pulled up to an interface are declared abstract there.": "上拉到接口的方法将在接口中声明为抽象方法。", "The file changed; open the dialog again": "文件已更改,请重新打开对话框", + "Bookmarked Commits": "已添加书签的提交", + "Open Another Log Tab": "打开另一个日志标签页", + "In Tool Window": "在工具窗口中", + "In Editor": "在编辑器中", + "External Repository…": "外部仓库…", + "Recent Filters": "最近使用的过滤条件", + "All commits": "所有提交", + "Incoming Commits First": "优先显示传入提交", + "Linearize Merges": "线性显示合并", + "Remove Commit Bookmark": "移除提交书签", + "Bookmark Commit": "为提交添加书签", + "Browse Repository at Revision": "浏览此修订版本的仓库", + "Signature": "签名", + "Commit is outside the loaded history": "提交位于已加载历史记录之外", + "Move Left": "向左移动", + "Move Right": "向右移动", + "Reset Width": "重置宽度", + "Resize {title} column": "调整{title}列宽度", + "Commit graph": "提交关系图", + "Go to connected commit": "转到关联提交", + "Expand this fragment": "展开此片段", + "Collapse this fragment": "折叠此片段", + "Collapse or expand this branch": "折叠或展开此分支", + "Diff Preview Location": "差异预览位置", + "Parent": "父提交", + "Commit {shortId}": "提交 {shortId}", + "Expand Commit Message": "展开提交消息", + "Committed by": "提交者", + "Merge of": "合并自", + "Containing branches": "包含此提交的分支", + "Could not load containing branches or signature details": "无法加载包含此提交的分支或签名详情", + "Could not verify": "无法验证", + "Choose an absolute path inside a selected repository.": "请选择所选仓库内的绝对路径。", + "Filter by Paths": "按多个路径过滤", + "Files and folders": "文件和文件夹", + "Additional absolute path": "其他绝对路径", + "Selected paths": "已选路径", + "Open Git Log": "打开 Git 日志", + "Could not open Git log": "无法打开 Git 日志", + "Search repository files": "搜索仓库文件", + "Repository files": "仓库文件", + "Open Revision in Editor": "在编辑器中打开修订版本", + "Open External Git Log…": "打开外部 Git 日志…", + "Repository at {slice}": "修订版本 {slice} 的仓库", + "Select a file to view its repository version": "选择文件以查看其仓库版本", + "Branches, patterns, exclusions or ranges (comma-separated):": "分支、模式、排除项或范围(逗号分隔):", + "Select Parent Commit": "选择父提交", + "Select Child Commit": "选择子提交", + "Select Repository Commit": "选择仓库中的提交", + "Good signature with unknown trust": "签名有效,信任状态未知", + "Bad signature": "签名无效", + "Expired signature": "签名已过期", + "Expired signing key": "签名密钥已过期", + "Revoked signing key": "签名密钥已撤销", + "Load Newer Commits": "加载较新的提交", + "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": "图标主题", diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 9b5c832c..67a50b57 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -20,16 +20,16 @@ import { useApplyTheme } from "./theme"; import { ToolWindowLayout } from "./toolwindows/ToolWindows"; import { Welcome } from "./Welcome"; import { WindowResizeHandles } from "./WindowResizeHandles"; -import { useContributions } from "./contributions"; +import { useContributions, useStandaloneEditor } from "./contributions"; import styles from "./Workbench.module.css"; /** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */ -function LightEditFrame() { +function LightEditFrame({ title }: { title?: string }) { const path = useEditorRegistry((s) => s.active?.ctx.path); const distractionFree = useDistractionFree((s) => s.active); return ( <> - +
@@ -66,10 +66,11 @@ export function Workbench() { const banners = useContributions((s) => s.workbenchBanners); const project = useProject((s) => s.project); const lightEdit = useProject((s) => s.lightEdit); + const standaloneTitle = useStandaloneEditor((s) => s.title); const openingPath = useProject((s) => s.openingPath); const root = project?.root ?? null; const distractionFree = useDistractionFree((s) => s.active); - const frame = project ? styles.framed : lightEdit ? styles.lightEditFrame : undefined; + const frame = project ? styles.framed : lightEdit || standaloneTitle ? styles.lightEditFrame : undefined; return (
} /> {!distractionFree && } - ) : lightEdit ? ( - + ) : lightEdit || standaloneTitle ? ( + ) : ( )} diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 6605c24b..7b7c2a63 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -120,6 +120,10 @@ export interface EditorKindContribution { * `data` must be plain JSON). The editor closes tabs whose target is gone itself. */ restorable?: boolean; + /** Can remain visible in a window without a project (for example, an external Git log). */ + standalone?: boolean; + /** Last tab showing this input closed (including split tabs and tab-limit eviction). */ + onClose?(input: EditorInput): void; /** * The editor owns a document with unsaved state (a notebook): it takes part in * the dirty mark, Save All, autosave and the close prompts. Tabs with equal @@ -158,6 +162,7 @@ export interface EditorDocumentLifecycle { /** Bumped by `notifyEditorDocumentChanged`; selectors reading `isDirty` re-run on it. */ export const useEditorDocuments = create<{ revision: number }>(() => ({ revision: 0 })); +export const useStandaloneEditor = create<{ title: string | null }>(() => ({ title: null })); /** A custom editor document's dirty state (or pending edits) may have changed: tab marks and counts refresh. */ export function notifyEditorDocumentChanged() { diff --git a/src/workbench/lightEdit.test.tsx b/src/workbench/lightEdit.test.tsx index 56075374..0feb9da6 100644 --- a/src/workbench/lightEdit.test.tsx +++ b/src/workbench/lightEdit.test.tsx @@ -4,7 +4,7 @@ import { useEditorRegistry } from "@/editor/api"; import { evaluateWhen } from "@/platform/context"; import { useProject } from "@/platform/project"; import { activateWorkbench } from "./activate"; -import { registerStatusItem, registerTitleItem, registerToolWindow } from "./contributions"; +import { registerStatusItem, registerTitleItem, registerToolWindow, useStandaloneEditor } from "./contributions"; import { EmptyEditor } from "./EditorArea"; import { chooseProjectWindow } from "./projectWindow"; import { Workbench } from "./Workbench"; @@ -36,6 +36,7 @@ describe("LightEdit window", () => { ); }); afterEach(() => { + useStandaloneEditor.setState({ title: null }); disposers.splice(0).forEach((d) => d()); useProject.setState({ project: null, lightEdit: false }); useEditorRegistry.setState({ active: null }); @@ -52,6 +53,16 @@ describe("LightEdit window", () => { expect(screen.queryByText("Welcome to OpenIDEA")).toBeNull(); }); + it("keeps a standalone log visible without a project or LightEdit session", () => { + useProject.setState({ lightEdit: false }); + useStandaloneEditor.setState({ title: "External Git Log" }); + render(); + expect(screen.getByText("External Git Log")).toBeInTheDocument(); + expect(document.querySelector("[data-editor-area]")).not.toBeNull(); + expect(screen.queryByText("Welcome to OpenIDEA")).toBeNull(); + expect(document.querySelector("[data-stripe-side]")).toBeNull(); + }); + it("hints only at opening files in an empty editor", () => { render(); expect(screen.getByText("Open File")).toBeInTheDocument();