дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
9fb38769ad
коміт
36fd117b29
3 files changed
+651
-10
No files matched your search
@@ -0,0 +1,454 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileStatus, ProjectInfo } from "@/bindings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ stage: vi.fn(), ask: vi.fn(), refresh: vi.fn(), notify: vi.fn(), mode: "ask" }));
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const real = await original<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
commands: { ...real.commands, vcsStage: mocks.stage },
|
||||
};
|
||||
});
|
||||
vi.mock("@/workbench/messages", () => ({ ask: mocks.ask }));
|
||||
vi.mock("@/platform/workflowSettings", () => ({
|
||||
workflowSettings: () => ({ vcs: { confirmation: { onCreate: mocks.mode, onDelete: "never" } } }),
|
||||
}));
|
||||
vi.mock("../store", async () => {
|
||||
const { create } = await import("zustand");
|
||||
return { useVcs: create(() => ({})), refreshNow: mocks.refresh };
|
||||
});
|
||||
import { notifyFileOperation } from "@/platform/fileOps";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
import { installFileConfirmations } from "../confirmations";
|
||||
import { clearConsole } from "../ops";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
const asset = "/p/Assets/New.cs";
|
||||
const meta = asset + ".meta";
|
||||
const file = (
|
||||
path: string,
|
||||
staged: FileStatus["staged"] = null,
|
||||
unstaged: FileStatus["unstaged"] = "untracked",
|
||||
): FileStatus => ({ path, relPath: path.slice(3), staged, unstaged, renamedFrom: null });
|
||||
const publish = (...status: FileStatus[]) => useVcs.setState({ status });
|
||||
const createFile = (path = asset) => notifyFileOperation({ kind: "created", paths: [path] });
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
let dispose: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.mode = "ask";
|
||||
mocks.ask.mockResolvedValue({ button: "ok", checked: false });
|
||||
mocks.refresh.mockResolvedValue(undefined);
|
||||
mocks.stage.mockImplementation(async (paths: string[]) => {
|
||||
publish(
|
||||
...useVcs
|
||||
.getState()
|
||||
.status.map((f) => (paths.includes(f.path) ? { ...f, staged: "added" as const, unstaged: null } : f)),
|
||||
);
|
||||
return { status: "ok", data: null };
|
||||
});
|
||||
clearConsole();
|
||||
provideNotificationService({ notify: mocks.notify });
|
||||
useProject.setState({ project: { root: "/p", unity: {} } as ProjectInfo, opening: false, openingPath: null });
|
||||
useVcs.setState({
|
||||
available: true,
|
||||
root: "/p",
|
||||
roots: ["/p"],
|
||||
repo: "/p",
|
||||
statusLoaded: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
status: [file(asset)],
|
||||
});
|
||||
dispose = installFileConfirmations();
|
||||
});
|
||||
afterEach(() => dispose());
|
||||
|
||||
describe("Unity new-file Git confirmations", () => {
|
||||
it("includes existing metadata in the confirmation and stages both files", async () => {
|
||||
publish(file(asset), file(meta));
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.ask.mock.calls[0]![0].body).toContain("Assets/New.cs.meta");
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
|
||||
});
|
||||
|
||||
it("adds delayed metadata only, preserving later unstaged script edits", async () => {
|
||||
createFile();
|
||||
await settle();
|
||||
publish(file(asset, "added", "modified"), file(meta), file("/p/Assets/Other.cs.meta"));
|
||||
await settle();
|
||||
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
|
||||
expect(useVcs.getState().status[0]!.unstaged).toBe("modified");
|
||||
expect(mocks.ask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("finds metadata generated while the confirmation is open", async () => {
|
||||
mocks.ask.mockImplementationOnce(async () => {
|
||||
publish(file(asset), file(meta));
|
||||
return { button: "ok", checked: false };
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
|
||||
});
|
||||
|
||||
it("also pairs metadata in silent mode", async () => {
|
||||
mocks.mode = "silently";
|
||||
createFile();
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.ask).not.toHaveBeenCalled();
|
||||
expect(mocks.stage).toHaveBeenLastCalledWith([meta]);
|
||||
});
|
||||
|
||||
it("waits for a completed status refresh before adding delayed metadata", async () => {
|
||||
createFile();
|
||||
await settle();
|
||||
useVcs.setState({ loading: true });
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
useVcs.setState({ loading: false });
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenLastCalledWith([meta]);
|
||||
});
|
||||
|
||||
it("finds metadata generated during the initial staging operation", async () => {
|
||||
mocks.stage.mockImplementationOnce(async () => {
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
return { status: "ok", data: null };
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
|
||||
});
|
||||
|
||||
it.each(["declined", "never", "failure"])("does not follow metadata after %s", async (reason) => {
|
||||
if (reason === "declined") mocks.ask.mockResolvedValue({ button: "cancel", checked: false });
|
||||
if (reason === "never") mocks.mode = "never";
|
||||
if (reason === "failure") mocks.stage.mockRejectedValueOnce(new Error("stage failed"));
|
||||
createFile();
|
||||
await settle();
|
||||
const calls = mocks.stage.mock.calls.length;
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
|
||||
it.each(["unstage", "delete", "switch", "opening", "repository", "dispose"])(
|
||||
"cancels pending metadata after %s",
|
||||
async (reason) => {
|
||||
createFile();
|
||||
await settle();
|
||||
if (reason === "unstage") publish(file(asset));
|
||||
if (reason === "delete") publish(file(asset, "added", "deleted"));
|
||||
if (reason === "switch") useProject.setState({ project: { root: "/other", unity: {} } as ProjectInfo });
|
||||
if (reason === "opening") {
|
||||
useProject.setState({ opening: true, openingPath: "/other" });
|
||||
useProject.setState({ opening: false, openingPath: null });
|
||||
}
|
||||
if (reason === "repository") {
|
||||
useVcs.setState({ repo: "/p/other" });
|
||||
useVcs.setState({ repo: "/p" });
|
||||
}
|
||||
if (reason === "dispose") dispose();
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["normal project", "outside Assets", "metadata itself"])("does not pair files in %s", async (reason) => {
|
||||
if (reason === "normal project") useProject.setState({ project: { root: "/p" } as ProjectInfo });
|
||||
const path = reason === "outside Assets" ? "/p/New.cs" : reason === "metadata itself" ? meta : asset;
|
||||
publish(file(path), file(path + ".meta"));
|
||||
createFile(path);
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([path]);
|
||||
});
|
||||
|
||||
it.each(["modified", "ignored"] as const)("does not stage %s metadata", async (kind) => {
|
||||
createFile();
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta, null, kind));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not repeatedly retry a failed metadata addition", async () => {
|
||||
createFile();
|
||||
await settle();
|
||||
mocks.stage.mockRejectedValueOnce(new Error("index locked"));
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "index locked" }));
|
||||
});
|
||||
|
||||
it("does not stage files if the project changed while the dialog was open", async () => {
|
||||
mocks.ask.mockImplementationOnce(async () => {
|
||||
useProject.setState({ project: { root: "/other" } as ProjectInfo });
|
||||
return { button: "ok", checked: false };
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["repository", "opening", "dispose"])("discards an open confirmation after %s", async (reason) => {
|
||||
mocks.ask.mockImplementationOnce(async () => {
|
||||
if (reason === "repository") useVcs.setState({ repo: "/p/nested" });
|
||||
if (reason === "opening") useProject.setState({ opening: true, openingPath: "/other" });
|
||||
if (reason === "dispose") dispose();
|
||||
return { button: "ok", checked: false };
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not stage a source or metadata staged by the user while the dialog was open", async () => {
|
||||
publish(file(asset), file(meta));
|
||||
mocks.ask.mockImplementationOnce(async () => {
|
||||
publish(file(asset, "added", "modified"), file(meta, "added", "modified"));
|
||||
return { button: "ok", checked: false };
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves manually staged metadata even when it was explicitly included in the creation event", async () => {
|
||||
publish(file(asset), file(meta));
|
||||
mocks.ask.mockImplementationOnce(async () => {
|
||||
publish(file(asset), file(meta, "added", "modified"));
|
||||
return { button: "ok", checked: false };
|
||||
});
|
||||
notifyFileOperation({ kind: "created", paths: [asset, meta] });
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
|
||||
expect(useVcs.getState().status[1]!.unstaged).toBe("modified");
|
||||
});
|
||||
|
||||
it.each(["added", "modified", "ignored", "missing"] as const)(
|
||||
"rechecks metadata that becomes %s while the dialog is open",
|
||||
async (kind) => {
|
||||
publish(file(asset), file(meta));
|
||||
mocks.ask.mockImplementationOnce(async () => {
|
||||
publish(
|
||||
file(asset),
|
||||
...(kind === "missing"
|
||||
? []
|
||||
: [file(meta, kind === "added" ? "added" : null, kind === "added" ? "modified" : kind)]),
|
||||
);
|
||||
return { button: "ok", checked: false };
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
|
||||
},
|
||||
);
|
||||
|
||||
it("waits for successful staging before following metadata", async () => {
|
||||
let complete!: () => void;
|
||||
mocks.stage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = () => resolve({ status: "ok", data: null });
|
||||
}),
|
||||
);
|
||||
createFile();
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
complete();
|
||||
await settle();
|
||||
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
|
||||
});
|
||||
|
||||
it("remembers an unstage while the initial staging response is still in flight", async () => {
|
||||
let complete!: () => void;
|
||||
mocks.stage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = () => resolve({ status: "ok", data: null });
|
||||
}),
|
||||
);
|
||||
createFile();
|
||||
await settle();
|
||||
publish(file(asset, "added", null));
|
||||
publish(file(asset));
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
complete();
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
|
||||
});
|
||||
|
||||
it("does not re-add metadata already included in the successful initial operation", async () => {
|
||||
publish(file(asset), file(meta));
|
||||
let complete!: () => void;
|
||||
mocks.stage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = () => resolve({ status: "ok", data: null });
|
||||
}),
|
||||
);
|
||||
createFile();
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta, "added", null));
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
complete();
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
|
||||
});
|
||||
|
||||
it("does not follow metadata after an in-flight addition outlives its project", async () => {
|
||||
let complete!: () => void;
|
||||
mocks.stage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = () => resolve({ status: "ok", data: null });
|
||||
}),
|
||||
);
|
||||
createFile();
|
||||
await settle();
|
||||
useProject.setState({ opening: true, openingPath: "/other" });
|
||||
complete();
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores incomplete or failed status snapshots", async () => {
|
||||
createFile();
|
||||
await settle();
|
||||
useVcs.setState({ statusLoaded: false });
|
||||
publish(file(asset));
|
||||
useVcs.setState({ statusLoaded: true, error: "refresh failed" });
|
||||
publish(file(asset, "added", "modified"), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
useVcs.setState({ error: null });
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenLastCalledWith([meta]);
|
||||
});
|
||||
|
||||
it("does not cancel an addition when the first refresh discovers the repository", async () => {
|
||||
useVcs.setState({ repo: null, roots: [], statusLoaded: false });
|
||||
mocks.refresh.mockImplementationOnce(async () => {
|
||||
useVcs.setState({ roots: ["/p"], repo: "/p", statusLoaded: true });
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
|
||||
});
|
||||
|
||||
it("does not stage from a failed pre-addition refresh", async () => {
|
||||
mocks.ask.mockImplementationOnce(async () => {
|
||||
useVcs.setState({ error: "refresh failed" });
|
||||
return { button: "ok", checked: false };
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks the project after the pre-staging promise resolves", async () => {
|
||||
mocks.refresh.mockResolvedValueOnce(undefined).mockImplementationOnce(async () => {
|
||||
queueMicrotask(() => queueMicrotask(() => useProject.setState({ opening: true, openingPath: "/other" })));
|
||||
});
|
||||
createFile();
|
||||
await settle();
|
||||
expect(mocks.stage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not infer metadata consent for children of a copied directory", async () => {
|
||||
createFile("/p/Assets");
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a batch limited to the explicitly created files", async () => {
|
||||
const other = "/p/Assets/Other.cs";
|
||||
publish(file(asset), file(meta), file(other), file(`${other}.meta`));
|
||||
notifyFileOperation({ kind: "created", paths: [asset, asset] });
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["D:/Unity Project", "D:\\Unity Project\\Assets\\New.cs", "D:/Unity Project/Assets/New.cs"],
|
||||
["//server/share/project", "\\\\server\\share\\project\\Assets\\New.cs", "//server/share/project/Assets/New.cs"],
|
||||
["/p", "/p/Assets/Name\\Part.cs", "/p/Assets/Name\\Part.cs"],
|
||||
["/project space", "/project space/Assets/角色.cs", "/project space/Assets/角色.cs"],
|
||||
])("pairs files with native or literal path characters under %s", async (root, input, path) => {
|
||||
useProject.setState({ project: { root, unity: {} } as ProjectInfo });
|
||||
useVcs.setState({ root, roots: [root], repo: root });
|
||||
publish(file(path), file(`${path}.meta`));
|
||||
createFile(input);
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([path, `${path}.meta`]);
|
||||
});
|
||||
|
||||
it.each(["/p/AssetsOther/New.cs", "/p/Packages/New.cs"])("does not pair %s outside Assets", async (path) => {
|
||||
publish(file(path), file(`${path}.meta`));
|
||||
createFile(path);
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([path]);
|
||||
});
|
||||
|
||||
it("does not add files from another project's similarly named path", async () => {
|
||||
const other = "/project/Assets/New.cs";
|
||||
publish(file(other), file(`${other}.meta`));
|
||||
createFile(other);
|
||||
await settle();
|
||||
expect(mocks.stage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels metadata when the asset's repository disappears", async () => {
|
||||
useVcs.setState({ roots: ["/p", "/p/Assets"] });
|
||||
createFile();
|
||||
await settle();
|
||||
useVcs.setState({ roots: ["/p"] });
|
||||
publish(file(asset, "added", null), file(meta));
|
||||
await settle();
|
||||
expect(mocks.stage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still cancels other pending additions while metadata staging is in flight", async () => {
|
||||
const other = "/p/Assets/Other.cs";
|
||||
publish(file(asset), file(other));
|
||||
notifyFileOperation({ kind: "created", paths: [asset, other] });
|
||||
await settle();
|
||||
let complete!: () => void;
|
||||
mocks.stage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = () => resolve({ status: "ok", data: null });
|
||||
}),
|
||||
);
|
||||
publish(file(asset, "added", null), file(meta), file(other, "added", null));
|
||||
await settle();
|
||||
publish(file(asset, "added", null), file(meta), file(other));
|
||||
publish(file(asset, "added", null), file(meta, "added", null), file(other, "added", null), file(`${other}.meta`));
|
||||
complete();
|
||||
await settle();
|
||||
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset, other], [meta]]);
|
||||
});
|
||||
});
|
||||
@@ -6,20 +6,20 @@
|
||||
import { onFileOperation, type FileOperation } from "@/platform/fileOps";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { ipcPath, isSameOrInside } from "@/platform/paths";
|
||||
import { loadSettings } from "@/platform/project";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { effectiveKind, relTo, stripSlash } from "./model";
|
||||
import { runOp } from "./ops";
|
||||
import { refreshNow, useVcs } from "./store";
|
||||
|
||||
const inside = (root: string, p: string) => p === root || p.startsWith(root.endsWith("/") ? root : root + "/");
|
||||
import { createUnityMetadataStaging } from "./unityMetadata";
|
||||
|
||||
/** Status paths affected by `paths` (a deleted folder covers its content). */
|
||||
function matching(paths: readonly string[], kind: "untracked" | "deleted"): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const f of useVcs.getState().status) {
|
||||
const p = stripSlash(f.path);
|
||||
const p = stripSlash(ipcPath(f.path));
|
||||
if (effectiveKind(f) !== kind || f.staged) continue;
|
||||
for (const q of paths) {
|
||||
if (p === q || p.startsWith(q + "/")) out.add(p);
|
||||
@@ -59,27 +59,33 @@ async function confirmFiles(op: FileOperation["kind"], files: string[]): Promise
|
||||
return { ok: r.button === "ok", remember: r.checked };
|
||||
}
|
||||
|
||||
async function handle(op: FileOperation) {
|
||||
async function handle(op: FileOperation, metadata: ReturnType<typeof createUnityMetadataStaging>) {
|
||||
const vcs = useVcs.getState();
|
||||
if (!vcs.available || !vcs.root || !isTauri()) return;
|
||||
const conf = workflowSettings().vcs.confirmation;
|
||||
const mode = op.kind === "created" ? conf.onCreate : conf.onDelete;
|
||||
if (mode === "never") return;
|
||||
const paths = op.paths.map(stripSlash).filter((p) => inside(vcs.root!, p));
|
||||
const paths = op.paths.map((p) => stripSlash(ipcPath(p))).filter((p) => isSameOrInside(p, vcs.root!));
|
||||
if (!paths.length) return;
|
||||
const addition = metadata.begin(op.kind === "created" ? paths : []);
|
||||
await refreshNow();
|
||||
if (!addition.current()) return;
|
||||
const files = matching(paths, op.kind === "created" ? "untracked" : "deleted");
|
||||
if (!files.length) return;
|
||||
const confirmed = addition.include(files);
|
||||
if (mode === "ask") {
|
||||
const { ok, remember } = await confirmFiles(op.kind, files);
|
||||
const { ok, remember } = await confirmFiles(op.kind, confirmed);
|
||||
if (!addition.current()) return;
|
||||
if (remember) {
|
||||
const key = op.kind === "created" ? "vcs.confirmation.onCreate" : "vcs.confirmation.onDelete";
|
||||
await rememberSetting(key, ok ? "silently" : "never");
|
||||
}
|
||||
if (!ok) return;
|
||||
}
|
||||
const n = files.length;
|
||||
await runOp(
|
||||
const stageFiles = await addition.beforeStage(files);
|
||||
if (!addition.current() || !stageFiles.length) return;
|
||||
const n = stageFiles.length;
|
||||
const ok = await runOp(
|
||||
op.kind === "created"
|
||||
? n === 1
|
||||
? tx("Add 1 file to Git")
|
||||
@@ -87,10 +93,19 @@ async function handle(op: FileOperation) {
|
||||
: n === 1
|
||||
? tx("Remove 1 file from Git")
|
||||
: tx`Remove ${n} files from Git`,
|
||||
() => call(commands.vcsStage(files)),
|
||||
async () => {
|
||||
await call(commands.vcsStage(stageFiles));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await addition.finished(!!ok);
|
||||
}
|
||||
|
||||
export function installFileConfirmations(): () => void {
|
||||
return onFileOperation((op) => void handle(op));
|
||||
const metadata = createUnityMetadataStaging();
|
||||
const stopFiles = onFileOperation((op) => void handle(op, metadata));
|
||||
return () => {
|
||||
stopFiles();
|
||||
metadata.dispose();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача