feat(vcs): Support several git repositories in one project (multi-root)

Only the repository containing the project root was known, so nested clones and
submodules (or a folder of side-by-side service repositories) showed no changes and
could not be committed or pushed. The project now has IDEA's VCS roots: the enclosing
repository plus those found below the project (scanned in the background, rescanned when
a .git appears or goes). Status covers all of them; path-based commands go to the file's
repository and repository-scoped ones take the selected repository.

The Commit view groups changes by repository and commits each one with included changes;
the branch widget names the selected repository and switches between them; Update Project
and Fetch run in every repository; Push lists every repository with outgoing commits; a
repository that stops in a merge or rebase is selected so Continue / Abort act on it.

Closes #12
This commit is contained in:
l0ng-ai committed 2026-10-23 23:01:07 +08:00
1 parent 7fa1d08b94
commit 825f190030
32 files changed
+1087 -169

No files matched your search

+6
View File
@@ -268,6 +268,7 @@ mod patch;
mod refs;
mod remotes;
mod repo;
mod roots;
pub mod shelf;
mod status;
mod write;
@@ -275,6 +276,7 @@ mod write;
pub use cli::{detected_git_executable, git_version, set_git_executable};
pub use diff::build_partial_patch;
pub use remotes::Remote;
pub use roots::Repos;
/// Clones `url` into `dest` (which must not exist or be empty), streaming progress.
pub fn clone(url: &str, dest: &Path, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
@@ -320,6 +322,10 @@ impl Repo {
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
refs::branch_info(self)
}
/// Merge / rebase / cherry-pick… in progress (cheap: no ref walking).
pub fn operation(&self) -> IdeResult<Option<String>> {
Ok(refs::operation(&self.gix()?))
}
/// Content of `path` at `rev` (default HEAD); `None` if absent there.
/// `rev` may be any revision git understands, or `":"` / `":0"` for the index.
pub fn file_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
+1 -1
View File
@@ -30,7 +30,7 @@ pub(crate) fn branch_info(r: &Repo) -> IdeResult<BranchInfo> {
Ok(info)
}
fn operation(repo: &gix::Repository) -> Option<String> {
pub(crate) fn operation(repo: &gix::Repository) -> Option<String> {
use gix::state::InProgress::*;
let op = match repo.state()? {
ApplyMailbox | ApplyMailboxRebase => "am",
+203
View File
@@ -0,0 +1,203 @@
//! The repositories of a project (IDEA's VCS roots): the one containing the project
//! root plus those nested below it (separate clones side by side, submodules).
use crate::{FileStatus, Repo};
use ide_core::IdeResult;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// How deep below the project root nested repositories are looked for.
const MAX_DEPTH: usize = 4;
/// Folders never searched for nested repositories (dependencies, build output).
const SKIPPED: &[&str] = &["node_modules", "target", "build", "dist", "out", "vendor", "venv", "__pycache__"];
/// The repositories of a project, sorted by root; lookups pick the innermost one.
#[derive(Clone, Default)]
pub struct Repos {
repos: Vec<Arc<Repo>>,
primary: Option<usize>,
}
impl Repos {
/// The repository containing `project_root` and every repository whose worktree
/// root lies below it (up to [`MAX_DEPTH`] levels; hidden and dependency folders and
/// folders `skip` rejects are not searched).
pub fn discover(project_root: &Path, skip: &dyn Fn(&Path) -> bool) -> IdeResult<Self> {
let mut repos: Vec<Repo> = Vec::new();
if let Some(r) = Repo::discover(project_root)? {
repos.push(r);
}
let mut stack: Vec<(PathBuf, usize)> = vec![(project_root.to_path_buf(), 0)];
while let Some((dir, depth)) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
if !entry.file_type().is_ok_and(|t| t.is_dir()) {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
let path = entry.path();
if name.starts_with('.') || SKIPPED.contains(&name.as_ref()) || skip(&path) {
continue;
}
if path.join(".git").exists()
&& let Ok(Some(r)) = Repo::discover(&path)
&& r.root() == path
&& !repos.iter().any(|x| x.root() == r.root())
{
repos.push(r);
}
if depth + 1 < MAX_DEPTH {
stack.push((path, depth + 1));
}
}
}
Ok(Self::new(repos, project_root))
}
/// Only the repository containing `project_root` (the quick lookup on project open).
pub fn enclosing(project_root: &Path) -> IdeResult<Self> {
Ok(Self::new(Repo::discover(project_root)?.into_iter().collect(), project_root))
}
/// Worktree roots, sorted.
pub fn roots(&self) -> Vec<PathBuf> {
self.repos.iter().map(|r| r.root().to_path_buf()).collect()
}
fn new(mut repos: Vec<Repo>, project_root: &Path) -> Self {
repos.sort_by(|a, b| a.root().cmp(b.root()));
let repos: Vec<Arc<Repo>> = repos.into_iter().map(Arc::new).collect();
let primary = Self::innermost(&repos, project_root).or((!repos.is_empty()).then_some(0));
Self { repos, primary }
}
fn innermost(repos: &[Arc<Repo>], path: &Path) -> Option<usize> {
repos
.iter()
.enumerate()
.filter(|(_, r)| path.starts_with(r.root()))
.max_by_key(|(_, r)| r.root().as_os_str().len())
.map(|(i, _)| i)
}
pub fn is_empty(&self) -> bool {
self.repos.is_empty()
}
pub fn all(&self) -> &[Arc<Repo>] {
&self.repos
}
/// The repository of the project root, else the first one.
pub fn primary(&self) -> Option<&Arc<Repo>> {
self.primary.map(|i| &self.repos[i])
}
/// The innermost repository containing `path` (a file, folder or repository root).
pub fn for_path(&self, path: &Path) -> Option<&Arc<Repo>> {
Self::innermost(&self.repos, path).map(|i| &self.repos[i]).or_else(|| {
// Another spelling of the same location (symlinks, /var vs /private/var).
let canon = dunce::canonicalize(path).ok()?;
self.repos
.iter()
.filter(|r| dunce::canonicalize(r.root()).is_ok_and(|root| canon.starts_with(root)))
.max_by_key(|r| r.root().as_os_str().len())
})
}
/// `paths` grouped by the repository each belongs to (paths outside every repository are dropped).
pub fn group(&self, paths: &[PathBuf]) -> Vec<(Arc<Repo>, Vec<PathBuf>)> {
let mut out: Vec<(Arc<Repo>, Vec<PathBuf>)> = Vec::new();
for p in paths {
let Some(repo) = self.for_path(p) else { continue };
match out.iter_mut().find(|(r, _)| Arc::ptr_eq(r, repo)) {
Some((_, list)) => list.push(p.clone()),
None => out.push((repo.clone(), vec![p.clone()])),
}
}
out
}
/// Changes of every repository. A nested repository reports its own files, so the
/// enclosing one's "untracked folder" entry for it is dropped.
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
let mut out = Vec::new();
for repo in &self.repos {
let nested: Vec<&Path> = self
.repos
.iter()
.map(|r| r.root())
.filter(|r| *r != repo.root() && r.starts_with(repo.root()))
.collect();
out.extend(repo.status()?.into_iter().filter(|f| {
let untracked_root =
f.unstaged == Some(crate::ChangeKind::Untracked) && nested.iter().any(|n| f.path.0 == *n);
!untracked_root
}));
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git_init(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
let ok = Command::new("git").args(["init", "-q"]).current_dir(dir).status().is_ok_and(|s| s.success());
assert!(ok, "git init in {}", dir.display());
}
#[test]
fn finds_nested_repositories_and_routes_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
git_init(&root);
git_init(&root.join("services/api"));
git_init(&root.join("web"));
git_init(&root.join("node_modules/dep"));
git_init(&root.join(".cache/repo"));
std::fs::write(root.join("services/api/main.rs"), "fn main() {}").unwrap();
std::fs::write(root.join("README.md"), "x").unwrap();
let repos = Repos::discover(&root, &|_| false).unwrap();
let roots: Vec<&Path> = repos.all().iter().map(|r| r.root()).collect();
assert_eq!(roots, [root.as_path(), &root.join("services/api"), &root.join("web")]);
assert_eq!(repos.primary().unwrap().root(), root);
assert_eq!(repos.for_path(&root.join("services/api/main.rs")).unwrap().root(), root.join("services/api"));
assert_eq!(repos.for_path(&root.join("services/other.txt")).unwrap().root(), root);
let status = repos.status().unwrap();
let paths: Vec<String> =
status.iter().map(|f| f.path.0.strip_prefix(&root).unwrap().to_string_lossy().into_owned()).collect();
assert!(paths.contains(&"README.md".to_owned()), "{paths:?}");
assert!(paths.contains(&"services/api/main.rs".to_owned()), "{paths:?}");
assert!(
!paths.iter().any(|p| p.trim_end_matches('/') == "web"),
"nested root is not an untracked folder: {paths:?}"
);
let groups = repos.group(&[root.join("README.md"), root.join("services/api/main.rs"), root.join("web/x")]);
assert_eq!(groups.len(), 3);
assert_eq!(groups[1].1, [root.join("services/api/main.rs")]);
let skipped = Repos::discover(&root, &|p| p.ends_with("services")).unwrap();
assert_eq!(skipped.all().len(), 2);
}
#[test]
fn side_by_side_repositories_without_an_enclosing_one() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
git_init(&root.join("a"));
git_init(&root.join("b"));
let repos = Repos::discover(&root, &|_| false).unwrap();
assert_eq!(repos.all().len(), 2);
assert_eq!(repos.primary().unwrap().root(), root.join("a"), "the first when none contains the project root");
assert!(repos.for_path(&root.join("c.txt")).is_none());
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
fn project_info(project: &crate::state::Project) -> ProjectInfo {
ProjectInfo {
name: project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
is_git: project.repo.lock().is_some(),
is_git: !project.repos.read().is_empty(),
settings: project.settings.effective(),
root: project.root.clone().into(),
}
+1
View File
@@ -129,6 +129,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
lsp::apply_workspace_edit,
lsp::lsp_execute_command,
vcs::vcs_status,
vcs::vcs_roots,
vcs::vcs_branch_info,
vcs::vcs_line_hunks,
vcs::vcs_file_at,
+171 -48
View File
@@ -1,4 +1,4 @@
use crate::state::AppState;
use crate::state::{AppState, Project};
use ide_core::{IdeResult, RequestId};
use ide_vcs::*;
use serde::{Deserialize, Serialize};
@@ -13,18 +13,59 @@ pub(crate) fn apply_git_executable(settings: &ide_core::settings::Settings) {
ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe)));
}
/// Changes of every repository of the project.
#[tauri::command]
#[specta::specta]
pub async fn vcs_status(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<FileStatus>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.status())).await
blocking(move || {
let settings = project.settings.effective();
apply_git_executable(&settings);
let repos = project.repos();
if repos.is_empty() {
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
}
for r in repos.all() {
r.set_ignored_names(settings.ignored_names());
}
repos.status()
})
.await
}
/// A repository of the project (IDEA's VCS root).
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct VcsRoot {
/// Worktree root.
pub root: ide_core::IpcPath,
/// Merge / rebase / cherry-pick / revert in progress.
pub operation: Option<String>,
}
/// The project's repositories, the primary one first.
#[tauri::command]
#[specta::specta]
pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<VcsRoot>> {
let repos = state.project(window.label())?.repos();
let primary = repos.primary().map(|r| r.root().to_path_buf());
let mut all: Vec<&std::sync::Arc<Repo>> = repos.all().iter().collect();
all.sort_by_key(|r| Some(r.root()) != primary.as_deref());
Ok(all
.into_iter()
.map(|r| VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten() })
.collect())
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_branch_info(window: Window, state: State<'_, AppState>) -> IdeResult<BranchInfo> {
pub async fn vcs_branch_info(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
) -> IdeResult<BranchInfo> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.branch_info())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
}
/// Gutter change markers for a file, computed against its current (unsaved) text.
@@ -33,7 +74,7 @@ pub async fn vcs_branch_info(window: Window, state: State<'_, AppState>) -> IdeR
pub fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
let project = state.project(window.label())?;
let text = project.text_of(&path)?;
project.with_repo(|r| r.line_hunks(&path, &text, false))
project.with_repo_at(Some(&path), |r| r.line_hunks(&path, &text, false))
}
#[tauri::command]
@@ -45,7 +86,7 @@ pub async fn vcs_file_at(
rev: Option<String>,
) -> IdeResult<Option<String>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.file_at(&path, rev.as_deref()))).await
blocking(move || project.with_repo_at(Some(&path), |r| r.file_at(&path, rev.as_deref()))).await
}
#[tauri::command]
@@ -53,14 +94,19 @@ pub async fn vcs_file_at(
pub async fn vcs_blame(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
let project = state.project(window.label())?;
let text = project.documents.text_of(&path);
blocking(move || project.with_repo(|r| r.blame(&path, text.as_deref()))).await
blocking(move || project.with_repo_at(Some(&path), |r| r.blame(&path, text.as_deref()))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_log(window: Window, state: State<'_, AppState>, query: LogQuery) -> IdeResult<Vec<Commit>> {
pub async fn vcs_log(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
query: LogQuery,
) -> IdeResult<Vec<Commit>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.log(&query))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
}
#[tauri::command]
@@ -68,38 +114,39 @@ pub async fn vcs_log(window: Window, state: State<'_, AppState>, query: LogQuery
pub async fn vcs_commit_files(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.commit_files(&commit))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_branches(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<Branch>> {
pub async fn vcs_branches(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.branches())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stashes(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<Stash>> {
pub async fn vcs_stashes(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.stashes())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stashes())).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.stage(&paths))).await
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.unstage(&paths))).await
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
}
#[tauri::command]
@@ -113,7 +160,7 @@ pub async fn vcs_stage_patch(window: Window, state: State<'_, AppState>, patch:
#[specta::specta]
pub async fn vcs_discard(window: Window, state: State<'_, AppState>, paths: Vec<PathBuf>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Rollback"), |r| r.discard(&paths))).await
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
}
#[tauri::command]
@@ -121,19 +168,27 @@ pub async fn vcs_discard(window: Window, state: State<'_, AppState>, paths: Vec<
pub async fn vcs_commit(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
message: String,
options: CommitOptions,
) -> IdeResult<String> {
// Commit hooks may run for a long time.
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.commit_with(&message, &options))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_checkout(window: Window, state: State<'_, AppState>, branch: String, create: bool) -> IdeResult<()> {
pub async fn vcs_checkout(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
branch: String,
create: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git checkout"), |r| r.checkout(&branch, create))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout(&branch, create)))
.await
}
/// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out.
@@ -142,22 +197,31 @@ pub async fn vcs_checkout(window: Window, state: State<'_, AppState>, branch: St
pub async fn vcs_create_branch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
name: String,
start: Option<String>,
checkout: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
let recorded = checkout.then_some("Git checkout");
blocking(move || project.with_repo_recorded(recorded, |r| r.create_branch(&name, start.as_deref(), checkout))).await
blocking(move || {
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
})
.await
}
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
/// Returns the undone commit's message.
#[tauri::command]
#[specta::specta]
pub async fn vcs_undo_commit(window: Window, state: State<'_, AppState>, commit: String) -> IdeResult<String> {
pub async fn vcs_undo_commit(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
commit: String,
) -> IdeResult<String> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.undo_commit(&commit))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
}
#[tauri::command]
@@ -165,32 +229,49 @@ pub async fn vcs_undo_commit(window: Window, state: State<'_, AppState>, commit:
pub async fn vcs_delete_branch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
branch: String,
force: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.delete_branch(&branch, force))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch(&branch, force))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_rename_branch(window: Window, state: State<'_, AppState>, from: String, to: String) -> IdeResult<()> {
pub async fn vcs_rename_branch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
from: String,
to: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.rename_branch(&from, &to))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_merge(window: Window, state: State<'_, AppState>, branch: String) -> IdeResult<()> {
pub async fn vcs_merge(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
branch: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git merge"), |r| r.merge(&branch))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_rebase(window: Window, state: State<'_, AppState>, onto: String) -> IdeResult<()> {
pub async fn vcs_rebase(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
onto: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git rebase"), |r| r.rebase(&onto))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
}
#[tauri::command]
@@ -198,28 +279,43 @@ pub async fn vcs_rebase(window: Window, state: State<'_, AppState>, onto: String
pub async fn vcs_stash_push(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
message: Option<String>,
include_untracked: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_recorded(Some("Git stash"), |r| r.stash_push(message.as_deref(), include_untracked))
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
r.stash_push(message.as_deref(), include_untracked)
})
})
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stash_apply(window: Window, state: State<'_, AppState>, index: u32, pop: bool) -> IdeResult<()> {
pub async fn vcs_stash_apply(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
index: u32,
pop: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(Some("Git unstash"), |r| r.stash_apply(index, pop))).await
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| r.stash_apply(index, pop)))
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_stash_drop(window: Window, state: State<'_, AppState>, index: u32) -> IdeResult<()> {
pub async fn vcs_stash_drop(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
index: u32,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.stash_drop(index))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
}
#[derive(Clone, Serialize, Deserialize, Type)]
@@ -258,6 +354,7 @@ pub enum RemoteOp {
pub async fn vcs_remote(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
request: RequestId,
op: RemoteOp,
on_output: Channel<String>,
@@ -269,7 +366,7 @@ pub async fn vcs_remote(
let _ = on_output.send(line);
};
let recorded = matches!(op, RemoteOp::Pull { .. }).then_some("Git pull");
project.with_repo_recorded(recorded, |r| match op {
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
RemoteOp::Fetch => r.fetch(&cancel, &out),
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
@@ -304,7 +401,7 @@ pub async fn vcs_conflict_versions(
) -> IdeResult<ConflictVersions> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo(|r| {
project.with_repo_at(Some(&path), |r| {
r.conflict_versions(&path).map(|(base, ours, theirs)| ConflictVersions { base, ours, theirs })
})
})
@@ -320,7 +417,7 @@ pub async fn vcs_diff_text(
staged: bool,
) -> IdeResult<String> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.diff_text(&path, staged))).await
blocking(move || project.with_repo_at(Some(&path), |r| r.diff_text(&path, staged))).await
}
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
@@ -337,7 +434,7 @@ pub fn vcs_diff_hunks(old_text: String, new_text: String) -> Vec<LineHunk> {
pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
let project = state.project(window.label())?;
let current = ide_vfs::load_text(&path)?.text;
project.with_repo(|r| {
project.with_repo_at(Some(&path), |r| {
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
@@ -408,9 +505,14 @@ pub fn vcs_detected_git() -> Option<String> {
/// `git config --get <key>` in the project repository; `None` when unset.
#[tauri::command]
#[specta::specta]
pub async fn vcs_config_get(window: Window, state: State<'_, AppState>, key: String) -> IdeResult<Option<String>> {
pub async fn vcs_config_get(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.config_get(&key))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
// ---- in-progress operations, history rewriting (Git Log actions) ----
@@ -448,6 +550,7 @@ pub enum GitOperation {
pub async fn vcs_operation(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
request: RequestId,
op: GitOperation,
on_output: Channel<String>,
@@ -465,7 +568,7 @@ pub async fn vcs_operation(
GitOperation::InteractiveRebase { .. } => Some("Git rebase"),
_ => None,
};
project.with_repo_recorded(recorded, |r| match op {
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
GitOperation::Continue => r.continue_operation(&cancel, &out),
GitOperation::Abort => r.abort_operation(&cancel, &out),
GitOperation::Skip => r.skip_operation(&cancel, &out),
@@ -488,20 +591,22 @@ pub async fn vcs_operation(
pub async fn vcs_create_tag(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
name: String,
commit: String,
message: Option<String>,
force: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.create_tag(&name, &commit, message.as_deref(), force))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
.await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_tags(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<String>> {
pub async fn vcs_tags(window: Window, state: State<'_, AppState>, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.tags())).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Compare with Local: tracked files that differ between `rev` and the working tree.
@@ -510,10 +615,28 @@ pub async fn vcs_tags(window: Window, state: State<'_, AppState>) -> IdeResult<V
pub async fn vcs_diff_with_worktree(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo(|r| r.diff_with_worktree(&rev))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Runs `f` once per repository with the `paths` that belong to it.
fn per_repo(
project: &Project,
paths: &[PathBuf],
recorded: Option<&str>,
f: impl Fn(&Repo, &[PathBuf]) -> IdeResult<()>,
) -> IdeResult<()> {
let groups = project.repos().group(paths);
if groups.is_empty() {
return project.with_repo_recorded(None, recorded, |r| f(r, paths));
}
for (repo, paths) in groups {
project.with_repo_recorded(Some(repo.root()), recorded, |r| f(r, &paths))?;
}
Ok(())
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
@@ -587,7 +710,7 @@ pub async fn vcs_shelve(
let project = state.project(window.label())?;
blocking(move || {
keep_private(&project.root);
project.with_repo_recorded(Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
})
.await
}
@@ -611,7 +734,7 @@ pub async fn vcs_unshelve(
) -> IdeResult<ApplyOutcome> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_recorded(Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
})
.await
}
+59 -11
View File
@@ -8,7 +8,7 @@ use ide_run::ProcessManager;
use ide_syntax::StructureService;
use ide_terminal::TerminalManager;
use ide_text::{AppliedChange, ByteEdit};
use ide_vcs::Repo;
use ide_vcs::{Repo, Repos};
use ide_vfs::Watcher;
use ide_workspace::documents::DocumentObserver;
use ide_workspace::{
@@ -56,7 +56,8 @@ pub struct Project {
pub moves: FileMoves,
pub index: Arc<ProjectIndex>,
pub lsp: Arc<LspManager>,
pub repo: Mutex<Option<Repo>>,
/// Git repositories: the one containing the project root and those nested below it.
pub repos: Arc<RwLock<Repos>>,
/// Incrementally maintained syntax trees of the open documents.
pub syntax: Arc<SyntaxCache>,
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
@@ -279,7 +280,10 @@ impl AppState {
documents.add_observer(Arc::new(LspObserver(lsp.clone())));
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
let watcher = {
let repos = repos.clone();
let settings = settings.clone();
let index = index.clone();
let documents = documents.clone();
let sink = sink.clone();
@@ -305,6 +309,12 @@ impl AppState {
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
}
}
// A repository was cloned / initialised / removed below the project.
let git_dirs =
changed.iter().chain(&batch.removed).any(|p| p.file_name().is_some_and(|n| n == ".git"));
if git_dirs {
scan_repositories(&root, &repos, &settings, &sink);
}
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
}),
)?
@@ -331,8 +341,13 @@ impl AppState {
};
mark("watcher");
let repo = Repo::discover(&root)?;
let branch = repo.as_ref().and_then(|r| r.branch_info().ok()).and_then(|b| b.head);
let branch = repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head);
{
let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone());
std::thread::Builder::new()
.name("openidea-vcs-roots".into())
.spawn(move || scan_repositories(&root, &repos, &settings, &sink))?;
}
mark("git");
self.recent.touch(&root, branch)?;
mark("recent");
@@ -349,7 +364,7 @@ impl AppState {
moves: FileMoves::new(),
index,
lsp,
repo: Mutex::new(repo),
repos,
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots,
@@ -391,12 +406,39 @@ impl AppState {
}
}
/// Looks for repositories nested below the project (and the one containing it);
/// emits `VcsChanged` when the set of roots changed.
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
let ignored = settings.effective().ignored_names();
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
match Repos::discover(root, &skip) {
Ok(found) => {
if found.roots() != repos.read().roots() {
*repos.write() = found;
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
}
}
Err(e) => tracing::warn!("looking for git repositories: {e}"),
}
}
impl Project {
/// Runs `f` with the git repository, or fails when the project is not under git.
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
/// The project's git repositories.
pub fn repos(&self) -> Repos {
self.repos.read().clone()
}
/// Runs `f` with the repository containing `at` (a path in it or its root), the
/// project's primary repository when `None`; fails when there is none.
pub fn with_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
let settings = self.settings.effective();
crate::commands::vcs::apply_git_executable(&settings);
match self.repo.lock().as_ref() {
let repos = self.repos();
let repo = match at {
Some(path) => repos.for_path(path),
None => repos.primary(),
};
match repo {
Some(repo) => {
repo.set_ignored_names(settings.ignored_names());
f(repo)
@@ -405,25 +447,31 @@ impl Project {
}
}
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
self.with_repo_at(None, f)
}
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
/// snapshot of every locally changed file before, and a label after.
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
pub fn with_repo_recorded<T>(
&self,
at: Option<&Path>,
operation: Option<&str>,
f: impl FnOnce(&Repo) -> IdeResult<T>,
) -> IdeResult<T> {
let Some(name) = operation else { return self.with_repo(f) };
let Some(name) = operation else { return self.with_repo_at(at, f) };
self.history_label(&format!("Before {name}"));
if let Ok(changed) = self.with_repo(|r| r.status()) {
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
tracing::debug!("local history: {e}");
}
}
}
let result = self.with_repo(f);
let result = self.with_repo_at(at, f);
self.history_label(&format!("After {name}"));
result
}
+32 -22
View File
@@ -251,37 +251,39 @@ export const commands = {
lspExecuteCommand: (doc: DocId, command: Command) => typedError<null, IdeError>(__TAURI_INVOKE("lsp_execute_command", { doc, command })),
/** Changes of every repository of the project. */
vcsStatus: () => typedError<FileStatus[], IdeError>(__TAURI_INVOKE("vcs_status")),
vcsBranchInfo: () => typedError<BranchInfo, IdeError>(__TAURI_INVOKE("vcs_branch_info")),
/** The project's repositories, the primary one first. */
vcsRoots: () => typedError<VcsRoot[], IdeError>(__TAURI_INVOKE("vcs_roots")),
vcsBranchInfo: (repo: string | null) => typedError<BranchInfo, IdeError>(__TAURI_INVOKE("vcs_branch_info", { repo })),
/** Gutter change markers for a file, computed against its current (unsaved) text. */
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
vcsBlame: (path: string) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path })),
vcsLog: (query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { query })),
vcsCommitFiles: (commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { commit })),
vcsBranches: () => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches")),
vcsStashes: () => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes")),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
vcsBranches: (repo: string | null) => typedError<Branch[], IdeError>(__TAURI_INVOKE("vcs_branches", { repo })),
vcsStashes: (repo: string | null) => typedError<Stash[], IdeError>(__TAURI_INVOKE("vcs_stashes", { repo })),
vcsStage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage", { paths })),
vcsUnstage: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_unstage", { paths })),
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
vcsCommit: (message: string, options: CommitOptions) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_commit", { message, options })),
vcsCheckout: (branch: string, create: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_checkout", { branch, create })),
vcsDeleteBranch: (branch: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_delete_branch", { branch, force })),
vcsCommit: (repo: string | null, message: string, options: CommitOptions) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_commit", { repo, message, options })),
vcsCheckout: (repo: string | null, branch: string, create: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_checkout", { repo, branch, create })),
vcsDeleteBranch: (repo: string | null, branch: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_delete_branch", { repo, branch, force })),
/** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */
vcsCreateBranch: (name: string, start: string | null, checkout: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { name, start, checkout })),
vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })),
/**
* Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
* Returns the undone commit's message.
*/
vcsUndoCommit: (commit: string) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_undo_commit", { commit })),
vcsRenameBranch: (from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { from, to })),
vcsMerge: (branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { branch })),
vcsRebase: (onto: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase", { onto })),
vcsStashPush: (message: string | null, includeUntracked: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { message, includeUntracked })),
vcsStashApply: (index: number, pop: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { index, pop })),
vcsStashDrop: (index: number) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_drop", { index })),
vcsUndoCommit: (repo: string | null, commit: string) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_undo_commit", { repo, commit })),
vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })),
vcsMerge: (repo: string | null, branch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_merge", { repo, branch })),
vcsRebase: (repo: string | null, onto: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_rebase", { repo, onto })),
vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked })),
vcsStashApply: (repo: string | null, index: number, pop: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop })),
vcsStashDrop: (repo: string | null, index: number) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_drop", { repo, index })),
/** Network operation; progress lines stream through `on_output`. Cancellable. */
vcsRemote: (request: RequestId, op: RemoteOp, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remote", { request, op, onOutput })),
vcsRemote: (repo: string | null, request: RequestId, op: RemoteOp, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remote", { repo, request, op, onOutput })),
vcsConflictVersions: (path: string) => typedError<ConflictVersions, IdeError>(__TAURI_INVOKE("vcs_conflict_versions", { path })),
vcsDiffText: (path: string, staged: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_diff_text", { path, staged })),
/** Line hunks between two arbitrary texts (diff viewer, commit dialog). */
@@ -301,16 +303,16 @@ export const commands = {
/** The auto-detected git executable (shown as the field's placeholder). */
vcsDetectedGit: () => __TAURI_INVOKE<string | null>("vcs_detected_git"),
/** `git config --get <key>` in the project repository; `None` when unset. */
vcsConfigGet: (key: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_config_get", { key })),
vcsConfigGet: (repo: string | null, key: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_config_get", { repo, key })),
/**
* Local history operation that may take a while (hooks, large checkouts); git's
* output streams through `on_output`. Cancellable.
*/
vcsOperation: (request: RequestId, op: GitOperation, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_operation", { request, op, onOutput })),
vcsCreateTag: (name: string, commit: string, message: string | null, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_tag", { name, commit, message, force })),
vcsTags: () => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_tags")),
vcsOperation: (repo: string | null, request: RequestId, op: GitOperation, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_operation", { repo, request, op, onOutput })),
vcsCreateTag: (repo: string | null, name: string, commit: string, message: string | null, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_tag", { repo, name, commit, message, force })),
vcsTags: (repo: string | null) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_tags", { repo })),
/** Compare with Local: tracked files that differ between `rev` and the working tree. */
vcsDiffWithWorktree: (rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { rev })),
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
vcsCreatePatch: (source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { source })),
vcsPatchPreview: (patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { patch })),
vcsApplyPatch: (patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { patch })),
@@ -3898,6 +3900,14 @@ export type VcsConfirmationSettings = {
onDelete?: string,
};
/** A repository of the project (IDEA's VCS root). */
export type VcsRoot = {
/** Worktree root. */
root: IpcPath,
/** Merge / rebase / cherry-pick / revert in progress. */
operation: string | null,
};
/** Version Control (main page) and its sub-pages. */
export type VcsSettings = {
/** Colour folders that contain changed files (project tree, tabs). */
+1 -1
View File
@@ -71,7 +71,7 @@ function CreateDialog(props: {
useEffect(() => {
let alive = true;
// Base candidates: branches of the remotes (without `origin/`), the default first.
call(commands.vcsBranches())
call(commands.vcsBranches(null))
.then((branches) => {
const names = new Set<string>();
if (props.defaultBase) names.add(props.defaultBase);
+1 -1
View File
@@ -89,7 +89,7 @@ export async function createPullRequest() {
let needsPush = true;
let title = "";
try {
const info = await call(commands.vcsBranchInfo());
const info = await call(commands.vcsBranchInfo(null));
head = info.head;
needsPush = !info.upstream || info.ahead > 0;
if (head) title = head.replace(/^[^/]+\//, "").replace(/[-_]+/g, " ").replace(/^\w/, (c) => c.toUpperCase());
+42
View File
@@ -18,6 +18,48 @@
overflow: hidden;
text-overflow: ellipsis;
}
.repo {
flex: none;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-muted);
}
.repo::after {
content: " /";
color: var(--fg-subtle);
}
.repos {
flex: none;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border-popup);
}
.repoButton {
max-width: 160px;
height: 22px;
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: none;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-secondary);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.repoButton:hover {
background: var(--bg-hover);
}
.repoSelected,
.repoSelected:hover {
background: var(--bg-selected);
color: var(--fg);
}
.operation {
color: var(--warning);
font-size: var(--fs-tiny);
+29 -1
View File
@@ -19,7 +19,7 @@ import { buildBranchRows, type BranchRow, type PopupAction } from "./branches";
import { branchMenu } from "./branchMenu";
import { recentBranches } from "./branchOps";
import { inProgress, operationTitle, type InProgress } from "./operations";
import { loadBranches, useVcs } from "./store";
import { loadBranches, repoName, selectRepo, useVcs } from "./store";
import styles from "./BranchWidget.module.css";
export const useBranchesPopup = create<{ open: boolean }>(() => ({ open: false }));
@@ -34,6 +34,8 @@ export function headLabel(branch: { head: string | null; headCommit: string | nu
export function BranchWidget() {
const available = useVcs((s) => s.available);
const branch = useVcs((s) => s.branch);
// With several repositories the widget names the selected one, like IDEA's.
const repo = useVcs((s) => (s.roots.length > 1 && s.repo ? repoName(s.repo) : null));
const open = useBranchesPopup((s) => s.open);
const ref = useRef<HTMLButtonElement>(null);
if (!available || !branch) return null;
@@ -43,6 +45,7 @@ export function BranchWidget() {
branch.ahead > 0 ? `${branch.ahead} outgoing` : "",
].filter(Boolean);
const tooltip =
(repo ? `Repository: ${repo}\n` : "") +
(branch.upstream
? `Git branch: ${headLabel(branch)}${op}\nTracking ${branch.upstream}`
: `Git branch: ${headLabel(branch)}${op}`) + (sync.length ? `\n${sync.join(", ")} commits` : "");
@@ -58,6 +61,7 @@ export function BranchWidget() {
onClick={() => useBranchesPopup.setState({ open: !open })}
>
<Icon name={branch.head ? "branch" : "commit"} size={16} className={styles.icon} />
{repo && <span className={styles.repo}>{repo}</span>}
<span className={styles.name}>
{inProgress(branch.operation) ? operationTitle(inProgress(branch.operation)!, branch.head) : headLabel(branch)}
</span>
@@ -179,6 +183,7 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
aria-label="Git Branches"
className={styles.popup}
>
<RepositoryBar />
<div className={styles.search}>
<SearchField
data-autofocus
@@ -237,6 +242,29 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject<HTMLElement | nu
);
}
/** Repository switcher (several repositories): branches, log and push act on the selected one. */
function RepositoryBar() {
const roots = useVcs((s) => s.roots);
const repo = useVcs((s) => s.repo);
if (roots.length < 2) return null;
return (
<div className={styles.repos} role="group" aria-label="Repository">
{roots.map((r) => (
<button
key={r}
type="button"
className={clsx(styles.repoButton, r === repo && styles.repoSelected)}
aria-pressed={r === repo}
title={r}
onClick={() => void selectRepo(r).then(() => loadBranches().catch(() => undefined))}
>
{repoName(r)}
</button>
))}
</div>
);
}
function BranchRowView({ row }: { row: BranchRow }) {
if (row.type === "action") {
const keys = shortcutLabel(row.action.id);
+15 -3
View File
@@ -43,7 +43,7 @@ import { KIND_COLOR, KIND_LABEL, formatTime, stashLabel, stripSlash } from "./mo
import { call, commands } from "@/platform/ipc";
import { OperationBanner } from "./operations";
import { ShelfPanel, loadShelves, useShelf } from "./shelf";
import { loadStashes, refreshNow, useVcs } from "./store";
import { currentRepo, loadStashes, refreshNow, useVcs } from "./store";
import styles from "./CommitView.module.css";
/** MIME type of changed files dragged between change lists (absolute paths, newline-separated). */
@@ -243,7 +243,11 @@ function ChangesPanel() {
const treeRef = useRef<TreeHandle>(null);
const viewRef = useRef<HTMLButtonElement>(null);
const roots = useMemo(() => buildChangeTree(status, root, groupByDir, lists), [status, root, groupByDir, lists]);
const repos = useVcs((s) => s.roots);
const roots = useMemo(
() => buildChangeTree(status, root, groupByDir, lists, repos),
[status, root, groupByDir, lists, repos],
);
const [byKey, parents] = useMemo(() => {
const m = new Map<string, ChangeNode>();
const up = new Map<string, string>();
@@ -556,7 +560,15 @@ function CommitMessage({ included }: { included: number }) {
if (v && !useCommit.getState().message.trim()) {
try {
const [last] = await call(
commands.vcsLog({ branch: "HEAD", path: null, author: null, text: null, range: null, skip: 0, limit: 1 }),
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
text: null,
range: null,
skip: 0,
limit: 1,
}),
);
if (last && !useCommit.getState().message.trim()) {
amendMessage.current = last.message;
+2 -2
View File
@@ -6,7 +6,7 @@ import { call, commands } from "@/platform/ipc";
import { Dialog, List, Spinner, Tabs, T } from "@/ui";
import { openDialog } from "./dialogs";
import { formatTime } from "./model";
import { useVcs } from "./store";
import { currentRepo, useVcs } from "./store";
import styles from "./common.module.css";
const LIMIT = 2000;
@@ -14,7 +14,7 @@ const LIMIT = 2000;
/** Commits reachable from `to` but not from `from` (git's `from..to`), computed by git, unbounded by any window. */
const commitsBetween = (from: string, to: string) =>
call(
commands.vcsLog({
commands.vcsLog(currentRepo(), {
branch: null,
path: null,
author: null,
+2 -2
View File
@@ -48,7 +48,7 @@ import {
} from "./logStore";
import { KIND_COLOR, baseName, formatTime, relTo, type EffectiveKind } from "./model";
import { clearConsole, useVcsConsole } from "./ops";
import { loadBranches, useVcs } from "./store";
import { currentRepo, loadBranches, useVcs } from "./store";
import { checkoutBranch, loadFavoriteBranches, newBranch, useFavoriteBranches } from "./branchOps";
import { branchMenu } from "./branchMenu";
import { branchFolderKeys, branchGroupKeys, buildBranchTree, filterBranchTree, type BranchTreeNode } from "./branches";
@@ -711,7 +711,7 @@ function CommitDetails({ commits }: { commits: Commit[] }) {
useEffect(() => {
if (!selected) return;
let alive = true;
call(commands.vcsCommitFiles(selected))
call(commands.vcsCommitFiles(currentRepo(), selected))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
+51 -1
View File
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import type { FileStatus } from "@/bindings";
import { aggregateCheck, buildChangeTree, initialCheck, planCommit, type CheckState, type ChangeNode } from "../changes";
import {
aggregateCheck,
buildChangeTree,
commitRepos,
initialCheck,
planCommit,
type CheckState,
type ChangeNode,
} from "../changes";
const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
@@ -54,6 +62,48 @@ describe("buildChangeTree", () => {
});
});
describe("several repositories", () => {
const nested = [...status, fs("libs/core/Lib.kt", null, "modified"), fs("libs/core/new.txt", null, "untracked")];
const repos = ["/r", "/r/libs/core"];
it("groups each change group by repository", () => {
expect(shape(buildChangeTree(nested, "/r", false, undefined, repos))).toEqual([
"Merge Conflicts",
" r",
" conf.yml",
"Changes",
" r",
" App.kt",
" AppTest.kt",
" Partial.kt",
" README.md",
" libs/core",
" Lib.kt",
"Unversioned Files",
" r",
" notes.txt",
" libs/core",
" new.txt",
]);
const changes = buildChangeTree(nested, "/r", true, undefined, repos)[1]!;
expect(changes.children!.map((n) => [n.label, n.path])).toEqual([
["r", "/r"],
["libs/core", "/r/libs/core"],
]);
expect(shape(changes.children![1]!.children!)).toEqual(["Lib.kt"]);
// One repository: no repository level.
const single = buildChangeTree(status, "/r", false, undefined, ["/r"]);
expect(shape(single)).toEqual(shape(buildChangeTree(status, "/r", false)));
});
it("commits every repository with included changes, the selected one last", () => {
const included = [nested[0]!, nested[7]!];
expect(commitRepos(included, repos, "/r")).toEqual(["/r/libs/core", "/r"]);
expect(commitRepos(included.slice(0, 1), repos, "/r/libs/core")).toEqual(["/r"]);
expect(commitRepos([], repos, "/r/libs/core")).toEqual(["/r/libs/core"]);
});
});
describe("commit inclusion", () => {
it("defaults: tracked changes included, partial files keep their staged part, unversioned excluded", () => {
expect(initialCheck(status[0]!)).toBe("all");
@@ -11,6 +11,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
...real.commands,
vcsStatus: () => vcsStatus(),
vcsBranchInfo: async () => ({ status: "ok", data: info() }),
vcsRoots: async () => ({ status: "ok", data: [{ root: "/p", operation: null }] }),
},
};
});
+33 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { messageProblems, wrapAtMargin } from "../CommitView";
import { linkifyIssues } from "../issueLinks";
import { isProtectedBranch, isPushRejected } from "../remoteUi";
import { defaultPushChecked, isProtectedBranch, isPushRejected, pushCandidates, type RepoPush } from "../remoteUi";
import { attentionRepo } from "../store";
import { statusDecorations } from "../model";
const cfg = { blankLineAfterSubject: true, limitSubject: true, subjectLimit: 10, limitBody: true, bodyLimit: 8 };
@@ -59,3 +60,34 @@ describe("git settings helpers", () => {
expect(statusDecorations(status, false).get("/p/src/a.ts")?.propagate).toBeUndefined();
});
});
describe("several repositories", () => {
it("selects a repository that just stopped in an operation", () => {
expect(attentionRepo("/p", {}, { "/p/lib": "merge" })).toBe("/p/lib");
expect(attentionRepo("/p", { "/p/lib": "merge" }, { "/p/lib": "merge" })).toBeNull();
expect(attentionRepo("/p", {}, { "/p": "rebase", "/p/lib": "merge" })).toBeNull();
expect(attentionRepo("/p", {}, {})).toBeNull();
});
it("lists repositories with something to push", () => {
const branch = (head: string | null, upstream: string | null) => ({
head,
headCommit: "c1",
upstream,
ahead: 0,
behind: 0,
operation: null,
});
const commit = { id: "c1", shortId: "c1" } as RepoPush["commits"][number];
const repos: RepoPush[] = [
{ root: "/p", branch: branch("main", "origin/main"), commits: [commit] },
{ root: "/p/a", branch: branch("main", "origin/main"), commits: [] },
{ root: "/p/b", branch: branch("feature", null), commits: [commit] },
{ root: "/p/c", branch: branch(null, null), commits: [] },
];
const candidates = pushCandidates(repos);
expect(candidates.map((r) => r.root)).toEqual(["/p", "/p/b"]);
expect([...defaultPushChecked(candidates, "/p")]).toEqual(["/p"]);
expect([...defaultPushChecked(candidates, "/p/b")]).toEqual(["/p", "/p/b"]);
});
});
+2 -2
View File
@@ -16,7 +16,7 @@ import { openDialog } from "./dialogs";
import { gotoChange } from "./gutter";
import { openLocalHistory } from "./localHistoryKind";
import { revealCommit, setLogFilters, useGitLog, emptyFilters } from "./logStore";
import { runRemote } from "./ops";
import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget } from "./remoteUi";
import { chooseRevision } from "./RevisionChooser";
import { showCloneDialog } from "./clone";
@@ -150,7 +150,7 @@ export function registerVcsActions(): () => void {
category: "Git",
icon: "git-fetch",
when: VCS,
run: () => void runRemote({ op: "fetch" }),
run: () => void runRemote({ op: "fetch" }, undefined, allRepos()),
},
{
id: "vcs.refresh",
+11 -10
View File
@@ -7,7 +7,7 @@ import { notifications } from "@/workbench/contributions";
import { isFavoriteBranch, pushRecent, shortRemoteName } from "./branches";
import { confirmDialog, promptText } from "./dialogs";
import { runOp, runRemote, saveAll } from "./ops";
import { conflictedFiles, refreshNow, useVcs } from "./store";
import { conflictedFiles, currentRepo, refreshNow, useVcs } from "./store";
const recentKey = () => `openidea.vcs.recentBranches:${useVcs.getState().root ?? ""}`;
@@ -46,7 +46,7 @@ export function validateBranchName(name: string): string | null {
export async function checkoutBranch(name: string) {
const previous = useVcs.getState().branch?.head;
await saveAll();
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(name, false)), {
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(currentRepo(), name, false)), {
success: () => `Checked out ${useVcs.getState().branch?.head ?? name}`,
});
if (ok !== undefined) {
@@ -72,7 +72,8 @@ export async function newBranch(from?: string) {
const checkout = r.checked;
const previous = useVcs.getState().branch?.head;
if (checkout) await saveAll();
const ok = await runOp(`Create branch ${r.value}`, () => call(commands.vcsCreateBranch(r.value, from ?? null, checkout)), {
const create = () => call(commands.vcsCreateBranch(currentRepo(), r.value, from ?? null, checkout));
const ok = await runOp(`Create branch ${r.value}`, create, {
success: checkout ? `Created and checked out ${r.value}` : `Created branch ${r.value}`,
});
if (ok !== undefined && checkout) {
@@ -91,7 +92,7 @@ export async function checkoutRevision() {
});
if (!r) return;
await saveAll();
await runOp(`Checkout ${r.value}`, () => call(commands.vcsCheckout(r.value, false)), {
await runOp(`Checkout ${r.value}`, () => call(commands.vcsCheckout(currentRepo(), r.value, false)), {
success: `Checked out ${r.value}`,
});
}
@@ -116,7 +117,7 @@ async function openConflictsInstead(): Promise<boolean> {
export async function mergeIntoCurrent(branch: string) {
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(branch)), {
await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(currentRepo(), branch)), {
success: `Merged ${branch} into ${current}`,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -126,7 +127,7 @@ export async function mergeIntoCurrent(branch: string) {
export async function rebaseOnto(branch: string) {
const current = useVcs.getState().branch?.head ?? "HEAD";
await saveAll();
await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(branch)), {
await runOp(`Rebase ${current} onto ${branch}`, () => call(commands.vcsRebase(currentRepo(), branch)), {
success: `Rebased ${current} onto ${branch}`,
errorActions: conflictActions,
onError: openConflictsInstead,
@@ -144,7 +145,7 @@ export async function renameBranch(name: string) {
});
if (!r) return;
const to = r.value.trim();
const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(name, to)), {
const ok = await runOp(`Rename branch ${name}`, () => call(commands.vcsRenameBranch(currentRepo(), name, to)), {
success: `Renamed branch ${name} to ${to}`,
});
if (ok !== undefined) {
@@ -164,7 +165,7 @@ export async function deleteBranch(name: string) {
danger: true,
});
if (!ok) return;
await runOp(`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(name, false)), {
await runOp(`Delete branch ${name}`, () => call(commands.vcsDeleteBranch(currentRepo(), name, false)), {
success: `Deleted branch ${name}`,
errorActions: (m) =>
/not fully merged/i.test(m)
@@ -172,7 +173,7 @@ export async function deleteBranch(name: string) {
{
label: "Force Delete",
run: () =>
void runOp(`Force delete ${name}`, () => call(commands.vcsDeleteBranch(name, true)), {
void runOp(`Force delete ${name}`, () => call(commands.vcsDeleteBranch(currentRepo(), name, true)), {
success: `Deleted branch ${name}`,
}),
},
@@ -198,7 +199,7 @@ export async function checkoutAndRebase(name: string) {
const current = useVcs.getState().branch?.head;
if (!current) return;
await saveAll();
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(name, false)));
const ok = await runOp(`Checkout ${name}`, () => call(commands.vcsCheckout(currentRepo(), name, false)));
if (ok === undefined) return;
rememberBranch(current);
rememberBranch(name);
+13 -7
View File
@@ -4,14 +4,14 @@ import type { FileStatus } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { Checkbox, Dialog, Icon, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { groupOf, planCommit, renamedFromPath } from "./changes";
import { commitRepos, groupOf, planCommit, renamedFromPath } from "./changes";
import { useChangelists, withListDefaults } from "./changelists";
import { runCommitChecks } from "./commitChecks";
import { rememberMessage, useCommit } from "./commitStore";
import { confirmDialog, openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, stripSlash } from "./model";
import { logConsole, runOp, saveAll } from "./ops";
import { loadStashes, refreshNow, useVcs } from "./store";
import { currentRepo, loadStashes, refreshNow, useVcs } from "./store";
import styles from "./common.module.css";
export function firstLine(message: string): string {
@@ -48,18 +48,24 @@ export async function commitChanges(): Promise<string | undefined> {
}
const candidates = plan.included.map((f) => ({ path: stripSlash(f.path), exists: effectiveKind(f) !== "deleted" }));
if (!(await runCommitChecks(candidates, amend))) return undefined;
// Like IDEA, every repository with included changes gets a commit with the same message.
const repos = commitRepos(plan.included, useVcs.getState().roots, currentRepo());
const id = await runOp(
amend ? "Amend commit" : "Commit",
async () => {
if (plan.unstage.length) await call(commands.vcsUnstage(plan.unstage));
if (plan.stage.length) await call(commands.vcsStage(plan.stage));
return call(commands.vcsCommit(message.trim(), { amend, signOff, author: author.trim() || null, runHooks }));
const options = { amend, signOff, author: author.trim() || null, runHooks };
let id = "";
for (const repo of repos) id = await call(commands.vcsCommit(repo, message.trim(), options));
return id;
},
{
success: () => {
const n = plan.included.length;
const summary = firstLine(message);
return `${n} file${n === 1 ? "" : "s"} ${amend ? "amended" : "committed"}${summary ? `: ${summary}` : ""}`;
const where = repos.length > 1 ? ` in ${repos.length} repositories` : "";
return `${n} file${n === 1 ? "" : "s"} ${amend ? "amended" : "committed"}${where}${summary ? `: ${summary}` : ""}`;
},
},
);
@@ -221,7 +227,7 @@ export async function stashChanges() {
});
if (!r) return;
await saveAll();
await runOp("Stash changes", () => call(commands.vcsStashPush(r.value || null, r.checked)), {
await runOp("Stash changes", () => call(commands.vcsStashPush(currentRepo(), r.value || null, r.checked)), {
success: "Changes stashed",
});
void loadStashes().catch(() => undefined);
@@ -229,7 +235,7 @@ export async function stashChanges() {
export async function applyStash(index: number, pop: boolean) {
await saveAll();
await runOp(`${pop ? "Pop" : "Apply"} stash@{${index}}`, () => call(commands.vcsStashApply(index, pop)), {
await runOp(`${pop ? "Pop" : "Apply"} stash@{${index}}`, () => call(commands.vcsStashApply(currentRepo(), index, pop)), {
success: pop ? `Unstashed stash@{${index}}` : `Applied stash@{${index}}`,
});
void loadStashes().catch((e) => logConsole("err", String(e)));
@@ -243,7 +249,7 @@ export async function dropStash(index: number, message: string) {
danger: true,
});
if (!ok) return;
await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(index)), {
await runOp(`Drop stash@{${index}}`, () => call(commands.vcsStashDrop(currentRepo(), index)), {
success: `Dropped stash@{${index}}`,
});
void loadStashes().catch((e) => logConsole("err", String(e)));
+51 -4
View File
@@ -1,6 +1,6 @@
/** Commit tool window model: change groups, directory tree, inclusion (pure). */
import type { ChangelistState, FileStatus } from "@/bindings";
import { baseName, dirName, effectiveKind, relTo, stripSlash, type EffectiveKind } from "./model";
import { baseName, dirName, effectiveKind, relTo, repoLabel, rootOf, stripSlash, type EffectiveKind } from "./model";
export type GroupId = "conflicts" | "changes" | "unversioned";
@@ -156,6 +156,37 @@ function childNodes(files: readonly FileStatus[], root: string, group: GroupId,
.map((f) => fileNode(f, root, group, true, scope));
}
/** With several repositories, a node per repository (IDEA's "Group by Repository") above the files. */
function repoNodes(
files: readonly FileStatus[],
root: string,
group: GroupId,
groupByDir: boolean,
scope: string,
repos: readonly string[],
): ChangeNode[] {
if (repos.length < 2) return childNodes(files, root, group, groupByDir, scope);
const byRepo = new Map<string, FileStatus[]>();
for (const f of files) {
const repo = rootOf(f.path, repos) ?? root;
let list = byRepo.get(repo);
if (!list) byRepo.set(repo, (list = []));
list.push(f);
}
const order = (r: string) => (repos.includes(r) ? repos.indexOf(r) : repos.length);
return [...byRepo]
.sort(([a], [b]) => order(a) - order(b))
.map(([repo, own]) => ({
key: `${scope}:repo:${repo}`,
type: "dir" as const,
label: repoLabel(repo, root),
group,
children: childNodes(own, repo, group, groupByDir, `${scope}@${repo}`),
files: own,
path: repo,
}));
}
function tagList(nodes: ChangeNode[], id: string): ChangeNode[] {
for (const n of nodes) {
n.changelist = id;
@@ -167,13 +198,15 @@ function tagList(nodes: ChangeNode[], id: string): ChangeNode[] {
/**
* Group roots (Merge Conflicts / Changes / Unversioned Files) with flat or directory
* children. With change lists, "Changes" is split into one group per list
* (`ChangelistState.files` keys are project-relative paths).
* (`ChangelistState.files` keys are project-relative paths). With several
* repositories (`repos`), each group's files are grouped by repository first.
*/
export function buildChangeTree(
status: readonly FileStatus[],
root: string,
groupByDir: boolean,
lists?: ChangelistState,
repos: readonly string[] = [],
): ChangeNode[] {
const byGroup = new Map<GroupId, FileStatus[]>();
for (const fs of status) {
@@ -202,7 +235,7 @@ export function buildChangeTree(
type: "group",
label: l.name,
group: g,
children: tagList(childNodes(own, root, g, groupByDir, scope), l.id),
children: tagList(repoNodes(own, root, g, groupByDir, scope, repos), l.id),
files: own,
changelist: l.id,
active: l.id === lists.active,
@@ -212,7 +245,7 @@ export function buildChangeTree(
continue;
}
if (!files.length) continue;
const children = childNodes(files, root, g, groupByDir, g);
const children = repoNodes(files, root, g, groupByDir, g, repos);
roots.push({ key: `group:${g}`, type: "group", label: GROUP_LABEL[g], group: g, children, files });
}
return roots;
@@ -225,6 +258,20 @@ export interface CommitPlan {
included: FileStatus[];
}
/**
* Repositories to commit: those holding `included` files (the selected one last, so its
* commit id is returned), else just the selected one (amend, concluding a merge).
*/
export function commitRepos(
included: readonly FileStatus[],
roots: readonly string[],
selected: string | null,
): Array<string | null> {
const repos = [...new Set(included.map((f) => rootOf(f.path, roots)).filter((r): r is string => !!r))];
if (!repos.length) return [selected];
return selected && repos.includes(selected) ? [...repos.filter((r) => r !== selected), selected] : repos;
}
/** Absolute path of the source of a staged rename (`renamedFrom` is repository-relative). */
export function renamedFromPath(fs: FileStatus): string | null {
if (!fs.renamedFrom) return null;
+2 -2
View File
@@ -13,7 +13,7 @@ import { maybeEditorService, toolWindowService } from "@/workbench/contributions
import { ask } from "@/workbench/messages";
import { baseName, relTo } from "./model";
import { logConsole } from "./ops";
import { useVcs } from "./store";
import { currentRepo, useVcs } from "./store";
import styles from "./common.module.css";
// ---------------------------------------------------------------------------
@@ -308,7 +308,7 @@ export async function runCommitChecks(files: readonly CommitCandidate[], amend:
}
if (git.warnCrlf) {
const autocrlf = (await call(commands.vcsConfigGet("core.autocrlf")).catch(() => null))?.toLowerCase();
const autocrlf = (await call(commands.vcsConfigGet(currentRepo(), "core.autocrlf")).catch(() => null))?.toLowerCase();
if (autocrlf !== "true" && autocrlf !== "input") {
const crlf: string[] = [];
for (const p of existing) {
+3 -3
View File
@@ -42,7 +42,7 @@ import {
} from "./rebase";
import { showApplyPatch, showCreatePatchForChanges, showCreatePatchForCommits } from "./patches";
import { deleteShelf, renameShelf, shelveChanges, showShelf, unshelve, useShelf } from "./shelf";
import { useVcs } from "./store";
import { currentRepo, useVcs } from "./store";
const VCS = "vcsAvailable";
const IN_GIT_LOG = "vcsAvailable && activeToolWindow == git";
@@ -83,7 +83,7 @@ function withCommits(fn: (cs: Commit[]) => unknown) {
async function openCommitInBrowser(c: Commit) {
const upstream = useVcs.getState().branch?.upstream;
const remote = upstream ? upstream.slice(0, upstream.indexOf("/")) : "origin";
const url = await call(commands.vcsConfigGet(`remote.${remote}.url`)).catch(() => null);
const url = await call(commands.vcsConfigGet(currentRepo(), `remote.${remote}.url`)).catch(() => null);
const page = url ? commitWebUrl(url, c.id) : null;
if (page) await openUrl(page);
else notifications().notify({ group: "vcs", level: "warning", title: `No web URL for remote '${remote}'` });
@@ -122,7 +122,7 @@ function shelfArg(arg: unknown) {
async function checkoutCommit(c: Commit) {
await saveAll();
await runOp(`Checkout ${c.shortId}`, () => call(commands.vcsCheckout(c.id, false)), {
await runOp(`Checkout ${c.shortId}`, () => call(commands.vcsCheckout(currentRepo(), c.id, false)), {
success: `Checked out ${c.shortId} (detached HEAD)`,
});
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { Channel } from "@tauri-apps/api/core";
import { call, commands, isTauri, nextRequestId } from "@/platform/ipc";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { logConsole } from "./ops";
import { loadBranches, refreshNow, useVcs } from "./store";
import { currentRepo, loadBranches, refreshNow, useVcs } from "./store";
const FIRST_DELAY = 20_000;
const INTERVAL = 15 * 60_000;
@@ -32,7 +32,7 @@ export function installIncomingCheck(): () => void {
const channel = new Channel<string>();
channel.onmessage = () => undefined;
try {
await call(commands.vcsRemote(nextRequestId(), { op: "fetch" }, channel));
await call(commands.vcsRemote(currentRepo(), nextRequestId(), { op: "fetch" }, channel));
await refreshNow();
if (useVcs.getState().branchesLoaded) void loadBranches().catch(() => undefined);
} catch (e) {
+31 -9
View File
@@ -5,7 +5,7 @@ import { call, commands } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph";
import { clearConsole, errorMessage } from "./ops";
import { useVcs } from "./store";
import { currentRepo, repoOf, selectRepo, useVcs } from "./store";
export interface LogFilters {
branch: string | null;
@@ -71,19 +71,33 @@ export const useGitLog = create<LogState>(() => ({ ...initialLog(), generation:
let loadPromise: Promise<void> | null = null;
/** Another repository (the window switched projects): forget its commits, filters, selection and console. */
/**
* Another project (the window switched projects): forget its commits, filters, selection
* and console. Another repository of the project selected: show its log.
*/
export function installGitLog(): () => void {
return useVcs.subscribe((s, prev) => {
if (s.root === prev.root) return;
loadPromise = null;
useGitLog.setState((l) => ({ ...initialLog(), tab: l.tab, generation: l.generation + 1 }));
clearConsole();
if (s.root !== prev.root) {
loadPromise = null;
useGitLog.setState((l) => ({ ...initialLog(), tab: l.tab, generation: l.generation + 1 }));
clearConsole();
} else if (s.repo !== prev.repo && prev.repo !== null) {
const l = useGitLog.getState();
if (l.generation === 0 && !l.filters.path) return;
const { path } = useGitLog.getState().filters;
// A path filter belongs to the repository it is in.
if (path && repoOf(path) !== s.repo) useGitLog.setState((l) => ({ filters: { ...l.filters, path: null } }));
void reloadLog();
}
});
}
export function setLogFilters(patch: Partial<LogFilters>) {
useGitLog.setState((s) => ({ filters: { ...s.filters, ...patch } }));
void reloadLog();
// History of a file in another repository: that repository's log.
const repo = patch.path ? repoOf(patch.path) : null;
if (repo && repo !== currentRepo()) void selectRepo(repo);
else void reloadLog();
}
/** Drops loaded commits and loads the first page. */
@@ -106,7 +120,15 @@ async function loadOutgoing() {
if (!upstream) return useGitLog.setState({ outgoing: null });
try {
const commits = await call(
commands.vcsLog({ branch: "HEAD", path: null, author: null, text: null, range: "@{u}..HEAD", skip: 0, limit: 0 }),
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
text: null,
range: "@{u}..HEAD",
skip: 0,
limit: 0,
}),
);
useGitLog.setState({ outgoing: new Set(commits.map((c) => c.id)) });
} catch {
@@ -137,7 +159,7 @@ export function loadMore(): Promise<void> {
const p = (async () => {
try {
const page = await call(
commands.vcsLog({
commands.vcsLog(currentRepo(), {
branch: f.branch,
path: f.path,
author: f.author.trim() || null,
+14
View File
@@ -92,6 +92,20 @@ export function relTo(root: string, path: string): string {
return path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
}
/** The innermost of `roots` (repository roots) containing `path`. */
export function rootOf(path: string, roots: readonly string[]): string | null {
const p = stripSlash(path);
let best: string | null = null;
for (const r of roots) if ((p === r || p.startsWith(`${r}/`)) && (!best || r.length > best.length)) best = r;
return best;
}
/** Repository name for display: its folder relative to the project, else its folder name. */
export function repoLabel(root: string, projectRoot: string | null): string {
if (projectRoot && root.startsWith(`${projectRoot}/`)) return root.slice(projectRoot.length + 1);
return baseName(root) || root;
}
export function baseName(path: string): string {
const p = stripSlash(path);
const i = p.lastIndexOf("/");
+11 -7
View File
@@ -14,7 +14,7 @@ import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName } from "./model";
import { runGitOperation, runOp } from "./ops";
import { reloadLog, useGitLog } from "./logStore";
import { conflictedFiles, refreshNow, useVcs } from "./store";
import { conflictedFiles, currentRepo, refreshNow, repoOf, useVcs } from "./store";
import styles from "./common.module.css";
// ---------------------------------------------------------------------------
@@ -84,7 +84,10 @@ export async function controlOperation(control: "continue" | "skip" | "abort", e
await refreshNow();
const op = inProgress();
if (!op || (expected && op !== expected)) return notInProgress(expected);
if (control === "continue" && conflictedFiles().length) {
// Only the selected repository's conflicts block it (others may be in their own merge).
const repo = currentRepo();
const conflicts = conflictedFiles().filter((f) => !repo || repoOf(f.path) === repo);
if (control === "continue" && conflicts.length) {
notifications().notify({ group: "vcs", level: "warning", title: "Resolve all conflicts before continuing" });
void executeAction("vcs.resolveConflicts");
return;
@@ -301,7 +304,7 @@ export function validateTagName(name: string, existing: readonly string[] = []):
/** Log › New Tag…: a lightweight tag on the selected commit. */
export async function newTagOn(commit: Commit) {
const existing = await call(commands.vcsTags()).catch(() => [] as string[]);
const existing = await call(commands.vcsTags(currentRepo())).catch(() => [] as string[]);
const r = await promptText({
title: `Create New Tag On ${short(commit.id)}`,
label: "Enter the name of new tag:",
@@ -310,7 +313,8 @@ export async function newTagOn(commit: Commit) {
validate: (v) => validateTagName(v, existing),
});
if (!r) return;
const ok = await runOp(`Create tag ${r.value}`, () => call(commands.vcsCreateTag(r.value, commit.id, null, false)), {
const create = () => call(commands.vcsCreateTag(currentRepo(), r.value, commit.id, null, false));
const ok = await runOp(`Create tag ${r.value}`, create, {
success: `Created tag ${r.value} on ${short(commit.id)}`,
});
// Tags are not part of the refs signature that reloads the log.
@@ -329,7 +333,7 @@ function TagDialog({ onClose }: { onClose: () => void }) {
const [force, setForce] = useState(false);
const [existing, setExisting] = useState<string[]>([]);
useEffect(() => {
void call(commands.vcsTags()).then(setExisting, () => undefined);
void call(commands.vcsTags(currentRepo())).then(setExisting, () => undefined);
}, []);
const error = validateTagName(name, force ? [] : existing);
const run = async () => {
@@ -338,7 +342,7 @@ function TagDialog({ onClose }: { onClose: () => void }) {
const tag = name.trim();
const ok = await runOp(
`Create tag ${tag}`,
() => call(commands.vcsCreateTag(tag, commit.trim() || "HEAD", message || null, force)),
() => call(commands.vcsCreateTag(currentRepo(), tag, commit.trim() || "HEAD", message || null, force)),
{ success: `Created tag ${tag}` },
);
if (ok !== undefined && useGitLog.getState().commits.length) void reloadLog();
@@ -405,7 +409,7 @@ function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: s
const [active, setActive] = useState(0);
useEffect(() => {
let alive = true;
call(commands.vcsDiffWithWorktree(rev))
call(commands.vcsDiffWithWorktree(currentRepo(), rev))
.then((f) => alive && setFiles(f))
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
+21 -5
View File
@@ -9,7 +9,7 @@ import { call, commands, emitLocalIdeEvent, isCancelled, nextRequestId } from "@
import { editorsFor } from "@/editor/api";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { progressFraction } from "./model";
import { refreshNow, useVcs } from "./store";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
// ---------------------------------------------------------------------------
// Console (Git tool window → Console tab)
@@ -130,8 +130,21 @@ export function cancelRemote() {
if (activeRemote !== null) void commands.cancelRequest(activeRemote);
}
/** Runs fetch / pull / push with progress in the status bar and output in the console. */
export async function runRemote(op: RemoteOp, detail?: string): Promise<boolean> {
/** Every repository of the project (Fetch and Update Project act on all of them, as in IDEA). */
export function allRepos(): Array<string | null> {
const roots = useVcs.getState().roots;
return roots.length ? roots : [null];
}
/**
* Runs fetch / pull / push with progress in the status bar and output in the console,
* in each of `repos` (default: the selected repository) in turn.
*/
export async function runRemote(
op: RemoteOp,
detail?: string,
repos: ReadonlyArray<string | null> = [currentRepo()],
): Promise<boolean> {
if (useVcs.getState().remoteBusy) {
notifications().notify({ group: "vcs", level: "warning", title: "Another remote operation is in progress" });
return false;
@@ -163,7 +176,10 @@ export async function runRemote(op: RemoteOp, detail?: string): Promise<boolean>
task(text || null, progressFraction(text), false);
};
try {
await call(commands.vcsRemote(request, op, channel));
for (const repo of repos) {
if (repos.length > 1 && repo) logConsole("info", repoName(repo));
await call(commands.vcsRemote(repo, request, op, channel));
}
lastRemoteError = null;
notifications().notify({
group: "vcs",
@@ -239,7 +255,7 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption
task(line.trim() || null, false);
};
try {
await call(commands.vcsOperation(request, op, channel));
await call(commands.vcsOperation(currentRepo(), request, op, channel));
let success = opts.success;
if (typeof success === "function") {
await refreshNow();
+4 -3
View File
@@ -26,7 +26,7 @@ import {
type RebaseRange,
type RebaseRow,
} from "./rebasePlan";
import { refreshNow, useVcs } from "./store";
import { currentRepo, refreshNow, useVcs } from "./store";
import styles from "./common.module.css";
const short = (id: string) => id.slice(0, 8);
@@ -56,7 +56,8 @@ export async function undoCommit(commit: Commit) {
});
if (!ok) return;
}
const message = await runOp(`Undo commit ${short(commit.id)}`, () => call(commands.vcsUndoCommit(commit.id)), {
const undo = () => call(commands.vcsUndoCommit(currentRepo(), commit.id));
const message = await runOp(`Undo commit ${short(commit.id)}`, undo, {
success: `Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`,
});
// IDEA offers the undone message for the next commit.
@@ -78,7 +79,7 @@ async function rangeFrom(target: Commit, verb: string): Promise<RebaseRange | nu
const parent = target.parents[0];
try {
const history = await call(
commands.vcsLog({
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
+185 -12
View File
@@ -1,14 +1,14 @@
/** Update Project and Push dialogs. */
import { useEffect, useState } from "react";
import type { Commit, PushTags, RemoteOp } from "@/bindings";
import type { BranchInfo, Commit, PushTags, RemoteOp } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifications } from "@/workbench/contributions";
import { Checkbox, Dialog, List, Select, Spinner, T } from "@/ui";
import { openDialog } from "./dialogs";
import { formatTime } from "./model";
import { lastRemoteFailure, runRemote, saveAll } from "./ops";
import { refreshNow, useVcs } from "./store";
import { allRepos, lastRemoteFailure, runRemote, saveAll } from "./ops";
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
import styles from "./common.module.css";
export function showUpdateDialog() {
@@ -22,7 +22,14 @@ function UpdateDialog({ onClose }: { onClose: () => void }) {
const run = async () => {
onClose();
await saveAll();
await runRemote({ op: "pull", rebase }, branch?.upstream ? `${branch.head} ← ${branch.upstream}` : undefined);
const repos = allRepos();
const detail =
repos.length > 1
? `${repos.length} repositories`
: branch?.upstream
? `${branch.head} ← ${branch.upstream}`
: undefined;
await runRemote({ op: "pull", rebase }, detail, repos);
};
return (
<Dialog
@@ -70,8 +77,8 @@ function UpdateDialog({ onClose }: { onClose: () => void }) {
);
}
const logOf = (branch: string, limit: number, range: string | null = null) =>
call(commands.vcsLog({ branch, path: null, author: null, text: null, range, skip: 0, limit }));
const logOf = (branch: string, limit: number, range: string | null = null, repo = currentRepo()) =>
call(commands.vcsLog(repo, { branch, path: null, author: null, text: null, range, skip: 0, limit }));
/** Outgoing commits of the current branch: `@{u}..HEAD`, or recent HEAD history without an upstream. */
export function outgoingQuery(upstream: string | null): { branch: string; range: string | null; limit: number } {
@@ -87,7 +94,169 @@ export interface PushTarget {
export async function showPushDialog(target: PushTarget = {}) {
await refreshNow();
openDialog((close) => <PushDialog target={target} onClose={close} />);
// Several repositories: IDEA's dialog lists each one with outgoing commits.
const multi = useVcs.getState().roots.length > 1 && !target.branch && !target.revision;
openDialog((close) => (multi ? <MultiPushDialog onClose={close} /> : <PushDialog target={target} onClose={close} />));
}
/** A repository in the multi-repository Push dialog. */
export interface RepoPush {
root: string;
branch: BranchInfo;
commits: Commit[];
}
/** Repositories with something to push: outgoing commits, or a branch without upstream. */
export function pushCandidates(repos: readonly RepoPush[]): RepoPush[] {
return repos.filter((r) => r.branch.head && (r.branch.upstream ? r.commits.length > 0 : true));
}
/** Checked by default: repositories with outgoing commits (a new branch only in the selected one). */
export function defaultPushChecked(repos: readonly RepoPush[], selected: string | null): Set<string> {
return new Set(repos.filter((r) => (r.branch.upstream ? r.commits.length > 0 : r.root === selected)).map((r) => r.root));
}
type PushRow = { type: "repo"; repo: RepoPush } | { type: "commit"; repo: RepoPush; commit: Commit };
function MultiPushDialog({ onClose }: { onClose: () => void }) {
const roots = useVcs((s) => s.roots);
const [repos, setRepos] = useState<RepoPush[] | null>(null);
const [checked, setChecked] = useState<Set<string>>(() => new Set());
const [error, setError] = useState<string | null>(null);
const [force, setForce] = useState(false);
const [pushTags, setPushTags] = useState(false);
const [tagsMode, setTagsMode] = useState<PushTags>("all");
useEffect(() => {
let alive = true;
Promise.all(
roots.map(async (root) => {
const branch = await call(commands.vcsBranchInfo(root));
const q = outgoingQuery(branch.upstream);
const commits = branch.head ? await logOf(q.branch, q.limit, q.range, root) : [];
return { root, branch, commits };
}),
)
.then((all) => {
if (!alive) return;
const list = pushCandidates(all);
setRepos(list);
setChecked(defaultPushChecked(list, currentRepo()));
})
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
return () => {
alive = false;
};
}, [roots]);
const toggle = (root: string) =>
setChecked((prev) => {
const next = new Set(prev);
if (!next.delete(root)) next.add(root);
return next;
});
const targetOf = (r: RepoPush) => (r.branch.upstream ? r.branch.upstream.slice(r.branch.upstream.indexOf("/") + 1) : r.branch.head!);
const picked = (repos ?? []).filter((r) => checked.has(r.root));
const push = async () => {
onClose();
for (const r of picked) {
const upstream = r.branch.upstream;
const ok = await pushWithAutoUpdate(
{
op: "push",
force_with_lease: force && !isProtectedBranch(targetOf(r)),
set_upstream: !upstream,
tags: pushTags ? tagsMode : null,
},
`${repoName(r.root)}: ${r.branch.head} → ${upstream ?? "new remote branch"}`,
r.root,
);
if (!ok) return;
}
};
const rows: PushRow[] = (repos ?? []).flatMap((repo) => [
{ type: "repo" as const, repo },
...repo.commits.map((commit) => ({ type: "commit" as const, repo, commit })),
]);
return (
<Dialog
open
title="Push Commits"
onClose={onClose}
width={620}
footerStart={
<>
<Checkbox checked={force} onChange={setForce} label="Force (with lease)" />
<Checkbox checked={pushTags} onChange={setPushTags} label="Push tags:" />
<Select<PushTags>
value={tagsMode}
disabled={!pushTags}
aria-label="Tags to push"
onChange={setTagsMode}
options={[
{ value: "all", label: "All" },
{ value: "currentBranch", label: "Current Branch" },
]}
/>
</>
}
buttons={[
{
label: force ? "Force Push" : "Push",
role: "default",
autoFocus: true,
disabled: !picked.length,
onClick: () => void push(),
},
{ label: "Cancel", role: "cancel" },
]}
>
<div style={{ height: 340, display: "flex", flexDirection: "column", padding: "14px" }}>
{error ? (
<div className={styles.error}>{error}</div>
) : !repos ? (
<div className={styles.empty}>
<Spinner size={14} />
</div>
) : (
<List
items={rows}
getKey={(r) => (r.type === "repo" ? `repo:${r.repo.root}` : `${r.repo.root}:${r.commit.id}`)}
rowHeight={22}
aria-label="Outgoing commits"
className={styles.boxList}
// Clicks on the checkbox toggle it themselves.
onActivate={(r, _i, e) => {
if (r.type === "repo" && !(e.target as HTMLElement | null)?.closest?.("label")) toggle(r.repo.root);
}}
empty={<span>Everything is up to date</span>}
renderItem={(r) =>
r.type === "repo" ? (
<span className={styles.commitRow} style={{ flex: 1, padding: 0 }}>
<Checkbox
checked={checked.has(r.repo.root)}
onChange={() => toggle(r.repo.root)}
label={repoName(r.repo.root)}
/>
<span className={styles.commitMeta}>
{r.repo.branch.head} → {r.repo.branch.upstream ?? "new remote branch"}
</span>
</span>
) : (
<span className={styles.commitRow} style={{ flex: 1, padding: "0 0 0 24px" }}>
<span className={styles.commitHash}>{r.commit.shortId}</span>
<span className={styles.commitSubject}>{r.commit.summary}</span>
<span className={styles.commitMeta}>{r.commit.author}</span>
<span className={styles.commitMeta}>{formatTime(r.commit.timeMs)}</span>
</span>
)
}
/>
)}
</div>
</Dialog>
);
}
function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClose: () => void }) {
@@ -262,16 +431,20 @@ export function isPushRejected(message: string): boolean {
* update method) and pushes again ("Auto-update if push of the current branch
* was rejected") or offers to.
*/
export async function pushWithAutoUpdate(op: Extract<RemoteOp, { op: "push" }>, detail: string): Promise<boolean> {
if (await runRemote(op, detail)) return true;
export async function pushWithAutoUpdate(
op: Extract<RemoteOp, { op: "push" }>,
detail: string,
repo: string | null = currentRepo(),
): Promise<boolean> {
if (await runRemote(op, detail, [repo])) return true;
const error = lastRemoteFailure();
if (!error || op.force_with_lease || !isPushRejected(error)) return false;
const git = workflowSettings().vcs.git;
const updateAndPush = async () => {
await saveAll();
if (!(await runRemote({ op: "pull", rebase: git.updateMethod === "rebase" }, "updating after rejected push")))
return false;
return runRemote(op, detail);
const pull = { op: "pull" as const, rebase: git.updateMethod === "rebase" };
if (!(await runRemote(pull, "updating after rejected push", [repo]))) return false;
return runRemote(op, detail, [repo]);
};
if (git.autoUpdateOnPushRejected) return updateAndPush();
notifications().notify({
+86 -8
View File
@@ -2,6 +2,11 @@
* Repository state shared by all VCS views: working tree status, HEAD /
* upstream info, branches and stashes. Refreshed (debounced) on `vcsChanged`
* and `fsChanged` events and after every operation.
*
* A project may hold several repositories (IDEA's VCS roots: nested clones,
* submodules). Status covers all of them; branch info, branches and stashes are
* those of the selected repository (`repo`), which branch / log / remote
* operations act on.
*/
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { create } from "zustand";
@@ -10,12 +15,18 @@ import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { setFileDecorations } from "@/workbench/contributions";
import { effectiveKind, statusDecorations, stripSlash } from "./model";
import { effectiveKind, repoLabel, rootOf, statusDecorations, stripSlash } from "./model";
export interface VcsState {
/** The open project is a git repository. */
available: boolean;
root: string | null;
/** Repository roots, the project's own repository first. */
roots: string[];
/** Root → merge / rebase / cherry-pick / revert in progress there. */
operations: Record<string, string>;
/** The selected repository's root (branch widget, log, push…). */
repo: string | null;
status: FileStatus[];
/** `status` reflects the repository (false until the first refresh). */
statusLoaded: boolean;
@@ -37,6 +48,9 @@ export interface VcsState {
const initial: VcsState = {
available: false,
root: null,
roots: [],
operations: {},
repo: null,
status: [],
statusLoaded: false,
byPath: new Map(),
@@ -53,6 +67,42 @@ const initial: VcsState = {
export const useVcs = create<VcsState>(() => ({ ...initial }));
/** Root of the selected repository; `null` = the project's primary one. */
export function currentRepo(): string | null {
return useVcs.getState().repo;
}
/** Root of the innermost repository containing `path`. */
export function repoOf(path: string, roots: readonly string[] = useVcs.getState().roots): string | null {
return rootOf(path, roots);
}
/** Repository name for display. */
export function repoName(root: string): string {
return repoLabel(root, useVcs.getState().root);
}
/**
* A repository that just stopped in a merge / rebase / cherry-pick / revert (e.g. on
* conflicts after Update Project) while `shown` has none in progress: it is selected so
* the banner, the branch widget and Continue / Abort act on it.
*/
export function attentionRepo(
shown: string | null,
before: Record<string, string>,
now: Record<string, string>,
): string | null {
if (shown && now[shown]) return null;
return Object.keys(now).find((root) => !before[root]) ?? null;
}
/** Selects the repository branch / log / remote operations act on. */
export function selectRepo(root: string): Promise<void> {
if (useVcs.getState().repo === root) return Promise.resolve();
useVcs.setState({ repo: root, branches: [], branchesLoaded: false, stashes: [], stashesLoaded: false });
return refreshNow();
}
export function conflictedFiles(status: readonly FileStatus[] = useVcs.getState().status): FileStatus[] {
return status.filter((f) => effectiveKind(f) === "conflicted");
}
@@ -98,14 +148,27 @@ export function refreshNow(): Promise<void> {
useVcs.setState({ loading: true });
try {
const s = useVcs.getState();
const [status, branch, branches, stashes] = await Promise.all([
const [vcsRoots, status, branch, branches, stashes] = await Promise.all([
call(commands.vcsRoots()),
call(commands.vcsStatus()),
call(commands.vcsBranchInfo()),
s.branchesLoaded ? call(commands.vcsBranches()) : Promise.resolve(s.branches),
s.stashesLoaded ? call(commands.vcsStashes()) : Promise.resolve(s.stashes),
call(commands.vcsBranchInfo(s.repo)),
s.branchesLoaded ? call(commands.vcsBranches(s.repo)) : Promise.resolve(s.branches),
s.stashesLoaded ? call(commands.vcsStashes(s.repo)) : Promise.resolve(s.stashes),
]);
const roots = vcsRoots.map((r) => r.root);
const operations = Object.fromEntries(vcsRoots.filter((r) => r.operation).map((r) => [r.root, r.operation!]));
// `null` (before the first refresh) is the primary repository, listed first.
const shown = s.repo && roots.includes(s.repo) ? s.repo : (roots[0] ?? null);
const repo = attentionRepo(shown, s.operations, operations) ?? shown;
useVcs.setState({ operations });
if (repo !== s.repo || roots.join("\n") !== s.roots.join("\n")) useVcs.setState({ roots, repo });
applyStatus(status, branch);
useVcs.setState({ branches, stashes, error: null });
// Branch info and branches above are of another repository: load the selected one's.
if (repo !== shown || (s.repo && repo !== s.repo)) {
useVcs.setState({ branchesLoaded: false, stashesLoaded: false });
void refreshNow();
}
} catch (e) {
useVcs.setState({ error: e instanceof Error ? e.message : String(e) });
} finally {
@@ -127,13 +190,13 @@ export function applyStatus(status: FileStatus[], branch: BranchInfo | null) {
}
export async function loadBranches(): Promise<Branch[]> {
const branches = await call(commands.vcsBranches());
const branches = await call(commands.vcsBranches(currentRepo()));
useVcs.setState({ branches, branchesLoaded: true });
return branches;
}
export async function loadStashes(): Promise<Stash[]> {
const stashes = await call(commands.vcsStashes());
const stashes = await call(commands.vcsStashes(currentRepo()));
useVcs.setState({ stashes, stashesLoaded: true });
return stashes;
}
@@ -142,6 +205,17 @@ export function statusOf(path: string): FileStatus | undefined {
return useVcs.getState().byPath.get(stripSlash(path));
}
/** Repositories found below a project that is not itself under git (scanned in the background). */
async function discoverRoots() {
const root = useVcs.getState().root;
if (!root || !isTauri()) return;
const roots = await call(commands.vcsRoots()).catch(() => []);
if (!roots.length || useVcs.getState().root !== root || useVcs.getState().available) return;
useVcs.setState({ available: true });
setContext("vcsAvailable", true);
void refreshNow();
}
/** Tracks the open project and backend events. Returns a disposer. */
export function installVcsStore(): () => void {
const onProject = () => {
@@ -154,12 +228,16 @@ export function installVcsStore(): () => void {
setContext("vcsAvailable", available);
setFileDecorations("vcs", new Map());
if (available && isTauri()) void refreshNow();
else if (project) void discoverRoots();
};
onProject();
const unsubProject = useProject.subscribe((s, prev) => {
if (s.project !== prev.project) onProject();
});
const offVcs = onIdeEvent("vcsChanged", () => scheduleRefresh(200));
const offVcs = onIdeEvent("vcsChanged", () => {
if (useVcs.getState().available) scheduleRefresh(200);
else void discoverRoots();
});
const offFs = onIdeEvent("fsChanged", () => scheduleRefresh(250));
const offSettings = onWorkflowSettingsChange(
(s) => s.vcs.showDirtyDirectories,