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>
This commit is contained in:
Shohruh Ismoilov
2026-11-05 02:31:32 +08:00
committed by GitHub
co-authored by shohruh-genius
parent 2d20e5a203
commit 9f2e6b570f
62 changed files with 7473 additions and 881 deletions
Generated
+2
View File
@@ -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",
+9
View File
@@ -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
+14 -5
View File
@@ -79,6 +79,7 @@ pub struct Host {
pub runtime: tokio::runtime::Handle,
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
}
@@ -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);
+1 -1
View File
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + 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<DocumentInfo> {
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
}
+3
View File
@@ -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<VirtualDocument> {
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
}
+239 -22
View File
@@ -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<std::sync::Arc<Project>>,
external: Option<std::sync::Arc<Repo>>,
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<ide_lsp::types::VirtualDocument> {
#[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::<IdeResult<Vec<_>>>()?;
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<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
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<Vec<VcsRoot>> {
}
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
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<String>) -> IdeResult<Option<String>> {
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<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
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<PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
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<PathBuf>) -> IdeResult<Vec<String>> {
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<ide_core::IpcPath> {
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<PathBuf>,
revision: String,
) -> IdeResult<LogDetails> {
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<PathBuf>,
revisions: Vec<String>,
request: RequestId,
) -> IdeResult<Vec<LogDetails>> {
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<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
let project = LogAccess::new(host, window, repo.as_deref());
let cancel =
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
let result = blocking(move || {
project.with_log_repo_at(repo.as_deref(), |r| {
r.enable_log_index(&project.cache);
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
})
})
.await;
if let Some(request) = request {
host.end_request(request);
}
result
}
pub async fn vcs_revision_children(
host: &Host,
window: &str,
repo: Option<PathBuf>,
revision: String,
directory: String,
) -> IdeResult<Vec<String>> {
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<PathBuf>,
revision: String,
) -> IdeResult<Vec<String>> {
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<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
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<PathBuf>,
commits: Vec<String>,
) -> IdeResult<Vec<RevisionChange>> {
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<PathBuf>) -> IdeResult<Vec<Branch>> {
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<PathBuf>) -> IdeResult<Vec<Stash>> {
@@ -628,8 +826,8 @@ pub async fn vcs_config_get(
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
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<PathBuf>) -> IdeResult<Vec<String>> {
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<PathBuf>) -> IdeResult<Vec<Remote>> {
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<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
@@ -815,8 +1013,8 @@ pub async fn vcs_diff_with_worktree(
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
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<Vec<ChangedFile>> {
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
});
+50 -1
View File
@@ -34,6 +34,8 @@ pub struct Project {
pub lsp: Arc<LspManager>,
/// Git repositories: the one containing the project root and those nested below it.
pub repos: Arc<RwLock<Repos>>,
/// Explicitly opened external logs remain read-only and outside project indexing.
pub(crate) external_log_repos: RwLock<Vec<Arc<Repo>>>,
/// Incrementally maintained syntax trees of the open documents.
pub syntax: Arc<SyntaxCache>,
/// 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<PathBuf> {
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<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
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<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
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<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
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))
}
+8
View File
@@ -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"
+115
View File
@@ -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::<String>();
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<_>>(), 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");
}
+41 -1
View File
@@ -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() {
+2
View File
@@ -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"
+192
View File
@@ -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<usize>], times: &[i64], heads: &[usize]) -> Vec<usize> {
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<usize>],
prepared: &mut [Option<usize>],
queue: &mut BinaryHeap<Reverse<(i64, usize)>>| {
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<Vec<usize>> = (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, &times, &heads);
assert_eq!(result.iter().copied().collect::<HashSet<_>>().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]);
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+254 -3
View File
@@ -112,22 +112,64 @@ pub struct Commit {
pub id: String,
pub short_id: String,
pub parents: Vec<String>,
/// Connections in the filtered graph; never use these as revision/diff parents.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_parents: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_dashed: Vec<bool>,
/// Repository-relative filename at this revision in rename-following history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_position: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_total: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub graph_boundary_parents: Vec<String>,
/// Reachability from HEAD, computed independently of the visible filters.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_current_branch: Option<bool>,
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<String>,
/// 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<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogDetails {
pub message: String,
pub containing_branches: Vec<String>,
/// 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<String>,
#[serde(default)]
pub search_fields: Vec<String>,
/// Jump directly to an indexed window containing this commit (50 rows of context).
#[serde(default)]
pub seek: Option<String>,
#[serde(default)]
pub retain_until: Option<String>,
pub branch: Option<String>,
pub path: Option<ide_core::IpcPath>,
#[serde(default)]
pub paths: Vec<ide_core::IpcPath>,
/// File history follows renames; structure filters retain fixed paths.
#[serde(default)]
pub follow: bool,
pub author: Option<String>,
pub text: Option<String>,
pub skip: u32,
@@ -152,6 +210,9 @@ pub struct LogQuery {
/// not the left). Takes precedence over `branch`.
#[serde(default)]
pub range: Option<String>,
/// Additional ranges unioned with each other and with selected branches.
#[serde(default)]
pub ranges: Vec<String>,
/// `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<String>,
#[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<f64>,
#[serde(default)]
pub until_ms: Option<f64>,
#[serde(default)]
pub request: Option<ide_core::RequestId>,
#[serde(default)]
pub summary_only: bool,
/// Inclusive refresh boundary. Clients join only verified linear prefixes.
#[serde(default)]
pub stop_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -487,6 +557,7 @@ pub struct ChangelistState {
pub files: std::collections::BTreeMap<String, String>,
}
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<repo::Cache>,
ignored: std::sync::RwLock<IgnoredNames>,
log_index: std::sync::Mutex<log_index::LogIndex>,
index_progress: std::sync::Mutex<LogIndexProgress>,
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
}
impl Repo {
@@ -738,7 +813,183 @@ impl Repo {
/// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: <name>`.
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
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<Vec<Commit>> {
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<std::collections::BTreeMap<String, String>> {
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<Vec<String>> {
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::<std::collections::BTreeSet<_>>()
.into_iter()
.collect())
}
pub fn log_index_progress(&self) -> LogIndexProgress {
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
/// Incremental persistent inverted index; completed objects survive cancellation/restart.
pub fn index_log(&self, cancel: &CancellationToken) -> IdeResult<LogIndexProgress> {
{
let mut progress = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
if progress.running {
return Ok(progress.clone());
}
progress.running = true;
}
let result = (|| {
let repo = self.gix()?;
let out = cli::run_cancellable(self.root(), &["rev-list", "--all", "--topo-order"], cancel)?;
let ids: Vec<_> = out
.stdout_str()
.lines()
.map(|id| gix::ObjectId::from_hex(id.as_bytes()).map_err(repo::gx))
.collect::<IdeResult<_>>()?;
{
let mut p = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
p.total = ids.len() as u32;
p.indexed = 0;
}
for (i, id) in ids.iter().enumerate() {
if cancel.is_cancelled() {
return Err(ide_core::IdeError::cancelled());
}
let indexed = self.log_index.lock().unwrap_or_else(|p| p.into_inner()).indexed(&id.to_string());
if !indexed {
let files = self.commit_files(&id.to_string())?;
let paths =
files.into_iter().flat_map(|f| std::iter::once(f.path).chain(f.old_path)).collect::<Vec<_>>();
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
let c = index.get(&repo, *id)?;
let message = index.message(&repo, *id)?;
index.index_commit(&c.id, &message, &c.author, &c.email, &paths);
if i % 100 == 0 {
index.flush();
}
}
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).indexed = i as u32 + 1;
}
Ok(())
})();
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).flush();
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).running = false;
result?;
Ok(self.log_index_progress())
}
pub fn log_details(&self, revision: &str) -> IdeResult<LogDetails> {
self.log_details_cancellable(revision, &CancellationToken::new())
}
pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult<LogDetails> {
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<Vec<String>> {
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<Vec<String>> {
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<Vec<ChangedFile>> {
+385
View File
@@ -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<Commit>,
pub positions: HashMap<String, usize>,
}
impl IndexedView {
pub fn new(commits: Vec<Commit>) -> 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<Arc<Database>>,
memory: HashMap<ObjectId, Arc<Commit>>,
dirty: Vec<Arc<Commit>>,
dirty_messages: Vec<(String, String)>,
dirty_search: Vec<(String, Vec<String>)>,
path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>,
dirty_paths: Vec<(String, Option<(String, u16)>)>,
pub head: Option<(ObjectId, Option<SystemTime>)>,
pub reachable: HashSet<ObjectId>,
pub views: HashMap<String, Arc<IndexedView>>,
pub nodes: HashMap<ObjectId, (Vec<ObjectId>, i64)>,
pub shallow: HashSet<ObjectId>,
}
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<ObjectId>,
first_parent: bool,
cancel: &ide_core::CancellationToken,
) -> IdeResult<Vec<ObjectId>> {
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::<IdeResult<_>>()?
};
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<String> = Self::grams(message).into_iter().map(|s| format!("m:{s}")).collect();
for user in [author, email, email.split('@').next().unwrap_or(email)] {
keys.insert(format!("u:{}", normalize_user(user)));
}
for path in paths {
keys.insert(format!("p:{path}"));
}
self.dirty_search.push((id.into(), keys.into_iter().collect()));
}
fn grams(text: &str) -> Vec<String> {
let chars: Vec<_> = text.to_lowercase().chars().collect();
chars.windows(3).map(|s| s.iter().collect()).collect::<HashSet<_>>().into_iter().collect()
}
/// Missing/unindexed commits are always checked directly by the caller.
pub fn message_candidates(&self, text: &str) -> Option<HashSet<String>> {
let grams = Self::grams(text);
if grams.is_empty() {
return None;
}
let db = self.db.as_ref()?;
let txn = db.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut candidates: Option<HashSet<String>> = None;
for gram in grams {
let values = table.get(format!("m:{gram}").as_str()).ok()?;
let ids: HashSet<_> = values.filter_map(Result::ok).map(|s| s.value().to_string()).collect();
candidates = Some(match candidates {
Some(old) => old.intersection(&ids).cloned().collect(),
None => ids,
});
if candidates.as_ref().is_some_and(HashSet::is_empty) {
break;
}
}
Some(candidates.unwrap_or_default())
}
pub fn user_candidates(&self, authors: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for author in authors {
let name = author.split('<').next().unwrap_or(author).trim();
let email = author.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(author);
for alias in [name, email, email.split('@').next().unwrap_or(email)] {
for value in table.get(format!("u:{}", normalize_user(alias)).as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn path_candidates(&self, paths: &[String]) -> Option<HashSet<String>> {
let txn = self.db.as_ref()?.begin_read().ok()?;
let table = txn.open_multimap_table(POSTINGS).ok()?;
let mut ids = HashSet::new();
for path in paths {
for value in table.get(format!("p:{path}").as_str()).ok()?.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
let prefix = format!("p:{path}/");
let end = format!("{prefix}{}", char::MAX);
for row in table.range(prefix.as_str()..end.as_str()).ok()?.filter_map(Result::ok) {
for value in row.1.filter_map(Result::ok) {
ids.insert(value.value().to_string());
}
}
}
Some(ids)
}
pub fn open(&mut self, dir: &Path) {
if self.opened {
return;
}
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<Mutex<HashMap<std::path::PathBuf, Weak<Database>>>> = 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<Arc<Commit>> {
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::<Commit>(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<String> {
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<dyn std::error::Error>> {
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<Option<(ObjectId, u16)>> {
let memory_key = (id, path.to_string());
if let Some(value) = self.path_memory.get(&memory_key) {
return Ok(*value);
}
let key = format!("{id}\0{path}");
let cached = self.db.as_ref().and_then(|db| {
let txn = db.begin_read().ok()?;
let table = txn.open_table(PATHS).ok()?;
let value = table.get(key.as_str()).ok()??;
serde_json::from_slice::<Option<(String, u16)>>(value.value()).ok()
});
let cached = match cached {
Some(None) => Some(None),
Some(Some((hex, mode))) => ObjectId::from_hex(hex.as_bytes()).ok().map(|id| Some((id, mode))),
None => None,
};
let value = if let Some(value) = cached {
value
} else {
let tree = repo.find_commit(id).map_err(gx)?.tree().map_err(gx)?;
let value = tree
.lookup_entry_by_path(Path::new(path))
.map_err(gx)?
.map(|entry| (entry.object_id(), entry.mode().value()));
if self.db.is_some() {
self.dirty_paths.push((key, value.map(|(id, mode)| (id.to_string(), mode))));
}
value
};
if self.path_memory.len() >= 4096 {
self.flush();
self.path_memory.clear();
}
self.path_memory.insert(memory_key, value);
Ok(value)
}
}
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)))
}
+3
View File
@@ -64,6 +64,9 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
git_dir,
inner: std::sync::Mutex::new(cache),
ignored: Default::default(),
log_index: Default::default(),
index_progress: Default::default(),
log_signatures: Default::default(),
}))
}
+438
View File
@@ -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::<std::collections::HashSet<_>>(),
[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<_>>(),
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 <other@example.com>"]);
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 <test@example.com> {} +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<_>>(), 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<_>>(), 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 <other@example.com>".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<_>>(), 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<_>>(), 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::<std::collections::HashSet<_>>(),
expected.lines().collect()
);
assert_eq!(log[0].parents.len(), 2);
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
}
}
+20 -4
View File
@@ -64,7 +64,7 @@ fn ids(commits: &[Commit]) -> Vec<String> {
}
fn parents_of(commits: &[Commit], id: &str) -> Vec<String> {
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<bool>)> = 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::<Vec<_>>(), std::slice::from_ref(&m1));
let _ = m3;
+8
View File
@@ -249,6 +249,14 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+85
View File
@@ -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<PathBuf>,
) -> IdeResult<std::collections::BTreeMap<String, String>> {
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<PathBuf>,
) -> IdeResult<Vec<String>> {
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_core::IpcPath> {
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<PathBuf>,
revision: String,
) -> IdeResult<LogDetails> {
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<PathBuf>,
revisions: Vec<String>,
request: RequestId,
) -> IdeResult<Vec<LogDetails>> {
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<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
ide_host::ops::vcs::vcs_log_index(&state.host, window.label(), repo, request).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_revision_children(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
revision: String,
directory: String,
) -> IdeResult<Vec<String>> {
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<PathBuf>,
revision: String,
) -> IdeResult<Vec<String>> {
ide_host::ops::vcs::vcs_revision_files(&state.host, window.label(), repo, revision).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_commit_files(
+100 -20
View File
@@ -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<BlockHistory, IdeError>(__TAURI_INVOKE("vcs_block_history", { request, path, text, lines })),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit_Serialize[], IdeError>(__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<string[], IdeError>(__TAURI_INVOKE("vcs_log_users", { repo })),
vcsOpenExternalLog: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("vcs_open_external_log", { path })),
vcsLogDetails: (repo: string | null, revision: string) => typedError<LogDetails, IdeError>(__TAURI_INVOKE("vcs_log_details", { repo, revision })),
vcsLogDetailsBatch: (repo: string | null, revisions: string[], request: RequestId) => typedError<LogDetails[], IdeError>(__TAURI_INVOKE("vcs_log_details_batch", { repo, revisions, request })),
vcsLogIndex: (repo: string | null, request: number | null) => typedError<LogIndexProgress, IdeError>(__TAURI_INVOKE("vcs_log_index", { repo, request })),
vcsRevisionChildren: (repo: string | null, revision: string, directory: string) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_revision_children", { repo, revision, directory })),
vcsRevisionFiles: (repo: string | null, revision: string) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_revision_files", { repo, revision })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
/** Combined changes of several commits, `commits` oldest first. */
vcsCommitsChanges: (repo: string | null, commits: string[]) => typedError<RevisionChange[], IdeError>(__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";
+22 -2
View File
@@ -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<string, number>();
@@ -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<string, EditorInput>();
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));
}
+37
View File
@@ -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();
}
});
});
+5
View File
@@ -14,6 +14,11 @@ export async function openLocation(
pos?: Position | null,
options: Omit<OpenOptions, "selection"> = {},
): Promise<void> {
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({
+50
View File
@@ -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<Commit | null> {
return new Promise((resolve) => {
const close = openDialog(() => (
<CommitChooser
commits={commits}
title={title}
onDone={(commit) => {
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 (
<Dialog open title={title} onClose={() => onDone(null)} width={520}>
<SearchField value={query} onChange={setQuery} placeholder="Search commits" aria-label="Search commits" />
<div style={{ height: 260, display: "flex" }}>
<List
items={shown}
getKey={commitKey}
renderItem={(c) => `${c.shortId} ${c.summary} — ${c.author}`}
onActivate={onDone}
rowHeight={26}
aria-label={title}
/>
</div>
</Dialog>
);
}
+65
View File
@@ -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);
+1415 -268
View File
File diff suppressed because it is too large Load Diff
+95
View File
@@ -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<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(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<number, string>());
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 (
<>
<div role={error ? "alert" : undefined}>{error}</div>
<div className={styles.revisionSource} style={{ display: "flex" }} ref={host} />
</>
);
}
+37
View File
@@ -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 (
<LogIdContext.Provider value={id}>
<LogOptionsContext.Provider value={active === id ? options : (parkedLogOptions(id) ?? options)}>
<div className={styles.root} data-git-log onPointerDownCapture={focus} onFocusCapture={focus}>
{children}
</div>
</LogOptionsContext.Provider>
</LogIdContext.Provider>
);
}
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 (
<LogScope id={data.id}>
<LogView />
</LogScope>
);
}
+138
View File
@@ -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<string[] | null> {
return new Promise((resolve) => {
const close = openDialog(() => (
<PathFilterDialog
roots={roots}
initial={initial}
readOnly={readOnly}
onDone={(value) => {
close();
resolve(value);
}}
/>
));
});
}
function PathFilterDialog({
roots,
initial,
readOnly,
onDone,
}: {
roots: string[];
initial: string[];
readOnly: boolean;
onDone: (value: string[] | null) => void;
}) {
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set(initial));
const [listings, setListings] = useState<Map<string, PathNode[]>>(() => new Map());
const [custom, setCustom] = useState("");
const [error, setError] = useState<string | null>(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<string, PathNode>();
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 (
<Dialog
open
title="Filter by Paths"
width={560}
onClose={() => onDone(null)}
buttons={[
{ label: "OK", role: "default", onClick: apply },
{ label: "Cancel", role: "cancel" },
]}
>
<div style={{ height: 320, display: "flex" }}>
<Tree<PathNode>
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) => (
<Checkbox label={n.name} checked={checked.has(n.path)} onChange={(on) => toggle(n.path, on)} />
)}
aria-label="Files and folders"
/>
</div>
{checked.size > 0 && (
<div style={{ maxHeight: 100, overflow: "auto" }} aria-label="Selected paths">
{[...checked].map((path) => (
<Checkbox key={path} label={path} checked onChange={(on) => toggle(path, on)} />
))}
</div>
)}
<Input
value={custom}
onChange={(e) => setCustom(e.target.value)}
placeholder="Additional absolute path"
aria-label="Additional absolute path"
/>
{error && <div role="alert">{error}</div>}
</Dialog>
);
}
+245
View File
@@ -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) => (
<RevisionBrowser repo={repo} revision={commit.id} external={(commit as RepoCommit).readOnly} onClose={close} />
));
}
function RevisionBrowser({
repo,
revision,
external,
onClose,
}: {
repo: string;
revision: string;
external?: boolean;
onClose: () => void;
}) {
const [directories, setDirectories] = useState<Record<string, string[]>>({});
const files = directories[""] ?? null;
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [searchFiles, setSearchFiles] = useState<string[] | null>(null);
const [selected, setSelected] = useState<string | null>(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 (
<Dialog open title={tx`Repository at ${revision.slice(0, 8)}`} onClose={onClose} width={1000}>
<div style={{ height: "min(70vh, 640px)", display: "flex", flexDirection: "column" }}>
<SearchField
value={query}
onChange={setQuery}
placeholder="Search repository files"
aria-label="Search repository files"
/>
{error ? (
<div role="alert">{error}</div>
) : !files ? (
<Spinner />
) : (
<SplitGroup orientation="horizontal" id="git-revision-browser">
<SplitPanel id="files" defaultSize="30%" minSize="20%">
<Tree
roots={tree}
getKey={(n) => 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"
/>
</SplitPanel>
<SplitHandle />
<SplitPanel id="source" minSize="30%">
{selected ? (
<div className={styles.filesPane}>
<div className={styles.detailsBar}>
{selected}
<IconButton icon="external-link" title="Open Revision in Editor" onClick={() => open(selected)} />
</div>
<RevisionSource key={selected} repo={repo} revision={revision} path={selected} />
</div>
) : (
<EmptyState>
<T>Select a file to view its repository version</T>
</EmptyState>
)}
</SplitPanel>
</SplitGroup>
)}
</div>
</Dialog>
);
}
export function GitRevisionEditor({ input }: { input: EditorInput }) {
return <RevisionSource {...(input.data as RevisionData)} />;
}
function RevisionSource({ repo, revision, path, external }: RevisionData) {
const host = useRef<HTMLDivElement>(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 (
<div className={styles.revisionSource}>
{error?.key === key && <div role="alert">{error.message}</div>}
<div className={styles.revisionSource} ref={host} />
</div>
);
}
@@ -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<typeof import("@/platform/ipc")>();
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(
<GitMergeDiff
input={{
kind: "git-merge-diff",
key: "merge:file",
data: {
repo: "/repo",
path: "a.ts",
revision: "merge",
parents: [
{ rev: "left", path: "a.ts" },
{ rev: "right", path: "a.ts" },
],
},
}}
/>,
);
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();
});
});
+77 -66
View File
@@ -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<typeof import("@/platform/ipc")>();
const api = vi.hoisted(() => ({ batch: vi.fn(), cancel: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
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(<CommitDetails commits={log} />);
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(<CommitDetails commits={log} />);
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(<CommitDetails commits={[front, back]} />);
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]);
});
});
@@ -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(<GraphCell row={first.rows.at(-1)!} />);
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(<GraphCell row={down} onNavigate={navigate} targetTitle={(i) => `Go to ${i}`} />);
fireEvent.keyDown(screen.getByRole("button", { name: "Go to 41" }), { key: "Enter" });
expect(navigate).toHaveBeenLastCalledWith(41, "base");
rerender(<GraphCell row={up} onNavigate={navigate} targetTitle={(i) => `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(<GraphCell row={graph.rows[1]!} onFragment={fragment} />);
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),
);
});
});
@@ -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<typeof import("@/platform/ipc")>();
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 = <T>(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<typeof ok<Commit[]>>) => 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<typeof ok<Commit[]>>) => 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<typeof ok<Commit[]>>) => 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");
});
});
@@ -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);
});
@@ -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);
});
});
@@ -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<typeof import("@/platform/ipc")>();
const ok = <T,>(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(
<>
<LogScope id="log">
<LogView />
</LogScope>
<LogScope id={other}>
<LogView />
</LogScope>
</>,
);
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(<LogView />);
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(<LogView />);
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(<LogView />);
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(
<>
<LogView />
<VcsDialogHost />
</>,
);
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(
<>
<CommitDetails commits={[merge]} />
<ContextMenuHost />
</>,
);
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"));
});
});
@@ -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<typeof import("@/platform/ipc")>();
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 <button onClick={() => setLogFilters({ text: `${name} edited` })}>{`${name}: ${text} / hash ${hash}`}</button>;
}
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(
<>
<LogScope id="log">
<Probe name="main" />
</LogScope>
<LogScope id={other}>
<Probe name="other" />
</LogScope>
</>,
);
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" }),
]),
);
});
});
+35 -1
View File
@@ -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"] } });
+3 -1
View File
@@ -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" },
];
+302 -24
View File
@@ -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<string>): { 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<string>,
only?: ReadonlySet<string>,
): { commits: C[]; graph: GraphCommit[] } {
const byId = new Map(commits.map((c) => [c.id, c]));
const children = new Map<string, number>();
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<string> = 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<string, string>();
const nodes = new Map<string, Set<string>>();
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<number>(),
tails = new Set<number>();
const tailEdges = new Map<number, Set<string>>();
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<number>();
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<string>();
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;
}
+45 -15
View File
@@ -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",
+30 -1
View File
@@ -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,
+16
View File
@@ -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;
}
}
+34 -1
View File
@@ -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"),
+5
View File
@@ -0,0 +1,5 @@
import { createContext } from "react";
import type { LogOptions } from "./logOptions";
export const LogIdContext = createContext<string | null>(null);
export const LogOptionsContext = createContext<LogOptions | null>(null);
+144
View File
@@ -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<Record<string, number>>(() => ({}));
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<LogDetails>;
resolve: (details: LogDetails) => void;
reject: (error: unknown) => void;
users: number;
request?: RequestId;
settled: boolean;
}
const cache = new Map<string, Entry>();
const pending: Entry[] = [];
const batches = new Map<RequestId, Entry[]>();
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<LogDetails> {
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<LogDetails>((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 };
}
+110
View File
@@ -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<typeof setInterval>;
}
const jobs = new Map<string, Job>();
const revision = create<{ value: number }>(() => ({ value: 0 }));
const notify = () => revision.setState((s) => ({ value: s.value + 1 }));
function start(repo: string, job: Job) {
if (job.paused) return;
if (job.request != null) {
job.restart = true;
return;
}
const request = nextRequestId();
job.request = request;
job.progress = { ...job.progress, running: true };
job.timer = setInterval(() => {
void call(commands.vcsLogIndex(repo, null))
.then((progress) => {
if (job.request === request) {
job.progress = progress;
notify();
}
})
.catch(() => undefined);
}, 750);
void call(commands.vcsLogIndex(repo, request))
.then(
(progress) => {
job.progress = progress;
},
() => {
job.progress = { ...job.progress, running: false };
},
)
.finally(() => {
if (job.request !== request) return;
job.request = null;
clearInterval(job.timer);
if (job.restart && job.users && !job.paused) {
job.restart = false;
start(repo, job);
}
notify();
});
notify();
}
/** Visible log scopes share one background job per repository. */
export function LogIndexStatus({ repositories }: { repositories: readonly string[] }) {
revision((s) => s.value);
const version = useVcs((s) => s.version);
useEffect(() => {
for (const repo of repositories) {
const job: Job = jobs.get(repo) ?? {
users: 0,
request: null,
paused: false,
progress: { indexed: 0, total: 0, running: false },
};
jobs.set(repo, job);
const first = job.users++ === 0;
if (first || job.version !== version) {
job.version = version;
start(repo, job);
}
}
return () => {
for (const repo of repositories) {
const job = jobs.get(repo)!;
if (--job.users === 0) {
clearInterval(job.timer);
if (job.request != null) void commands.cancelRequest(job.request).catch(() => undefined);
}
}
};
}, [repositories, version]);
return repositories.map((repo) => {
const job = jobs.get(repo);
if (!job || (!job.paused && !job.progress.running)) return null;
return (
<div key={repo} role="status" style={{ display: "flex", alignItems: "center", gap: 6 }}>
{tx`Indexing Git log: ${job.progress.indexed} / ${job.progress.total}`}
<IconButton
icon={job.paused ? "play" : "pause"}
title={job.paused ? "Resume Indexing" : "Pause Indexing"}
onClick={() => {
job.paused = !job.paused;
if (job.paused && job.request != null) void commands.cancelRequest(job.request).catch(() => undefined);
else start(repo, job);
notify();
}}
/>
</div>
);
});
}
+43 -3
View File
@@ -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<string, number>;
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<string, unknown>) : {};
const out = { ...DEFAULT_LOG_OPTIONS };
for (const k of Object.keys(out) as Array<keyof LogOptions>) {
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<string, unknown>)[k] = o[k];
}
return out;
@@ -76,7 +110,13 @@ function load(): LogOptions {
}
}
export const useLogOptions = create<LogOptions>(() => load());
export const rootLogOptions = create<LogOptions>(() => load());
export const useLogOptions = Object.assign(function useScopedLogOptions<T = LogOptions>(
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")
+12 -12
View File
@@ -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 }),
},
+30
View File
@@ -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<string>; collapsed: ReadonlySet<string>; merges: ReadonlySet<string> },
keep: ReadonlySet<string>,
) {
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 };
}
+44
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -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<RepoCommit, "id" | "repo">): 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. */
-4
View File
@@ -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",
-4
View File
@@ -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}개",
-4
View File
@@ -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",
+61 -4
View File
@@ -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": "图标主题",
+7 -6
View File
@@ -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 (
<>
<TitleBar lightEdit bare={distractionFree} title={path ? basename(path) : "LightEdit"} />
<TitleBar lightEdit bare={distractionFree} title={title ?? (path ? basename(path) : "LightEdit")} />
<main className={styles.lightEdit} data-light-edit>
<div className={styles.lightEditIsland}>
<EditorArea />
@@ -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 (
<div
@@ -88,8 +89,8 @@ export function Workbench() {
<ToolWindowLayout key={root} editor={<EditorArea />} />
{!distractionFree && <StatusBar />}
</>
) : lightEdit ? (
<LightEditFrame />
) : lightEdit || standaloneTitle ? (
<LightEditFrame title={standaloneTitle ?? undefined} />
) : (
<Welcome />
)}
+5
View File
@@ -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() {
+12 -1
View File
@@ -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(<Workbench />);
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(<EmptyEditor />);
expect(screen.getByText("Open File")).toBeInTheDocument();