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; }