mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
shohruh-genius
parent
2d20e5a203
commit
9f2e6b570f
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, ×, &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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+735
-272
File diff suppressed because it is too large
Load Diff
+254
-3
@@ -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>> {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"] } });
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+781
-129
File diff suppressed because it is too large
Load Diff
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}개",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "图标主题",
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user