Файли
openidea/src/features/vcs/store.ts
T
l0ng-ai 825f190030 feat(vcs): Support several git repositories in one project (multi-root)
Only the repository containing the project root was known, so nested clones and
submodules (or a folder of side-by-side service repositories) showed no changes and
could not be committed or pushed. The project now has IDEA's VCS roots: the enclosing
repository plus those found below the project (scanned in the background, rescanned when
a .git appears or goes). Status covers all of them; path-based commands go to the file's
repository and repository-scoped ones take the selected repository.

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

Closes #12
2026-10-23 23:01:07 +08:00

258 рядки
9.6 KiB
TypeScript

/**
* Repository state shared by all VCS views: working tree status, HEAD /
* upstream info, branches and stashes. Refreshed (debounced) on `vcsChanged`
* and `fsChanged` events and after every operation.
*
* A project may hold several repositories (IDEA's VCS roots: nested clones,
* submodules). Status covers all of them; branch info, branches and stashes are
* those of the selected repository (`repo`), which branch / log / remote
* operations act on.
*/
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { create } from "zustand";
import type { Branch, BranchInfo, FileStatus, Stash } from "@/bindings";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { setFileDecorations } from "@/workbench/contributions";
import { effectiveKind, repoLabel, rootOf, statusDecorations, stripSlash } from "./model";
export interface VcsState {
/** The open project is a git repository. */
available: boolean;
root: string | null;
/** Repository roots, the project's own repository first. */
roots: string[];
/** Root → merge / rebase / cherry-pick / revert in progress there. */
operations: Record<string, string>;
/** The selected repository's root (branch widget, log, push…). */
repo: string | null;
status: FileStatus[];
/** `status` reflects the repository (false until the first refresh). */
statusLoaded: boolean;
/** Absolute path (no trailing slash) → status. */
byPath: Map<string, FileStatus>;
branch: BranchInfo | null;
branches: Branch[];
branchesLoaded: boolean;
stashes: Stash[];
stashesLoaded: boolean;
/** Bumped after each refresh; gutters re-query hunks when it changes. */
version: number;
loading: boolean;
error: string | null;
/** A network operation (fetch / pull / push) is running. */
remoteBusy: string | null;
}
const initial: VcsState = {
available: false,
root: null,
roots: [],
operations: {},
repo: null,
status: [],
statusLoaded: false,
byPath: new Map(),
branch: null,
branches: [],
branchesLoaded: false,
stashes: [],
stashesLoaded: false,
version: 0,
loading: false,
error: null,
remoteBusy: null,
};
export const useVcs = create<VcsState>(() => ({ ...initial }));
/** Root of the selected repository; `null` = the project's primary one. */
export function currentRepo(): string | null {
return useVcs.getState().repo;
}
/** Root of the innermost repository containing `path`. */
export function repoOf(path: string, roots: readonly string[] = useVcs.getState().roots): string | null {
return rootOf(path, roots);
}
/** Repository name for display. */
export function repoName(root: string): string {
return repoLabel(root, useVcs.getState().root);
}
/**
* A repository that just stopped in a merge / rebase / cherry-pick / revert (e.g. on
* conflicts after Update Project) while `shown` has none in progress: it is selected so
* the banner, the branch widget and Continue / Abort act on it.
*/
export function attentionRepo(
shown: string | null,
before: Record<string, string>,
now: Record<string, string>,
): string | null {
if (shown && now[shown]) return null;
return Object.keys(now).find((root) => !before[root]) ?? null;
}
/** Selects the repository branch / log / remote operations act on. */
export function selectRepo(root: string): Promise<void> {
if (useVcs.getState().repo === root) return Promise.resolve();
useVcs.setState({ repo: root, branches: [], branchesLoaded: false, stashes: [], stashesLoaded: false });
return refreshNow();
}
export function conflictedFiles(status: readonly FileStatus[] = useVcs.getState().status): FileStatus[] {
return status.filter((f) => effectiveKind(f) === "conflicted");
}
let timer: ReturnType<typeof setTimeout> | undefined;
let inflight: Promise<void> | null = null;
/** The refresh queued behind `inflight` (it may have started before the caller's change). */
let followUp: Promise<void> | null = null;
let conflictListener: ((files: FileStatus[]) => void) | null = null;
let conflictsClearedListener: (() => void) | null = null;
/** Called when the set of conflicted files becomes non-empty. */
export function onConflictsAppeared(fn: (files: FileStatus[]) => void) {
conflictListener = fn;
}
/** Called when the last conflicted file is resolved (or the merge is aborted). */
export function onConflictsCleared(fn: () => void) {
conflictsClearedListener = fn;
}
export function scheduleRefresh(delay = 200) {
if (!useVcs.getState().available) return;
clearTimeout(timer);
timer = setTimeout(() => void refreshNow(), delay);
}
/**
* Refreshes status + branch info now. Concurrent calls coalesce; the returned
* promise always settles after a refresh that started after this call, so callers
* that await it read the state their operation produced.
*/
export function refreshNow(): Promise<void> {
if (!useVcs.getState().available) return Promise.resolve();
if (inflight) {
followUp ??= inflight.then(() => {
followUp = null;
return refreshNow();
});
return followUp;
}
inflight = (async () => {
useVcs.setState({ loading: true });
try {
const s = useVcs.getState();
const [vcsRoots, status, branch, branches, stashes] = await Promise.all([
call(commands.vcsRoots()),
call(commands.vcsStatus()),
call(commands.vcsBranchInfo(s.repo)),
s.branchesLoaded ? call(commands.vcsBranches(s.repo)) : Promise.resolve(s.branches),
s.stashesLoaded ? call(commands.vcsStashes(s.repo)) : Promise.resolve(s.stashes),
]);
const roots = vcsRoots.map((r) => r.root);
const operations = Object.fromEntries(vcsRoots.filter((r) => r.operation).map((r) => [r.root, r.operation!]));
// `null` (before the first refresh) is the primary repository, listed first.
const shown = s.repo && roots.includes(s.repo) ? s.repo : (roots[0] ?? null);
const repo = attentionRepo(shown, s.operations, operations) ?? shown;
useVcs.setState({ operations });
if (repo !== s.repo || roots.join("\n") !== s.roots.join("\n")) useVcs.setState({ roots, repo });
applyStatus(status, branch);
useVcs.setState({ branches, stashes, error: null });
// Branch info and branches above are of another repository: load the selected one's.
if (repo !== shown || (s.repo && repo !== s.repo)) {
useVcs.setState({ branchesLoaded: false, stashesLoaded: false });
void refreshNow();
}
} catch (e) {
useVcs.setState({ error: e instanceof Error ? e.message : String(e) });
} finally {
useVcs.setState((st) => ({ loading: false, version: st.version + 1 }));
inflight = null;
}
})();
return inflight;
}
export function applyStatus(status: FileStatus[], branch: BranchInfo | null) {
const before = conflictedFiles().length;
const byPath = new Map(status.map((f) => [stripSlash(f.path), f]));
useVcs.setState({ status, byPath, branch, statusLoaded: true });
setFileDecorations("vcs", statusDecorations(status, workflowSettings().vcs.showDirtyDirectories));
const conflicts = conflictedFiles(status);
if (before === 0 && conflicts.length > 0) conflictListener?.(conflicts);
else if (before > 0 && conflicts.length === 0) conflictsClearedListener?.();
}
export async function loadBranches(): Promise<Branch[]> {
const branches = await call(commands.vcsBranches(currentRepo()));
useVcs.setState({ branches, branchesLoaded: true });
return branches;
}
export async function loadStashes(): Promise<Stash[]> {
const stashes = await call(commands.vcsStashes(currentRepo()));
useVcs.setState({ stashes, stashesLoaded: true });
return stashes;
}
export function statusOf(path: string): FileStatus | undefined {
return useVcs.getState().byPath.get(stripSlash(path));
}
/** Repositories found below a project that is not itself under git (scanned in the background). */
async function discoverRoots() {
const root = useVcs.getState().root;
if (!root || !isTauri()) return;
const roots = await call(commands.vcsRoots()).catch(() => []);
if (!roots.length || useVcs.getState().root !== root || useVcs.getState().available) return;
useVcs.setState({ available: true });
setContext("vcsAvailable", true);
void refreshNow();
}
/** Tracks the open project and backend events. Returns a disposer. */
export function installVcsStore(): () => void {
const onProject = () => {
const project = useProject.getState().project;
const available = !!project?.isGit;
const root = project?.root ?? null;
if (useVcs.getState().root === root && useVcs.getState().available === available) return;
clearTimeout(timer);
useVcs.setState({ ...initial, available, root, version: useVcs.getState().version + 1 });
setContext("vcsAvailable", available);
setFileDecorations("vcs", new Map());
if (available && isTauri()) void refreshNow();
else if (project) void discoverRoots();
};
onProject();
const unsubProject = useProject.subscribe((s, prev) => {
if (s.project !== prev.project) onProject();
});
const offVcs = onIdeEvent("vcsChanged", () => {
if (useVcs.getState().available) scheduleRefresh(200);
else void discoverRoots();
});
const offFs = onIdeEvent("fsChanged", () => scheduleRefresh(250));
const offSettings = onWorkflowSettingsChange(
(s) => s.vcs.showDirtyDirectories,
(show) => {
if (useVcs.getState().available) setFileDecorations("vcs", statusDecorations(useVcs.getState().status, show));
},
);
return () => {
offSettings();
clearTimeout(timer);
unsubProject();
offVcs();
offFs();
setContext("vcsAvailable", false);
setFileDecorations("vcs", new Map());
};
}