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