From 4d503ef976ec40fc83676d809adcb0fa9c127719 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:30:32 +0800 Subject: [PATCH] feat(vcs): support multiple repositories in the Git tool window The Git tool window followed one globally selected repository even when multiple roots were registered. Aggregate repository logs and branches, preserve repository identity in selections, history tabs, stashes, diffs and patch commands, and capture repository targets for delayed operations and dialogs. Validate tag names against the captured repository. Fixes #195 --- crates/ide-host/src/ops/vcs/commands.rs | 41 ++- crates/ide-host/tests/vcs_roots.rs | 65 +++++ src-tauri/src/commands/vcs.rs | 27 +- src/bindings.ts | 6 +- src/features/vcs/BranchWidget.tsx | 12 +- src/features/vcs/CommitView.tsx | 88 ++++-- src/features/vcs/CompareBranches.tsx | 7 +- src/features/vcs/GitLog.module.css | 8 + src/features/vcs/GitLog.tsx | 233 ++++++++++++---- src/features/vcs/RevisionChooser.tsx | 17 +- .../vcs/__tests__/logDetails.test.tsx | 11 + src/features/vcs/__tests__/multiRoot.test.ts | 151 ++++++++++ .../vcs/__tests__/multiRootStashes.test.tsx | 38 +++ .../vcs/__tests__/multiRootTags.test.ts | 53 ++++ src/features/vcs/actions.tsx | 7 +- src/features/vcs/branchOps.ts | 12 +- src/features/vcs/branches.ts | 40 ++- src/features/vcs/changeOps.tsx | 40 +-- src/features/vcs/diffInput.ts | 15 +- src/features/vcs/gitMenuDialogs.tsx | 56 ++-- src/features/vcs/historyActions.tsx | 14 +- src/features/vcs/logChanges.ts | 34 ++- src/features/vcs/logPaneActions.ts | 12 +- src/features/vcs/logStore.ts | 260 +++++++++++------- src/features/vcs/multiRoot.ts | 43 +++ src/features/vcs/operations.tsx | 27 +- src/features/vcs/ops.ts | 8 +- src/features/vcs/patches.tsx | 13 +- src/features/vcs/rebase.tsx | 38 ++- src/features/vcs/remoteUi.tsx | 11 +- src/features/vcs/store.ts | 10 +- 31 files changed, 1084 insertions(+), 313 deletions(-) create mode 100644 crates/ide-host/tests/vcs_roots.rs create mode 100644 src/features/vcs/__tests__/multiRoot.test.ts create mode 100644 src/features/vcs/__tests__/multiRootStashes.test.tsx create mode 100644 src/features/vcs/__tests__/multiRootTags.test.ts create mode 100644 src/features/vcs/multiRoot.ts diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 1b40ba30..05af0d64 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -680,13 +680,17 @@ pub async fn vcs_diff_revisions( blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await } -pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult { +pub async fn vcs_create_patch( + host: &Host, + window: &str, + repo: Option, + source: PatchSource, +) -> IdeResult { let project = host.project(window)?; blocking(move || { - project.with_repo(|r| match source { + project.with_repo_at(repo.as_deref(), |r| match source { PatchSource::Changes { paths, reverse } => { let paths: Vec = paths.into_iter().map(Into::into).collect(); - in_primary_repo(&project, &paths)?; r.create_patch(&paths, reverse) } PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse), @@ -695,14 +699,24 @@ pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> .await } -pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult> { +pub async fn vcs_patch_preview( + host: &Host, + window: &str, + repo: Option, + patch: String, +) -> IdeResult> { let project = host.project(window)?; - blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.patch_preview(&patch))).await } -pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult { +pub async fn vcs_apply_patch( + host: &Host, + window: &str, + repo: Option, + patch: String, +) -> IdeResult { let project = host.project(window)?; - blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await + blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. @@ -901,9 +915,16 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_diff_revisions", |c| async move { vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await }); - d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await }); - d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await }); - d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await }); + d.add("vcs_create_patch", |c| async move { + vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await + }); + d.add("vcs_patch_preview", |c| async move { + vcs_patch_preview(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await + }); + d.add( + "vcs_apply_patch", + |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("repo")?, c.arg("patch")?).await }, + ); d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await }); d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) }); d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await }); diff --git a/crates/ide-host/tests/vcs_roots.rs b/crates/ide-host/tests/vcs_roots.rs new file mode 100644 index 00000000..c0ee818b --- /dev/null +++ b/crates/ide-host/tests/vcs_roots.rs @@ -0,0 +1,65 @@ +//! Repository-scoped patch operations must never fall back to the primary root. +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops::vcs}; +use std::{path::Path, process::Command, sync::Arc, time::Duration}; + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn patches_read_and_write_the_requested_repository() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let project_root = base.join("project"); + let roots = [project_root.join("front"), project_root.join("back")]; + let mut revisions = Vec::new(); + for (i, root) in roots.iter().enumerate() { + std::fs::create_dir_all(root).unwrap(); + git(root, &["init", "-b", "main"]); + git(root, &["config", "user.name", "Test"]); + git(root, &["config", "user.email", "test@example.com"]); + std::fs::write(root.join("same.txt"), format!("root {i}\n")).unwrap(); + git(root, &["add", "."]); + git(root, &["commit", "-m", "initial"]); + std::fs::write(root.join("same.txt"), format!("changed {i}\n")).unwrap(); + git(root, &["commit", "-am", "change"]); + revisions.push(git(root, &["rev-parse", "HEAD"])); + } + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let project = host.open_project("main", &project_root).unwrap(); + for _ in 0..500 { + if project.repos().all().len() == 2 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(project.repos().all().len(), 2); + for (i, root) in roots.iter().enumerate() { + let patch = vcs::vcs_create_patch( + &host, + "main", + Some(root.clone()), + vcs::PatchSource::Commits { commits: vec![revisions[i].clone()], reverse: true }, + ) + .await + .unwrap(); + assert!(patch.contains(&format!("-changed {i}"))); + assert_eq!(vcs::vcs_patch_preview(&host, "main", Some(root.clone()), patch.clone()).await.unwrap().len(), 1); + vcs::vcs_apply_patch(&host, "main", Some(root.clone()), patch).await.unwrap(); + assert_eq!(std::fs::read_to_string(root.join("same.txt")).unwrap(), format!("root {i}\n")); + } + assert!(vcs::vcs_apply_patch(&host, "main", Some(base.join("outside")), String::new()).await.is_err()); + host.close_project("main"); +} diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 32e9c105..eeeda3ea 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -520,20 +520,35 @@ pub async fn vcs_diff_revisions( #[tauri::command] #[specta::specta] -pub async fn vcs_create_patch(window: Window, state: State<'_, AppState>, source: PatchSource) -> IdeResult { - ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), source).await +pub async fn vcs_create_patch( + window: Window, + state: State<'_, AppState>, + repo: Option, + source: PatchSource, +) -> IdeResult { + ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), repo, source).await } #[tauri::command] #[specta::specta] -pub async fn vcs_patch_preview(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult> { - ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), patch).await +pub async fn vcs_patch_preview( + window: Window, + state: State<'_, AppState>, + repo: Option, + patch: String, +) -> IdeResult> { + ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), repo, patch).await } #[tauri::command] #[specta::specta] -pub async fn vcs_apply_patch(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult { - ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), patch).await +pub async fn vcs_apply_patch( + window: Window, + state: State<'_, AppState>, + repo: Option, + patch: String, +) -> IdeResult { + ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), repo, patch).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. diff --git a/src/bindings.ts b/src/bindings.ts index bbe05593..f1994cd6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -474,9 +474,9 @@ export const commands = { vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })), /** Files that differ between two revisions (the "N files updated" of Update Project). */ vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })), - vcsCreatePatch: (source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { source })), - vcsPatchPreview: (patch: string) => typedError(__TAURI_INVOKE("vcs_patch_preview", { patch })), - vcsApplyPatch: (patch: string) => typedError(__TAURI_INVOKE("vcs_apply_patch", { patch })), + vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { repo, source })), + vcsPatchPreview: (repo: string | null, patch: string) => typedError(__TAURI_INVOKE("vcs_patch_preview", { repo, patch })), + vcsApplyPatch: (repo: string | null, patch: string) => typedError(__TAURI_INVOKE("vcs_apply_patch", { repo, patch })), /** Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. */ vcsShelve: (name: string, paths: string[]) => typedError(__TAURI_INVOKE("vcs_shelve", { name, paths })), vcsShelves: () => typedError(__TAURI_INVOKE("vcs_shelves")), diff --git a/src/features/vcs/BranchWidget.tsx b/src/features/vcs/BranchWidget.tsx index a2592de8..3e1a7475 100644 --- a/src/features/vcs/BranchWidget.tsx +++ b/src/features/vcs/BranchWidget.tsx @@ -1,3 +1,4 @@ +import { branchesByRepo } from "./multiRoot"; /** Title bar branch widget (branch name + incoming / outgoing markers) + Branches popup. */ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "react"; import clsx from "clsx"; @@ -533,14 +534,9 @@ function MultiRepoPanel({ onClose }: { onClose: () => void }) { // Every repository's branches (common ones, submenus, search); refreshed after operations. useEffect(() => { let live = true; - void Promise.all( - roots.map((r) => - call(commands.vcsBranches(r)).then( - (list) => [r, list] as const, - () => [r, [] as Branch[]] as const, - ), - ), - ).then((entries) => live && setAll(Object.fromEntries(entries))); + void branchesByRepo(roots) + .then((entries) => live && setAll(entries)) + .catch(() => undefined); return () => { live = false; }; diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index b30ca92d..da57fc93 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -15,6 +15,7 @@ import { IconButton, List, Menu, + Select, SplitGroup, SplitHandle, SplitPanel, @@ -52,7 +53,7 @@ import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } import { call, commands } from "@/platform/ipc"; import { OperationBanner } from "./operations"; import { ShelfPanel, loadShelves, useShelf } from "./shelf"; -import { loadStashes, refreshNow, useVcs } from "./store"; +import { refreshNow, repoName, selectRepo, useVcs } from "./store"; import styles from "./CommitView.module.css"; /** MIME type of changed files dragged between change lists (absolute paths, newline-separated). */ @@ -76,7 +77,20 @@ export function tabItems(tabs: readonly ContentTab[]): TabItem[] { /** Shelf and stash counts for the tabs (loads the shelves once VCS is available). */ export function useStashTabCounts(available: boolean) { - const stashCount = useVcs((s) => s.stashes.length); + const roots = useVcs((s) => s.roots); + const version = useVcs((s) => s.version); + const [stashCount, setStashCount] = useState(0); + useEffect(() => { + let live = true; + if (available) + void Promise.all(roots.map((r) => call(commands.vcsStashes(r)))) + .then((lists) => live && setStashCount(lists.reduce((n, list) => n + list.length, 0))) + .catch(() => undefined); + else setStashCount(0); + return () => { + live = false; + }; + }, [available, roots, version]); const shelfCount = useShelf((s) => s.shelves.length); useEffect(() => { if (available) void loadShelves().catch(() => undefined); @@ -825,17 +839,52 @@ function CommitPanel({ included }: { included: number }) { // --------------------------------------------------------------------------- export function StashesPanel() { - const stashes = useVcs((s) => s.stashes); - const loaded = useVcs((s) => s.stashesLoaded); + const repo = useVcs((s) => s.repo); + const repos = useVcs((s) => s.roots); + const version = useVcs((s) => s.version); + const [stashes, setStashes] = useState<(import("@/bindings").Stash & { repo: string | null })[]>([]); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(null); + const [tick, setTick] = useState(0); const [active, setActive] = useState(0); useEffect(() => { - void loadStashes().catch(() => undefined); - }, []); + let live = true; + setLoaded(false); + void Promise.all( + (repos.length ? repos : [repo]).map(async (root) => + (await call(commands.vcsStashes(root))).map((s) => ({ ...s, repo: root })), + ), + ) + .then((lists) => { + if (live) { + setStashes(lists.flat()); + setError(null); + setLoaded(true); + } + }) + .catch((e) => { + if (live) { + setError(String(e)); + setLoaded(true); + } + }); + return () => { + live = false; + }; + }, [repos, repo, version, tick]); const current = stashes[active]; return (
- void loadStashes()} /> + {repos.length > 1 && ( + ({ value: r, label: repoName(r) }))} + onChange={(r) => void selectRepo(r)} + /> + )} void reload()} /> void }) { - const root = useVcs((s) => s.repo ?? s.root) ?? ""; + const [repo] = useState(currentRepo); + const [root] = useState(() => useVcs.getState().repo ?? useVcs.getState().root ?? ""); const branches = useBranches(); const [source, setSource] = useState(null); const [createBranch, setCreateBranch] = useState(true); @@ -683,7 +703,7 @@ function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void }) const create = async () => { if (invalid || !source) return; try { - await call(commands.vcsAddWorktree(currentRepo(), target.trim(), source, createBranch ? branchName : null)); + await call(commands.vcsAddWorktree(repo, target.trim(), source, createBranch ? branchName : null)); notifications().notify({ group: "vcs", level: "info", title: `Created worktree at ${target.trim()}` }); void loadBranches().catch(() => undefined); onClose(true); diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 8915fa15..92a599f5 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -1,3 +1,4 @@ +import { commitKey, commitRepo } from "./multiRoot"; /** * Actions for operations in progress, Git Log commits, change lists, the shelf * and patches (ids are bound in keymaps/intellij-macos.ts where IDEA binds them). @@ -45,7 +46,7 @@ import { } from "./rebase"; import { showApplyPatch, showCreatePatchForChanges, showCreatePatchForCommits } from "./patches"; import { deleteShelf, renameShelf, shelveChanges, showShelf, unshelve, useShelf } from "./shelf"; -import { currentRepo, useVcs } from "./store"; +import { currentRepo, focusRepo, useVcs } from "./store"; const VCS = "vcsAvailable"; const IN_GIT_LOG = "vcsAvailable && activeToolWindow == git"; @@ -57,7 +58,7 @@ export function commitArg(arg: unknown): Commit | null { if (arg && typeof arg === "object" && "id" in arg && "summary" in arg) return arg as Commit; const id = typeof arg === "string" ? arg : selected; if (!id) return null; - return commits.find((c) => c.id === id) ?? null; + return commits.find((c) => commitKey(c) === id) ?? null; } /** Commits an action refers to: its argument (commit list or commit), else the Git Log selection. */ @@ -72,12 +73,15 @@ export function commitsArg(arg: unknown): Commit[] { } function withCommits(fn: (cs: Commit[]) => unknown) { - return ({ arg }: { arg?: unknown }) => { + return async ({ arg }: { arg?: unknown }) => { const cs = commitsArg(arg); if (!cs.length) { notifications().notify({ group: "vcs", level: "info", title: "Select a commit in the Git log" }); return; } + if (new Set(cs.map(commitRepo)).size > 1) return; + const repo = commitRepo(cs[0]!); + if (repo) await focusRepo(repo); void fn(cs); }; } @@ -93,12 +97,14 @@ async function openCommitInBrowser(c: Commit) { } function withCommit(fn: (c: Commit) => unknown) { - return ({ arg }: { arg?: unknown }) => { + return async ({ arg }: { arg?: unknown }) => { const c = commitArg(arg); if (!c) { notifications().notify({ group: "vcs", level: "info", title: "Select a commit in the Git log" }); return; } + const repo = commitRepo(c); + if (repo) await focusRepo(repo); void fn(c); }; } diff --git a/src/features/vcs/logChanges.ts b/src/features/vcs/logChanges.ts index ece1d21a..12dfe3a6 100644 --- a/src/features/vcs/logChanges.ts +++ b/src/features/vcs/logChanges.ts @@ -20,6 +20,7 @@ import { confirmDialog } from "./dialogs"; export interface LogChange { /** Repository-relative path (after the change). */ path: string; + repo?: string | null; oldPath: string | null; kind: ChangedFile["kind"]; /** `null`: the file did not exist before. */ @@ -30,27 +31,33 @@ export interface LogChange { } /** One commit's files (`commit` given) or several commits' combined changes. */ -export function toLogChanges(files: readonly (ChangedFile | RevisionChange)[], commit: Commit | null): LogChange[] { +export function toLogChanges( + files: readonly (ChangedFile | RevisionChange)[], + commit: Commit | null, + repo = currentRepoRoot(), +): LogChange[] { return files.map((f) => { if (commit) { const parent = commit.parents[0] ?? null; return { + repo, path: f.path, oldPath: f.oldPath, kind: f.kind, before: f.kind === "added" ? null : parent, after: f.kind === "deleted" ? null : commit.id, - diff: commitFileDiffData(commit, f), + diff: commitFileDiffData({ ...commit, repo }, f), }; } const r = f as RevisionChange; return { + repo, path: r.path, oldPath: r.oldPath, kind: r.kind, before: r.before, after: r.after, - diff: revisionChangeDiffData(r), + diff: revisionChangeDiffData(r, repo), }; }); } @@ -101,8 +108,7 @@ export function filterPatch(patch: string, paths: ReadonlySet): string { } /** Absolute path of a repository-relative one. */ -export function absolutePath(rel: string): string { - const root = currentRepoRoot(); +export function absolutePath(rel: string, root = currentRepoRoot()): string { return root ? `${root}/${rel}` : rel; } @@ -146,9 +152,9 @@ export async function showChangesDiff(changes: readonly LogChange[], preview = f export async function compareChangeWithLocal(change: LogChange, side: "after" | "before" = "after") { const rev = side === "after" ? change.after : change.before; if (!rev) return; - const revPath = side === "before" && change.oldPath ? absolutePath(change.oldPath) : undefined; + const revPath = side === "before" && change.oldPath ? absolutePath(change.oldPath, change.repo) : undefined; await openDiff({ - path: absolutePath(change.path), + path: absolutePath(change.path, change.repo), left: { type: "rev", rev, path: revPath, label: rev.slice(0, 8) }, right: { type: "worktree" }, }); @@ -156,7 +162,7 @@ export async function compareChangeWithLocal(change: LogChange, side: "after" | /** Jump to Source: the working file. */ export async function openChangeSource(change: LogChange) { - await editorService().openFile(absolutePath(change.path), { focus: true }); + await editorService().openFile(absolutePath(change.path, change.repo), { focus: true }); } /** @@ -180,7 +186,7 @@ export async function getFromRevision(changes: readonly LogChange[]) { "Get from Revision", async () => { for (const c of changes) { - const path = absolutePath(c.path); + const path = absolutePath(c.path, c.repo); if (!c.after) { if ((await readWorkingText(path)) !== null) await call(commands.fsDelete(path)); continue; @@ -207,11 +213,17 @@ export async function revertSelectedChanges(changes: readonly LogChange[], commi "Revert selected changes", async () => { const patch = filterPatch( - await call(commands.vcsCreatePatch({ from: "commits", commits: ids, reverse: true })), + await call( + commands.vcsCreatePatch(changes[0]!.repo ?? currentRepoRoot(), { + from: "commits", + commits: ids, + reverse: true, + }), + ), paths, ); if (!patch.trim()) throw new Error(tx("Nothing to revert")); - return call(commands.vcsApplyPatch(patch)); + return call(commands.vcsApplyPatch(changes[0]!.repo ?? currentRepoRoot(), patch)); }, { refresh: false }, ); diff --git a/src/features/vcs/logPaneActions.ts b/src/features/vcs/logPaneActions.ts index a99b30a0..95393948 100644 --- a/src/features/vcs/logPaneActions.ts +++ b/src/features/vcs/logPaneActions.ts @@ -1,3 +1,4 @@ +import { focusRepo } from "./store"; /** * Actions of the Git Log's inner panes: the Branches pane toolbar, Graph Options' * linear-branch actions and the changed-files pane (toolbar and context menu). @@ -24,6 +25,7 @@ const VCS = "vcsAvailable"; /** A ref selected in the Branches pane. */ export interface BranchTarget { ref: string; + repo?: string; branch?: Branch; } @@ -37,8 +39,10 @@ function branchArg(arg: unknown): BranchTarget | null { const withBranch = (fn: (b: Branch) => unknown): Action["run"] => - ({ arg }) => { - const b = branchArg(arg)?.branch; + async ({ arg }) => { + const target = branchArg(arg); + if (target?.repo) await focusRepo(target.repo); + const b = target?.branch; if (b) void fn(b); }; @@ -90,8 +94,9 @@ export function registerLogPaneActions(): () => void { icon: "plus", when: VCS, hidden: true, - run: ({ arg }) => { + run: async ({ arg }) => { const t = branchArg(arg); + if (t?.repo) await focusRepo(t.repo); void newBranch(t && t.ref !== "HEAD" && !t.branch?.current ? t.ref : undefined); }, }, @@ -134,6 +139,7 @@ export function registerLogPaneActions(): () => void { hidden: true, run: async ({ arg }) => { const t = branchArg(arg); + if (t?.repo) await focusRepo(t.repo); if (t && !(await goToRef(t.ref))) notifications().notify({ group: "vcs", level: "warning", title: `'${t.ref}' was not found in the log` }); }, diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index e4417c3b..de739551 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -1,4 +1,5 @@ /** Git log tool window state: filters, paged commits, graph, selection. */ +import { branchesByRepo, commitKey, commitRepo, graphCommit, mergeRepoLogs, type RepoCommit } from "./multiRoot"; import { create } from "zustand"; import type { Branch, BranchInfo, Commit } from "@/bindings"; import { call, commands } from "@/platform/ipc"; @@ -6,7 +7,7 @@ import { workflowSettings } from "@/platform/workflowSettings"; import { collapseLinear, computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph"; import { logOptions, setLogOptions, type LogOptions } from "./logOptions"; import { clearConsole, errorMessage } from "./ops"; -import { currentRepo, currentRepoRoot, repoOf, selectRepo, useVcs } from "./store"; +import { currentRepo, currentRepoRoot, repoName, repoOf, useVcs } from "./store"; /** Date filter: commits made (committer time) after / before the given times (epoch ms). */ export interface LogDateFilter { @@ -21,6 +22,7 @@ export const ME = "me"; export interface LogFilters { /** Branches / revisions to show; empty = all. */ branches: string[]; + repositories?: string[]; /** Author names or emails (any matches); `ME` = the current user. */ authors: string[]; text: string; @@ -34,7 +36,7 @@ export interface LogFilters { interface LogState { filters: LogFilters; - commits: Commit[]; + commits: RepoCommit[]; rows: GraphRow[]; graph: GraphState; loading: boolean; @@ -46,6 +48,7 @@ interface LogState { selection: string[]; /** Commits of HEAD not on its upstream (`@{u}..HEAD`); null without an upstream. */ outgoing: Set | null; + tracked: Set; /** Content tab of the Git tool window (a `GitTab`, or the id of a log tab). */ tab: string; /** Closable log tabs (History: file, Log: branch) next to the main Log. */ @@ -70,6 +73,7 @@ export interface LogTab { kind: "history" | "branch"; /** What it shows (the file path or the branch); opening the same again selects the tab. */ target: string; + repo?: string | null; } export const PAGE = 300; @@ -86,7 +90,7 @@ export const emptyFilters = (): LogFilters => ({ /** Any filter other than the text field's toggles is set. */ export function hasFilters(f: LogFilters): boolean { - return !!(f.branches.length || f.authors.length || f.text || f.path || f.date); + return !!(f.repositories?.length || f.branches.length || f.authors.length || f.text || f.path || f.date); } /** `ME` replaced by the current user's email (or name); duplicates and blanks dropped. */ @@ -137,6 +141,7 @@ const initialLog = (): Omit => ({ selected: null, selection: [], outgoing: null, + tracked: new Set(), tab: "log", logTabs: [], shownLog: "log", @@ -185,6 +190,7 @@ function rememberLogBranches(repo: string, branches: readonly string[]) { */ export function initLogBranchFilter(): boolean { const s = useGitLog.getState(); + if (useVcs.getState().roots.length > 1) return false; const repo = currentRepoRoot(); if (s.shownLog !== "log" || !repo || s.branchScope === repo) return false; const names = useVcs @@ -258,7 +264,8 @@ let tabSeq = 0; * they are: Show History for a file, Show Git Log for a branch. */ export function openLogTab(kind: LogTab["kind"], target: string): string { - const existing = useGitLog.getState().logTabs.find((t) => t.kind === kind && t.target === target); + const repo = kind === "history" ? repoOf(target) : currentRepoRoot(); + const existing = useGitLog.getState().logTabs.find((t) => t.kind === kind && t.target === target && t.repo === repo); if (existing) { selectGitTab(existing.id); return existing.id; @@ -266,11 +273,8 @@ export function openLogTab(kind: LogTab["kind"], target: string): string { const id = `${kind}:${++tabSeq}`; const filters = { ...emptyFilters(), ...(kind === "history" ? { path: target } : { branches: [target] }) }; parked.set(id, freshView(filters)); - useGitLog.setState((s) => ({ logTabs: [...s.logTabs, { id, kind, target }] })); + useGitLog.setState((s) => ({ logTabs: [...s.logTabs, { id, kind, target, repo }] })); selectGitTab(id); - // History of a file in another repository: that repository's log. - const repo = kind === "history" ? repoOf(target) : null; - if (repo && repo !== currentRepo()) void selectRepo(repo); return id; } @@ -288,7 +292,7 @@ export function closeLogTab(id: string) { /** * Another project (the window switched projects): forget its commits, filters, selection - * and console. Another repository of the project selected: show its log. + * and console. Repository selection does not change the log or its history tabs. */ export function installGitLog(): () => void { return useVcs.subscribe((s, prev) => { @@ -301,33 +305,9 @@ export function installGitLog(): () => void { generation: l.generation + 1, })); clearConsole(); - } else if (s.repo !== prev.repo && prev.repo !== null) { - // The parked log tabs load again when shown. + } else if (s.roots !== prev.roots) { for (const [id, view] of parked) parked.set(id, freshView(view.filters)); - // Drops a page still loading for the old repository (its generation no longer matches). - loadPromise = null; - useGitLog.setState((l) => ({ - // The shown log belonged to the old repository: it loads again once the new - // repository's branch filter is settled (see LogView). - commits: [], - rows: [], - graph: emptyGraphState(), - loading: false, - done: false, - error: null, - selected: null, - selection: [], - branchScope: null, - generation: l.generation + 1, - filters: { - ...l.filters, - // The main Log's branch filter belonged to the old repository; show all - // until the new repository's remembered/current branch is settled. - ...(l.shownLog === "log" ? { branches: [] } : {}), - // A path filter belongs to the repository it is in. - ...(l.filters.path && repoOf(l.filters.path) !== s.repo ? { path: null } : {}), - }, - })); + void reloadLog(); } }); } @@ -335,9 +315,7 @@ export function installGitLog(): () => void { export function setLogFilters(patch: Partial) { useGitLog.setState((s) => ({ filters: { ...s.filters, ...patch } })); // 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(); + void reloadLog(); } /** Drops loaded commits and loads the first page. */ @@ -352,35 +330,49 @@ export function reloadLog(): Promise { })); loadPromise = null; void loadOutgoing(); - void currentUser().then((user) => useGitLog.setState({ user })); + const generation = useGitLog.getState().generation; + void currentUser().then((user) => { + if (useGitLog.getState().generation === generation) useGitLog.setState({ user }); + }); return loadMore(); } async function loadOutgoing() { - const upstream = useVcs.getState().branch?.upstream; - if (!upstream) return useGitLog.setState({ outgoing: null }); - try { - const commits = await call( - 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 { - useGitLog.setState({ outgoing: null }); - } + const generation = useGitLog.getState().generation; + const roots = useVcs.getState().roots.length ? useVcs.getState().roots : [currentRepo()]; + const outgoing = new Set(); + const tracked = new Set(); + await Promise.all( + roots.map(async (repo) => { + try { + const info = await call(commands.vcsBranchInfo(repo)); + if (!info.upstream) return; + const commits = await call( + commands.vcsLog(repo, { + branch: "HEAD", + path: null, + author: null, + text: null, + range: "@{u}..HEAD", + skip: 0, + limit: 0, + }), + ); + tracked.add(repo ?? ""); + for (const c of commits) outgoing.add(commitKey({ ...c, repo })); + } catch { + /* A missing upstream does not prevent browsing history. */ + } + }), + ); + if (useGitLog.getState().generation === generation) useGitLog.setState({ outgoing, tracked }); } /** The commit is on the upstream of the current branch (rewriting it needs a force push). */ export function isPushed(id: string): boolean { - const { outgoing } = useGitLog.getState(); - return !!outgoing && !outgoing.has(id); + const { outgoing, tracked, commits } = useGitLog.getState(); + const c = commits.find((c) => commitKey(c) === id); + return !!c && tracked.has(commitRepo(c) ?? "") && !!outgoing && !outgoing.has(id); } export function loadMore(): Promise { @@ -399,42 +391,77 @@ export function loadMore(): Promise { useGitLog.setState({ loading: true }); const p = (async () => { try { - const authors = resolveAuthors(f.authors, f.authors.includes(ME) ? await currentUser() : null); - if (f.authors.length && !authors.length) { - if (useGitLog.getState().generation === gen) useGitLog.setState({ done: true }); - return; - } const o = logOptions(); - const page = await call( - commands.vcsLog(currentRepo(), { - branch: null, - branches: f.branches, - authors, - path: f.path, - author: null, - text: f.text.trim() || null, - textRegex: f.regex, - textMatchCase: f.matchCase, - range: null, - skip: s.commits.length, - limit: Math.min(PAGE, cap - s.commits.length), - order: o.sort, - firstParent: o.firstParent, - noMerges: o.noMerges, - // The date filter runs in the backend so the graph stays connected. - sinceMs: f.date?.after ?? null, - untilMs: f.date?.before ?? null, + const tab = s.logTabs.find((t) => t.id === s.shownLog); + const roots = tab?.repo + ? [tab.repo] + : f.path + ? [repoOf(f.path)] + : f.repositories?.length + ? f.repositories + : useVcs.getState().roots.length + ? useVcs.getState().roots + : [currentRepo()]; + const limit = Math.min(PAGE, cap - s.commits.length); + const refs = await branchesByRepo(roots.filter((r): r is string => !!r)); + const knownRefs = new Set( + Object.values(refs) + .flat() + .map((b) => b.name), + ); + const results = await Promise.allSettled( + roots.map(async (repo) => { + const branches = f.branches.filter( + (ref) => !knownRefs.has(ref) || (repo && refs[repo]?.some((b) => b.name === ref)), + ); + if (f.branches.length && !branches.length) return []; + const config = (key: string) => call(commands.vcsConfigGet(repo, key)).catch(() => null); + const user = (await config("user.email")) || (await config("user.name")); + const ownAuthors = resolveAuthors(f.authors, user); + if (f.authors.length && !ownAuthors.length) return []; + const page = await call( + commands.vcsLog(repo, { + branch: null, + branches, + authors: ownAuthors, + path: f.path, + author: null, + text: f.text.trim() || null, + textRegex: f.regex, + textMatchCase: f.matchCase, + range: null, + skip: s.commits.filter((c) => commitRepo(c) === repo).length, + limit, + order: o.sort, + firstParent: o.firstParent, + noMerges: o.noMerges, + // The date filter runs in the backend so the graph stays connected. + sinceMs: f.date?.after ?? null, + untilMs: f.date?.before ?? null, + }), + ); + return page.map((c) => ({ + ...c, + repo, + user, + remoteNames: repo ? refs[repo]?.filter((b) => b.remote).map((b) => b.name) : undefined, + })); }), ); + const streams = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); + const failures = results.flatMap((r, i) => + r.status === "rejected" ? [`${roots[i] ? repoName(roots[i]!) : "Git"}: ${errorMessage(r.reason)}`] : [], + ); + const page = mergeRepoLogs(streams, limit); const cur = useGitLog.getState(); if (cur.generation !== gen) return; - const { rows, state } = computeGraph(page, cur.graph); + const { rows, state } = computeGraph(page.map(graphCommit), cur.graph); useGitLog.setState({ commits: cur.commits.concat(page), rows: cur.rows.concat(rows), graph: state, done: page.length < PAGE || cur.commits.length + page.length >= cap, - error: null, + error: failures.join("\n") || null, }); } catch (e) { if (useGitLog.getState().generation === gen) useGitLog.setState({ error: errorMessage(e), done: true }); @@ -469,8 +496,10 @@ export function selectCommit(id: string | null, reveal = false) { /** Cmd/Ctrl-click: toggles `id` in the selection. */ export function toggleCommitSelection(id: string) { useGitLog.setState((s) => { - const on = s.selection.includes(id); - const selection = on ? s.selection.filter((x) => x !== id) : [...s.selection, id]; + const repo = commitRepo(s.commits.find((c) => commitKey(c) === id)!); + const existing = s.selection.filter((key) => commitRepo(s.commits.find((c) => commitKey(c) === key)!) === repo); + const on = existing.includes(id); + const selection = on ? existing.filter((x) => x !== id) : [...existing, id]; return { selection, selected: on ? (selection[selection.length - 1] ?? null) : id }; }); } @@ -479,13 +508,19 @@ export function toggleCommitSelection(id: string) { export function extendCommitSelection(id: string, visible: readonly Commit[]) { useGitLog.setState((s) => { const anchor = s.selection[0] ?? s.selected; - const a = anchor ? visible.findIndex((c) => c.id === anchor) : -1; - const b = visible.findIndex((c) => c.id === id); + const a = anchor ? visible.findIndex((c) => commitKey(c) === anchor) : -1; + const b = visible.findIndex((c) => commitKey(c) === id); if (a < 0 || b < 0) return { selected: id, selection: [id] }; const [from, to] = a <= b ? [a, b] : [b, a]; - const range = visible.slice(from, to + 1).map((c) => c.id); + const range = visible + .slice(from, to + 1) + .filter((c) => commitRepo(c) === commitRepo(visible[b]!)) + .map(commitKey); // Keep the anchor first so further extensions pivot on it. - return { selected: id, selection: [anchor!, ...range.filter((x) => x !== anchor)] }; + return { + selected: id, + selection: range.includes(anchor!) ? [anchor!, ...range.filter((x) => x !== anchor)] : range, + }; }); } @@ -493,17 +528,20 @@ export function extendCommitSelection(id: string, visible: readonly Commit[]) { export function selectedCommits(): Commit[] { const { commits, selection } = useGitLog.getState(); const ids = new Set(selection); - return commits.filter((c) => ids.has(c.id)); + return commits.filter((c) => ids.has(commitKey(c))); } const MAX_SEARCH_PAGES = 30; /** Selects `id` in the log, clearing filters and paging until it is loaded. */ -export async function revealCommit(id: string) { +export async function revealCommit(id: string, repo = currentRepo()) { selectGitTab("log"); const f = useGitLog.getState().filters; const filtered = hasFilters(f); - const find = () => useGitLog.getState().commits.find((c) => c.id === id || c.id.startsWith(id)); + const find = () => + useGitLog + .getState() + .commits.find((c) => (!commitRepo(c) || commitRepo(c) === repo) && (c.id === id || c.id.startsWith(id))); if (filtered) { useGitLog.setState({ filters: emptyFilters() }); await reloadLog(); @@ -512,24 +550,25 @@ export async function revealCommit(id: string) { } for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore(); const hit = find(); - if (hit) selectCommit(hit.id, true); + if (hit) selectCommit(commitKey(hit), true); } /** Go to Parent Commit: selects the first parent, paging the log until it is loaded. */ export async function goToParent(c: Commit) { const parent = c.parents[0]; if (!parent) return; - const find = () => useGitLog.getState().commits.find((x) => x.id === parent); + const find = () => useGitLog.getState().commits.find((x) => x.id === parent && commitRepo(x) === commitRepo(c)); for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore(); - if (find()) selectCommit(parent, true); + if (find()) selectCommit(commitKey(find()!), true); } /** Go to Child Commit: the nearest newer loaded commit having `c` as a parent. */ export function goToChild(c: Commit) { const { commits } = useGitLog.getState(); - const index = commits.findIndex((x) => x.id === c.id); + const index = commits.findIndex((x) => commitKey(x) === commitKey(c)); for (let i = index - 1; i >= 0; i--) { - if (commits[i]!.parents.includes(c.id)) return selectCommit(commits[i]!.id, true); + if (commitRepo(commits[i]!) === commitRepo(c) && commits[i]!.parents.includes(c.id)) + return selectCommit(commitKey(commits[i]!), true); } } @@ -540,6 +579,7 @@ export function updateLogOptions(patch: Partial) { /** Highlight › My Commits: authored by the current user. */ export function isMine(c: Commit, user: string | null): boolean { + if ("user" in c) user = (c as RepoCommit).user ?? null; if (!user) return false; const u = user.toLowerCase(); return c.email.toLowerCase() === u || c.author.toLowerCase() === u; @@ -550,12 +590,13 @@ export function isMine(c: Commit, user: string | null): boolean { * loaded (and clearing the filters when they hide it). False when there is no such commit. */ export async function goToRef(rev: string): Promise { + const repo = currentRepo(); const target = rev.trim(); if (!target) return false; let id: string | undefined; try { const [hit] = await call( - commands.vcsLog(currentRepo(), { + commands.vcsLog(repo, { branch: target, path: null, author: null, @@ -572,17 +613,24 @@ export async function goToRef(rev: string): Promise { if (!id) return false; const tab = useGitLog.getState().tab; selectGitTab(isLogTab(tab) ? tab : "log"); - const find = () => useGitLog.getState().commits.some((c) => c.id === id); + const find = () => + useGitLog.getState().commits.some((c) => c.id === id && (!commitRepo(c) || commitRepo(c) === repo)); if (!useGitLog.getState().commits.length) await reloadLog(); for (let i = 0; i < MAX_SEARCH_PAGES && !find() && !useGitLog.getState().done; i++) await loadMore(); - if (!find()) await revealCommit(id); + if (!find()) await revealCommit(id, repo); if (!find()) return false; // A commit inside a collapsed linear branch: expand them (IDEA does the same). if (logOptions().collapseLinear) { - const head = useVcs.getState().branch?.headCommit; - const shown = collapseLinear(useGitLog.getState().commits, new Set(head ? [head] : [])).commits; - if (!shown.some((c) => c.id === id)) setLogOptions({ collapseLinear: false }); + const commits = useGitLog.getState().commits; + const heads = useVcs.getState().heads; + const headKeys = new Set(commits.filter((c) => c.id === heads[commitRepo(c) ?? ""]?.headCommit).map(commitKey)); + const shown = collapseLinear(commits.map(graphCommit), headKeys).commits; + if (!shown.some((c) => c.id === commitKey({ id: id!, repo }))) { + setLogOptions({ collapseLinear: false }); + } } - selectCommit(id, true); + const hit = useGitLog.getState().commits.find((c) => c.id === id && (!commitRepo(c) || commitRepo(c) === repo)); + if (!hit) return false; + selectCommit(commitKey(hit), true); return true; } diff --git a/src/features/vcs/multiRoot.ts b/src/features/vcs/multiRoot.ts new file mode 100644 index 00000000..5057f3b2 --- /dev/null +++ b/src/features/vcs/multiRoot.ts @@ -0,0 +1,43 @@ +/** Repository identity for log rows and shared repository queries. */ +import type { Branch, Commit } from "@/bindings"; +import { call, commands } from "@/platform/ipc"; + +export type RepoCommit = Commit & { repo?: string | null; user?: string | null; remoteNames?: string[] }; +export const commitRepo = (c: Commit | undefined): string | null | undefined => (c as RepoCommit | undefined)?.repo; +export const commitKey = (c: Pick): string => (c.repo ? `${c.repo}\0${c.id}` : c.id); +export const graphCommit = (c: Commit): Commit => ({ + ...c, + id: commitKey(c), + parents: c.parents.map((id) => commitKey({ ...c, id })), +}); + +/** Merge ordered streams without breaking any repository's topological order. */ +export function mergeRepoLogs(streams: readonly RepoCommit[][], limit: number): RepoCommit[] { + const offsets = streams.map(() => 0); + const result: RepoCommit[] = []; + while (result.length < limit) { + let best = -1; + for (let i = 0; i < streams.length; i++) { + const c = streams[i]![offsets[i]!]; + if ( + c && + (best < 0 || + (c.commitTimeMs ?? c.timeMs ?? 0) > + (streams[best]![offsets[best]!]!.commitTimeMs ?? streams[best]![offsets[best]!]!.timeMs ?? 0)) + ) + best = i; + } + if (best < 0) break; + result.push(streams[best]![offsets[best]!]!); + offsets[best]!++; + } + return result; +} + +export async function branchesByRepo(roots: readonly string[]): Promise> { + return Object.fromEntries( + await Promise.all( + roots.map(async (root) => [root, await call(commands.vcsBranches(root)).catch(() => [] as Branch[])] as const), + ), + ); +} diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index 2c0d6975..7727f45b 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -1,3 +1,4 @@ +import { commitKey, commitRepo } from "./multiRoot"; /** * Operations on history: Continue / Abort / Skip of a stopped merge, rebase, * cherry-pick or revert, and the Git Log commit actions (Cherry-Pick, Revert @@ -219,10 +220,10 @@ export function newestFirst( commits: readonly Commit[], log: readonly Commit[] = useGitLog.getState().commits, ): Commit[] { - const pos = new Map(log.map((c, i) => [c.id, i])); + const pos = new Map(log.map((c, i) => [commitKey(c), i])); return [...commits].sort((a, b) => { - const pa = pos.get(a.id); - const pb = pos.get(b.id); + const pa = pos.get(commitKey(a)); + const pb = pos.get(commitKey(b)); if (pa !== undefined && pb !== undefined) return pa - pb; return (b.timeMs ?? 0) - (a.timeMs ?? 0); }); @@ -242,6 +243,7 @@ export async function cherryPick(commits: Commit[]) { failure: "Cherry-pick failed", onError: conflictsInstead, }, + commitRepo(commits[0]!) ?? currentRepo(), ); } @@ -253,6 +255,7 @@ export async function revertCommits(commits: Commit[]) { await runGitOperation( { op: "revert", commits: ordered.map((c) => c.id) }, { progress: "Reverting", success: `Reverted ${label}`, failure: "Revert failed", onError: conflictsInstead }, + commitRepo(commits[0]!) ?? currentRepo(), ); } @@ -277,6 +280,7 @@ export function showResetDialog(commit?: Commit) { } function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void }) { + const [repo] = useState(() => (commit && commitRepo(commit)) || currentRepo()); const head = useVcs((s) => s.branch?.head ?? "HEAD"); const [mode, setMode] = useState("mixed"); const [target, setTarget] = useState(commit?.id ?? "HEAD"); @@ -291,6 +295,7 @@ function ResetDialog({ commit, onClose }: { commit?: Commit; onClose: () => void success: `Reset ${head} to ${commit ? short(commit.id) : rev} (${mode})`, failure: "Reset failed", }, + repo, ); }; return ( @@ -356,7 +361,8 @@ 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(currentRepo())).catch(() => [] as string[]); + const repo = commitRepo(commit) ?? currentRepo(); + const existing = await call(commands.vcsTags(repo)).catch(() => [] as string[]); const r = await promptText({ title: tx`Create New Tag On ${short(commit.id)}`, label: "Enter the name of new tag:", @@ -365,7 +371,7 @@ export async function newTagOn(commit: Commit) { validate: (v) => validateTagName(v, existing), }); if (!r) return; - const create = () => call(commands.vcsCreateTag(currentRepo(), r.value, commit.id, null, false)); + const create = () => call(commands.vcsCreateTag(repo, r.value, commit.id, null, false)); const ok = await runOp(`Create tag ${r.value}`, create, { success: `Created tag ${r.value} on ${short(commit.id)}`, }); @@ -379,13 +385,14 @@ export function showTagDialog() { } function TagDialog({ onClose }: { onClose: () => void }) { + const [repo] = useState(currentRepo); const [name, setName] = useState(""); const [commit, setCommit] = useState("HEAD"); const [message, setMessage] = useState(""); const [force, setForce] = useState(false); const [existing, setExisting] = useState([]); useEffect(() => { - void call(commands.vcsTags(currentRepo())).then(setExisting, () => undefined); + void call(commands.vcsTags(repo)).then(setExisting, () => undefined); }, []); const error = validateTagName(name, force ? [] : existing); const run = async () => { @@ -394,7 +401,7 @@ function TagDialog({ onClose }: { onClose: () => void }) { const tag = name.trim(); const ok = await runOp( `Create tag ${tag}`, - () => call(commands.vcsCreateTag(currentRepo(), tag, commit.trim() || "HEAD", message || null, force)), + () => call(commands.vcsCreateTag(repo, tag, commit.trim() || "HEAD", message || null, force)), { success: `Created tag ${tag}` }, ); if (ok !== undefined && useGitLog.getState().commits.length) void reloadLog(); @@ -472,7 +479,8 @@ interface Revision { /** Files that differ between `from` and `to` (`null`: the working tree). */ function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revision | null; onClose: () => void }) { - const root = useVcs((s) => s.root) ?? ""; + const [root] = useState(currentRepoRoot); + const [repo] = useState(currentRepo); const [files, setFiles] = useState(null); const [error, setError] = useState(null); const [active, setActive] = useState(0); @@ -480,7 +488,6 @@ function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revis const toRev = to?.rev ?? null; useEffect(() => { let alive = true; - const repo = currentRepo(); call(toRev ? commands.vcsDiffRevisions(repo, fromRev, toRev) : commands.vcsDiffWithWorktree(repo, fromRev)) .then((f) => alive && setFiles(f)) .catch((e) => alive && setError(e instanceof Error ? e.message : String(e))); @@ -489,7 +496,7 @@ function ChangesBetweenDialog({ from, to, onClose }: { from: Revision; to: Revis }; }, [fromRev, toRev]); const open = (f: ChangedFile) => { - const repoRoot = currentRepoRoot() ?? root; + const repoRoot = root; const missing = (label: string): DiffSide => ({ type: "text", text: "", label }); void openDiff({ path: `${repoRoot}/${f.path}`, diff --git a/src/features/vcs/ops.ts b/src/features/vcs/ops.ts index 6d422b25..c76fb17b 100644 --- a/src/features/vcs/ops.ts +++ b/src/features/vcs/ops.ts @@ -321,7 +321,11 @@ export interface GitOperationOptions { * Runs a long local git operation like `runRemote`: progress in the status bar, * output in the Git console, a sticky balloon with Cancel. */ -export async function runGitOperation(op: GitOperation, opts: GitOperationOptions): Promise { +export async function runGitOperation( + op: GitOperation, + opts: GitOperationOptions, + repo = currentRepo(), +): Promise { if (useVcs.getState().remoteBusy) { notifications().notify({ group: "vcs", level: "warning", title: "Another Git operation is in progress" }); return false; @@ -351,7 +355,7 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption task(line.trim() || null, false); }; try { - await call(commands.vcsOperation(currentRepo(), request, op, channel)); + await call(commands.vcsOperation(repo, request, op, channel)); let success = opts.success; if (typeof success === "function") { await refreshNow(); diff --git a/src/features/vcs/patches.tsx b/src/features/vcs/patches.tsx index 182d025c..16ef2905 100644 --- a/src/features/vcs/patches.tsx +++ b/src/features/vcs/patches.tsx @@ -1,3 +1,4 @@ +import { commitRepo } from "./multiRoot"; /** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */ import { useEffect, useState } from "react"; import type { Commit, FileStatus, PatchFile, PatchSource } from "@/bindings"; @@ -12,7 +13,7 @@ import { KIND_COLOR, baseName, dirName, stripSlash } from "./model"; import { errorMessage, logConsole, runOp, saveAll } from "./ops"; import { FileChecklist, defaultChangeFiles, reportApply } from "./shelf"; import { activeList, useChangelists } from "./changelists"; -import { useVcs } from "./store"; +import { currentRepoRoot, useVcs } from "./store"; import styles from "./common.module.css"; import { tx } from "@/platform/i18n"; @@ -76,6 +77,9 @@ async function afterPatchSaved(path: string) { function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: () => void }) { const root = useVcs((s) => s.root) ?? ""; + const [repo] = useState(() => + origin.kind === "commits" ? (commitRepo(origin.commits[0]!) ?? currentRepoRoot()) : currentRepoRoot(), + ); const listName = useChangelists((s) => activeList(s.state).name); const [selected, setSelected] = useState( () => new Set(origin.kind === "changes" ? origin.files.map((f) => f.path) : []), @@ -103,7 +107,7 @@ function CreatePatchDialog({ origin, onClose }: { origin: PatchOrigin; onClose: const done = await runOp( "Create patch", async () => { - const patch = await call(commands.vcsCreatePatch(source)); + const patch = await call(commands.vcsCreatePatch(repo, source)); if (toFile) await call(commands.vcsWritePatch(file.trim(), patch)); else await navigator.clipboard.writeText(patch); return true; @@ -212,12 +216,13 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string) } function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: string; onClose: () => void }) { + const [repo] = useState(currentRepoRoot); const [files, setFiles] = useState(null); const [error, setError] = useState(null); useEffect(() => { let alive = true; void saveAll().then(() => - call(commands.vcsPatchPreview(patch)) + call(commands.vcsPatchPreview(repo, patch)) .then((f) => alive && setFiles(f)) .catch((e) => alive && setError(errorMessage(e))), ); @@ -229,7 +234,7 @@ function ApplyPatchDialog({ patch, source, onClose }: { patch: string; source: s const run = async () => { onClose(); await saveAll(); - const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(patch)), { + const outcome = await runOp(`Apply patch (${source})`, () => call(commands.vcsApplyPatch(repo, patch)), { refresh: false, }); if (outcome) await reportApply(outcome, "Patch applied"); diff --git a/src/features/vcs/rebase.tsx b/src/features/vcs/rebase.tsx index 9f57413a..044b0218 100644 --- a/src/features/vcs/rebase.tsx +++ b/src/features/vcs/rebase.tsx @@ -1,3 +1,4 @@ +import { commitRepo } from "./multiRoot"; /** * Git Log history rewriting: Undo Commit, Edit Commit Message, Drop Commit and * Interactively Rebase from Here (IDEA's Git.Log.ContextMenu). @@ -46,6 +47,7 @@ const pushedWarning = (what: string) => // Undo Commit export async function undoCommit(commit: Commit) { + const repo = commitRepo(commit) ?? currentRepo(); await refreshNow(); const branch = useVcs.getState().branch; const problem = undoCommitProblem(commit, branch); @@ -59,7 +61,7 @@ export async function undoCommit(commit: Commit) { }); if (!ok) return; } - const undo = () => call(commands.vcsUndoCommit(currentRepo(), commit.id)); + const undo = () => call(commands.vcsUndoCommit(repo, commit.id)); const message = await runOp(`Undo commit ${short(commit.id)}`, undo, { success: `Undid commit ${short(commit.id)} “${commit.summary}”; its changes are staged`, }); @@ -71,7 +73,8 @@ export async function undoCommit(commit: Commit) { // Rebase-based actions /** The commits from `target` to HEAD, or a warning when they cannot be rewritten. */ -async function rangeFrom(target: Commit, verb: string): Promise { +async function rangeFrom(target: Commit, verb: string): Promise<(RebaseRange & { repo: string | null }) | null> { + const repo = commitRepo(target) ?? currentRepo(); await refreshNow(); const branch = useVcs.getState().branch; const op = inProgress(branch?.operation); @@ -82,7 +85,7 @@ async function rangeFrom(target: Commit, verb: string): Promise { } /** The range from the oldest of `commits`, when all of them are on the current branch. */ -async function rangeFor(commits: Commit[], verb: string): Promise { +async function rangeFor(commits: Commit[], verb: string): Promise<(RebaseRange & { repo: string | null }) | null> { // Log order is newest first: the last selected row is the oldest commit. const oldest = commits[commits.length - 1]!; const range = await rangeFrom(oldest, verb); @@ -215,7 +225,7 @@ export async function squashCommits(commits: Commit[]) { oldestFirst.map((c) => c.message.trim()).join("\n\n"), ); if (message === null || !message.trim()) return; - await runRebase(range.base, squashPlan(range, ids, message), `Squashed ${commits.length} commits`); + await runRebase(range.base, squashPlan(range, ids, message), `Squashed ${commits.length} commits`, range.repo); } /** @@ -293,7 +303,13 @@ const ACTIONS: Array<{ value: RebaseAction; label: string }> = [ { value: "drop", label: "Drop" }, ]; -function InteractiveRebaseDialog({ range, onClose }: { range: RebaseRange; onClose: () => void }) { +function InteractiveRebaseDialog({ + range, + onClose, +}: { + range: RebaseRange & { repo: string | null }; + onClose: () => void; +}) { const [rows, setRows] = useState(() => initialRows(range.commits)); const problem = planProblem(rows); const noop = planIsNoop(rows, range.commits); @@ -302,7 +318,7 @@ function InteractiveRebaseDialog({ range, onClose }: { range: RebaseRange; onClo const start = () => { if (problem || noop) return; onClose(); - void runRebase(range.base, toEntries(rows), "Rebase finished"); + void runRebase(range.base, toEntries(rows), "Rebase finished", range.repo); }; return ( void }) { } function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClose: () => void }) { - const branch = useVcs((s) => s.branch); - const branches = useVcs((s) => s.branches); + const [repo] = useState(currentRepo); + const [branch] = useState(() => useVcs.getState().branch); + const [branches] = useState(() => useVcs.getState().branches); const [commits, setCommits] = useState(null); const [error, setError] = useState(null); const [force, setForce] = useState(false); @@ -305,10 +306,10 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo if (!head) return []; if (!explicit) { const q = outgoingQuery(upstream); - return logOf(q.branch, q.limit, q.range); + return logOf(q.branch, q.limit, q.range, repo); } const tip = revision ?? head; - return upstream ? logOf(tip, 1000, `${upstream}..${tip}`) : logOf(tip, 50); + return upstream ? logOf(tip, 1000, `${upstream}..${tip}`, repo) : logOf(tip, 50, null, repo); })() .then((c) => alive && setCommits(c)) .catch((e) => alive && setError(e instanceof Error ? e.message : String(e))); @@ -339,6 +340,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo target: retarget ? edited : null, }, `${revision ? `${head} up to ${revision.slice(0, 8)}` : head} → ${targetLabel}`, + [repo], ); return; } @@ -350,6 +352,7 @@ function PushDialog({ target: pushTarget, onClose }: { target: PushTarget; onClo tags: pushTags ? tagsMode : null, }, upstream ? `${head} → ${upstream}` : `${head} → new remote branch`, + repo, ); }; diff --git a/src/features/vcs/store.ts b/src/features/vcs/store.ts index 5cf5b94a..9983af82 100644 --- a/src/features/vcs/store.ts +++ b/src/features/vcs/store.ts @@ -225,14 +225,16 @@ export function applyStatus(status: FileStatus[], branch: BranchInfo | null) { } export async function loadBranches(): Promise { - const branches = await call(commands.vcsBranches(currentRepo())); - useVcs.setState({ branches, branchesLoaded: true }); + const repo = currentRepo(); + const branches = await call(commands.vcsBranches(repo)); + if (repo === currentRepo()) useVcs.setState({ branches, branchesLoaded: true }); return branches; } export async function loadStashes(): Promise { - const stashes = await call(commands.vcsStashes(currentRepo())); - useVcs.setState({ stashes, stashesLoaded: true }); + const repo = currentRepo(); + const stashes = await call(commands.vcsStashes(repo)); + if (repo === currentRepo()) useVcs.setState({ stashes, stashesLoaded: true }); return stashes; }