diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 14f6f8f3f..4151a6c06 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -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 { refs::branch_info(self) } + /// Merge / rebase / cherry-pick… in progress (cheap: no ref walking). + pub fn operation(&self) -> IdeResult> { + 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> { diff --git a/crates/ide-vcs/src/refs.rs b/crates/ide-vcs/src/refs.rs index 985b353fb..5b7903054 100644 --- a/crates/ide-vcs/src/refs.rs +++ b/crates/ide-vcs/src/refs.rs @@ -30,7 +30,7 @@ pub(crate) fn branch_info(r: &Repo) -> IdeResult { Ok(info) } -fn operation(repo: &gix::Repository) -> Option { +pub(crate) fn operation(repo: &gix::Repository) -> Option { use gix::state::InProgress::*; let op = match repo.state()? { ApplyMailbox | ApplyMailboxRebase => "am", diff --git a/crates/ide-vcs/src/roots.rs b/crates/ide-vcs/src/roots.rs new file mode 100644 index 000000000..d71abe72c --- /dev/null +++ b/crates/ide-vcs/src/roots.rs @@ -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>, + primary: Option, +} + +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 { + let mut repos: Vec = 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 { + Ok(Self::new(Repo::discover(project_root)?.into_iter().collect(), project_root)) + } + + /// Worktree roots, sorted. + pub fn roots(&self) -> Vec { + self.repos.iter().map(|r| r.root().to_path_buf()).collect() + } + + fn new(mut repos: Vec, project_root: &Path) -> Self { + repos.sort_by(|a, b| a.root().cmp(b.root())); + let repos: Vec> = 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], path: &Path) -> Option { + 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] { + &self.repos + } + + /// The repository of the project root, else the first one. + pub fn primary(&self) -> Option<&Arc> { + 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> { + 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, Vec)> { + let mut out: Vec<(Arc, Vec)> = 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> { + 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 = + 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()); + } +} diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 34ed04aff..df5641283 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -71,7 +71,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult 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(), } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e56caeb2f..4c4f60fab 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -129,6 +129,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 196076c57..8fd0da6f6 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -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> { 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, +} + +/// The project's repositories, the primary one first. +#[tauri::command] +#[specta::specta] +pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult> { + 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> = 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 { +pub async fn vcs_branch_info( + window: Window, + state: State<'_, AppState>, + repo: Option, +) -> IdeResult { 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> { 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, ) -> IdeResult> { 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> { 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> { +pub async fn vcs_log( + window: Window, + state: State<'_, AppState>, + repo: Option, + query: LogQuery, +) -> IdeResult> { 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, commit: String, ) -> IdeResult> { 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> { +pub async fn vcs_branches(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { 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> { +pub async fn vcs_stashes(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { 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) -> 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) -> 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) -> 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, message: String, options: CommitOptions, ) -> IdeResult { // 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, + 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, name: String, start: Option, 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 { +pub async fn vcs_undo_commit( + window: Window, + state: State<'_, AppState>, + repo: Option, + commit: String, +) -> IdeResult { 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, 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, + 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, + 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, + 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, message: Option, 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, + 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, + 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, request: RequestId, op: RemoteOp, on_output: Channel, @@ -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 { 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 { 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 { pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec) -> 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 = hunks.iter().map(|&h| h as usize).collect(); @@ -408,9 +505,14 @@ pub fn vcs_detected_git() -> Option { /// `git config --get ` 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> { +pub async fn vcs_config_get( + window: Window, + state: State<'_, AppState>, + repo: Option, + key: String, +) -> IdeResult> { 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, request: RequestId, op: GitOperation, on_output: Channel, @@ -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, name: String, commit: String, message: Option, 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> { +pub async fn vcs_tags(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { 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, + repo: Option, rev: String, ) -> IdeResult> { 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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { @@ -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 { 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 } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 02fd62738..5fe94c1d1 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -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, pub lsp: Arc, - pub repo: Mutex>, + /// Git repositories: the one containing the project root and those nested below it. + pub repos: Arc>, /// Incrementally maintained syntax trees of the open documents. pub syntax: Arc, /// 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, 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(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + /// 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(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { 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(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + 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( &self, + at: Option<&Path>, operation: Option<&str>, f: impl FnOnce(&Repo) -> IdeResult, ) -> IdeResult { - 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 } diff --git a/src/bindings.ts b/src/bindings.ts index c5ce3d46e..2afc23058 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -251,37 +251,39 @@ export const commands = { lspExecuteCommand: (doc: DocId, command: Command) => typedError(__TAURI_INVOKE("lsp_execute_command", { doc, command })), /** Changes of every repository of the project. */ vcsStatus: () => typedError(__TAURI_INVOKE("vcs_status")), - vcsBranchInfo: () => typedError(__TAURI_INVOKE("vcs_branch_info")), + /** The project's repositories, the primary one first. */ + vcsRoots: () => typedError(__TAURI_INVOKE("vcs_roots")), + vcsBranchInfo: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_branch_info", { repo })), /** Gutter change markers for a file, computed against its current (unsaved) text. */ vcsLineHunks: (path: string) => typedError(__TAURI_INVOKE("vcs_line_hunks", { path })), vcsFileAt: (path: string, rev: string | null) => typedError(__TAURI_INVOKE("vcs_file_at", { path, rev })), vcsBlame: (path: string) => typedError(__TAURI_INVOKE("vcs_blame", { path })), - vcsLog: (query: LogQuery) => typedError(__TAURI_INVOKE("vcs_log", { query })), - vcsCommitFiles: (commit: string) => typedError(__TAURI_INVOKE("vcs_commit_files", { commit })), - vcsBranches: () => typedError(__TAURI_INVOKE("vcs_branches")), - vcsStashes: () => typedError(__TAURI_INVOKE("vcs_stashes")), + vcsLog: (repo: string | null, query: LogQuery) => typedError(__TAURI_INVOKE("vcs_log", { repo, query })), + vcsCommitFiles: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_commit_files", { repo, commit })), + vcsBranches: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_branches", { repo })), + vcsStashes: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_stashes", { repo })), vcsStage: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_stage", { paths })), vcsUnstage: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_unstage", { paths })), vcsStagePatch: (patch: string) => typedError(__TAURI_INVOKE("vcs_stage_patch", { patch })), vcsDiscard: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_discard", { paths })), - vcsCommit: (message: string, options: CommitOptions) => typedError(__TAURI_INVOKE("vcs_commit", { message, options })), - vcsCheckout: (branch: string, create: boolean) => typedError(__TAURI_INVOKE("vcs_checkout", { branch, create })), - vcsDeleteBranch: (branch: string, force: boolean) => typedError(__TAURI_INVOKE("vcs_delete_branch", { branch, force })), + vcsCommit: (repo: string | null, message: string, options: CommitOptions) => typedError(__TAURI_INVOKE("vcs_commit", { repo, message, options })), + vcsCheckout: (repo: string | null, branch: string, create: boolean) => typedError(__TAURI_INVOKE("vcs_checkout", { repo, branch, create })), + vcsDeleteBranch: (repo: string | null, branch: string, force: boolean) => typedError(__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(__TAURI_INVOKE("vcs_create_branch", { name, start, checkout })), + vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError(__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(__TAURI_INVOKE("vcs_undo_commit", { commit })), - vcsRenameBranch: (from: string, to: string) => typedError(__TAURI_INVOKE("vcs_rename_branch", { from, to })), - vcsMerge: (branch: string) => typedError(__TAURI_INVOKE("vcs_merge", { branch })), - vcsRebase: (onto: string) => typedError(__TAURI_INVOKE("vcs_rebase", { onto })), - vcsStashPush: (message: string | null, includeUntracked: boolean) => typedError(__TAURI_INVOKE("vcs_stash_push", { message, includeUntracked })), - vcsStashApply: (index: number, pop: boolean) => typedError(__TAURI_INVOKE("vcs_stash_apply", { index, pop })), - vcsStashDrop: (index: number) => typedError(__TAURI_INVOKE("vcs_stash_drop", { index })), + vcsUndoCommit: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_undo_commit", { repo, commit })), + vcsRenameBranch: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_rename_branch", { repo, from, to })), + vcsMerge: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_merge", { repo, branch })), + vcsRebase: (repo: string | null, onto: string) => typedError(__TAURI_INVOKE("vcs_rebase", { repo, onto })), + vcsStashPush: (repo: string | null, message: string | null, includeUntracked: boolean) => typedError(__TAURI_INVOKE("vcs_stash_push", { repo, message, includeUntracked })), + vcsStashApply: (repo: string | null, index: number, pop: boolean) => typedError(__TAURI_INVOKE("vcs_stash_apply", { repo, index, pop })), + vcsStashDrop: (repo: string | null, index: number) => typedError(__TAURI_INVOKE("vcs_stash_drop", { repo, index })), /** Network operation; progress lines stream through `on_output`. Cancellable. */ - vcsRemote: (request: RequestId, op: RemoteOp, onOutput: Channel) => typedError(__TAURI_INVOKE("vcs_remote", { request, op, onOutput })), + vcsRemote: (repo: string | null, request: RequestId, op: RemoteOp, onOutput: Channel) => typedError(__TAURI_INVOKE("vcs_remote", { repo, request, op, onOutput })), vcsConflictVersions: (path: string) => typedError(__TAURI_INVOKE("vcs_conflict_versions", { path })), vcsDiffText: (path: string, staged: boolean) => typedError(__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("vcs_detected_git"), /** `git config --get ` in the project repository; `None` when unset. */ - vcsConfigGet: (key: string) => typedError(__TAURI_INVOKE("vcs_config_get", { key })), + vcsConfigGet: (repo: string | null, key: string) => typedError(__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) => typedError(__TAURI_INVOKE("vcs_operation", { request, op, onOutput })), - vcsCreateTag: (name: string, commit: string, message: string | null, force: boolean) => typedError(__TAURI_INVOKE("vcs_create_tag", { name, commit, message, force })), - vcsTags: () => typedError(__TAURI_INVOKE("vcs_tags")), + vcsOperation: (repo: string | null, request: RequestId, op: GitOperation, onOutput: Channel) => typedError(__TAURI_INVOKE("vcs_operation", { repo, request, op, onOutput })), + vcsCreateTag: (repo: string | null, name: string, commit: string, message: string | null, force: boolean) => typedError(__TAURI_INVOKE("vcs_create_tag", { repo, name, commit, message, force })), + vcsTags: (repo: string | null) => typedError(__TAURI_INVOKE("vcs_tags", { repo })), /** Compare with Local: tracked files that differ between `rev` and the working tree. */ - vcsDiffWithWorktree: (rev: string) => typedError(__TAURI_INVOKE("vcs_diff_with_worktree", { rev })), + vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })), vcsCreatePatch: (source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { source })), vcsPatchPreview: (patch: string) => typedError(__TAURI_INVOKE("vcs_patch_preview", { patch })), vcsApplyPatch: (patch: string) => typedError(__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). */ diff --git a/src/features/github/dialogs.tsx b/src/features/github/dialogs.tsx index 1505d1524..7f6a49a1c 100644 --- a/src/features/github/dialogs.tsx +++ b/src/features/github/dialogs.tsx @@ -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(); if (props.defaultBase) names.add(props.defaultBase); diff --git a/src/features/github/ops.ts b/src/features/github/ops.ts index c90d47d69..27805772d 100644 --- a/src/features/github/ops.ts +++ b/src/features/github/ops.ts @@ -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()); diff --git a/src/features/vcs/BranchWidget.module.css b/src/features/vcs/BranchWidget.module.css index 4ac83eec5..ba171ece7 100644 --- a/src/features/vcs/BranchWidget.module.css +++ b/src/features/vcs/BranchWidget.module.css @@ -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); diff --git a/src/features/vcs/BranchWidget.tsx b/src/features/vcs/BranchWidget.tsx index c91c575f3..64574a255 100644 --- a/src/features/vcs/BranchWidget.tsx +++ b/src/features/vcs/BranchWidget.tsx @@ -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(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 })} > + {repo && {repo}} {inProgress(branch.operation) ? operationTitle(inProgress(branch.operation)!, branch.head) : headLabel(branch)} @@ -179,6 +183,7 @@ function BranchesPopup({ anchor, onClose }: { anchor: RefObject +
s.roots); + const repo = useVcs((s) => s.repo); + if (roots.length < 2) return null; + return ( +
+ {roots.map((r) => ( + + ))} +
+ ); +} + function BranchRowView({ row }: { row: BranchRow }) { if (row.type === "action") { const keys = shortcutLabel(row.action.id); diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 40d08035f..9555c62e7 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -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(null); const viewRef = useRef(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(); const up = new Map(); @@ -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; diff --git a/src/features/vcs/CompareBranches.tsx b/src/features/vcs/CompareBranches.tsx index 9db91f8b3..f2aacb868 100644 --- a/src/features/vcs/CompareBranches.tsx +++ b/src/features/vcs/CompareBranches.tsx @@ -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, diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index d2f1e2ad4..2c6818e58 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -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 () => { diff --git a/src/features/vcs/__tests__/changes.test.ts b/src/features/vcs/__tests__/changes.test.ts index df1297a5f..d30500ae0 100644 --- a/src/features/vcs/__tests__/changes.test.ts +++ b/src/features/vcs/__tests__/changes.test.ts @@ -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"); diff --git a/src/features/vcs/__tests__/gitOps.test.ts b/src/features/vcs/__tests__/gitOps.test.ts index b95bc62ce..b27ee3cd0 100644 --- a/src/features/vcs/__tests__/gitOps.test.ts +++ b/src/features/vcs/__tests__/gitOps.test.ts @@ -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 }] }), }, }; }); diff --git a/src/features/vcs/__tests__/workflow.test.ts b/src/features/vcs/__tests__/workflow.test.ts index 3a09b468a..40d0cca89 100644 --- a/src/features/vcs/__tests__/workflow.test.ts +++ b/src/features/vcs/__tests__/workflow.test.ts @@ -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"]); + }); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 7139151ff..883292416 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -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", diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index eb664fcbb..039c924c2 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -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 { 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); diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index 2242a9710..b24bffe4d 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -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 { } 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))); diff --git a/src/features/vcs/changes.ts b/src/features/vcs/changes.ts index 49e24f9e2..f0b3c47ad 100644 --- a/src/features/vcs/changes.ts +++ b/src/features/vcs/changes.ts @@ -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(); + 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(); 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 { + 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; diff --git a/src/features/vcs/commitChecks.tsx b/src/features/vcs/commitChecks.tsx index 0ddb8bee3..3119a30d0 100644 --- a/src/features/vcs/commitChecks.tsx +++ b/src/features/vcs/commitChecks.tsx @@ -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) { diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 39a8871a1..4a8ad4272 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -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)`, }); } diff --git a/src/features/vcs/incoming.ts b/src/features/vcs/incoming.ts index dfe244577..ca177604d 100644 --- a/src/features/vcs/incoming.ts +++ b/src/features/vcs/incoming.ts @@ -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(); 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) { diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index eaaab18cb..beb575d61 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -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(() => ({ ...initialLog(), generation: let loadPromise: Promise | 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) { 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 { const p = (async () => { try { const page = await call( - commands.vcsLog({ + commands.vcsLog(currentRepo(), { branch: f.branch, path: f.path, author: f.author.trim() || null, diff --git a/src/features/vcs/model.ts b/src/features/vcs/model.ts index 197b3ec99..8b9cbad13 100644 --- a/src/features/vcs/model.ts +++ b/src/features/vcs/model.ts @@ -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("/"); diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index db340c71f..1ca9bcf3b 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -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([]); 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 () => { diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index b613683e4..3342b0323 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -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 { +/** Every repository of the project (Fetch and Update Project act on all of them, as in IDEA). */ +export function allRepos(): Array { + 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 = [currentRepo()], +): Promise { 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 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(); diff --git a/src/features/vcs/rebase.tsx b/src/features/vcs/rebase.tsx index f1a5455a1..262d041af 100644 --- a/src/features/vcs/rebase.tsx +++ b/src/features/vcs/rebase.tsx @@ -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 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 ( 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) => ); + // 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 ? : )); +} + +/** 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 { + 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(null); + const [checked, setChecked] = useState>(() => new Set()); + const [error, setError] = useState(null); + const [force, setForce] = useState(false); + const [pushTags, setPushTags] = useState(false); + const [tagsMode, setTagsMode] = useState("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 ( + + + + + 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" }, + ]} + > +
+ {error ? ( +
{error}
+ ) : !repos ? ( +
+ +
+ ) : ( + (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={Everything is up to date} + renderItem={(r) => + r.type === "repo" ? ( + + toggle(r.repo.root)} + label={repoName(r.repo.root)} + /> + + {r.repo.branch.head} → {r.repo.branch.upstream ?? "new remote branch"} + + + ) : ( + + {r.commit.shortId} + {r.commit.summary} + {r.commit.author} + {formatTime(r.commit.timeMs)} + + ) + } + /> + )} +
+
+ ); } 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, detail: string): Promise { - if (await runRemote(op, detail)) return true; +export async function pushWithAutoUpdate( + op: Extract, + detail: string, + repo: string | null = currentRepo(), +): Promise { + 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({ diff --git a/src/features/vcs/store.ts b/src/features/vcs/store.ts index 49e3ad003..3e71f0e3d 100644 --- a/src/features/vcs/store.ts +++ b/src/features/vcs/store.ts @@ -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; + /** 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(() => ({ ...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, + now: Record, +): 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 { + 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 { 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 { - 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 { - 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,