Файли
openidea/src/features/vcs/unityMetadata.ts
T
Hacbit 36fd117b29 feat(vcs): stage Unity metadata with new assets (#1509)
In a Unity project, Add File to Git for a new asset under Assets/ now takes its .meta along: an untracked .meta that already exists is listed in the same confirmation, and one Unity writes later is added once the asset's addition has succeeded. The ask / silently / never setting applies as before; pending metadata is dropped when the asset is unstaged or deleted, or the project or repository changes. File-operation paths are compared in IPC form, so they match the status paths on Windows.

Closes #1429

Co-authored-by: hacbit <[email protected]>
2026-11-05 21:42:08 +08:00

173 рядки
6.2 KiB
TypeScript

import type { FileStatus } from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { ipcPath, isSameOrInside } from "@/platform/paths";
import { useProject } from "@/platform/project";
import { rootOf, stripSlash } from "./model";
import { runOp } from "./ops";
import { refreshNow, useVcs, type VcsState } from "./store";
const pathKey = (path: string) => stripSlash(ipcPath(path));
const untracked = (file: FileStatus | undefined) =>
file?.unstaged === "untracked" && !file.staged && !file.path.endsWith("/");
const ready = (vcs: VcsState) => vcs.available && vcs.statusLoaded && !vcs.loading && !vcs.error;
const selectedRepo = (vcs: VcsState) => vcs.repo ?? vcs.roots[0] ?? null;
const statusByPath = () => new Map(useVcs.getState().status.map((file) => [pathKey(file.path), file]));
interface PendingMetadata {
repo: string;
confirmed: boolean;
seenAdded: boolean;
}
/** Pair only IDE-created files whose addition succeeded, for this project/repository session. */
export function createUnityMetadataStaging() {
const pending = new Map<string, PendingMetadata>();
let generation = 0;
let disposed = false;
let staging = false;
const clear = () => {
generation++;
pending.clear();
};
const drain = () => {
const vcs = useVcs.getState();
if (disposed || !pending.size || !ready(vcs)) return;
const status = statusByPath();
const files: string[] = [];
for (const [asset, entry] of pending) {
const source = status.get(asset);
const meta = status.get(`${asset}.meta`);
if (source?.staged === "added") entry.seenAdded = true;
if (
((entry.confirmed || entry.seenAdded) && source?.staged !== "added") ||
source?.unstaged === "deleted" ||
rootOf(asset, vcs.roots) !== entry.repo ||
rootOf(`${asset}.meta`, vcs.roots) !== entry.repo ||
(meta && !untracked(meta))
) {
pending.delete(asset);
} else if (meta && entry.confirmed && !staging) {
// Consume consent before invoking: a failed addition must not retry on every refresh.
pending.delete(asset);
files.push(meta.path);
}
}
if (!files.length) return;
staging = true;
void runOp(files.length === 1 ? tx("Add 1 file to Git") : tx`Add ${files.length} files to Git`, () =>
call(commands.vcsStage(files)),
).finally(() => {
staging = false;
drain();
});
};
const stopVcs = useVcs.subscribe((s, prev) => {
const before = selectedRepo(prev);
// The first refresh discovers the primary repository; that is not a user switch.
if (s.root !== prev.root || !s.available || (before && selectedRepo(s) !== before)) clear();
else drain();
});
const stopProject = useProject.subscribe((s, prev) => {
if (
s.project?.root !== prev.project?.root ||
!!s.project?.unity !== !!prev.project?.unity ||
(s.opening && !prev.opening)
)
clear();
});
return {
begin(paths: readonly string[]) {
const started = generation;
const current = () => !disposed && generation === started && !useProject.getState().opening;
const project = useProject.getState().project;
const assetsDir = project?.unity ? `${pathKey(project.root)}/Assets` : null;
const requested = new Set(paths.map(pathKey));
const attempt = new Map<string, PendingMetadata>();
let assets: Map<string, string> | undefined;
const include = (files: readonly string[]) => {
const vcs = useVcs.getState();
const status = statusByPath();
const paired = (assets ??= new Map(
files.flatMap((file) => {
const path = pathKey(file);
const source = status.get(path);
const repo = rootOf(path, vcs.roots);
return assetsDir &&
requested.has(path) &&
path !== assetsDir &&
isSameOrInside(path, assetsDir) &&
!path.toLowerCase().endsWith(".meta") &&
source &&
untracked(source) &&
repo &&
rootOf(`${path}.meta`, vcs.roots) === repo
? [[path, repo] as const]
: [];
}),
));
const out = new Set(
files.filter((file) => {
const path = pathKey(file);
return !(paired.has(path) || paired.has(path.replace(/\.meta$/, ""))) || untracked(status.get(path));
}),
);
for (const [asset, repo] of paired) {
if (!untracked(status.get(asset)) || rootOf(asset, vcs.roots) !== repo) {
out.delete(asset);
out.delete(`${asset}.meta`);
paired.delete(asset);
continue;
}
const meta = status.get(`${asset}.meta`);
if (meta && untracked(meta) && rootOf(pathKey(meta.path), vcs.roots) === repo) out.add(meta.path);
}
return [...out];
};
return {
current,
include,
async beforeStage(files: readonly string[]) {
if (assets?.size) {
await refreshNow();
if (!ready(useVcs.getState())) return [];
}
if (!current()) return [];
const out = include(files);
const selected = new Set(out.map(pathKey));
for (const [asset, repo] of assets ?? []) {
// Observe cancellation while the initial IPC is in flight, but do not
// add metadata until it succeeds. Already included metadata is finished.
if (selected.has(`${asset}.meta`)) continue;
const entry = { repo, confirmed: false, seenAdded: false };
attempt.set(asset, entry);
pending.set(asset, entry);
}
return out;
},
async finished(ok: boolean) {
for (const [asset, entry] of attempt) {
if (pending.get(asset) !== entry) continue;
if (ok && current()) entry.confirmed = true;
else pending.delete(asset);
}
if (!ok || !current() || !attempt.size) return;
await refreshNow();
if (current()) drain();
},
};
},
dispose() {
disposed = true;
clear();
stopVcs();
stopProject();
},
};
}