дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
258 рядки
9.6 KiB
TypeScript
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());
|
|
};
|
|
}
|