diff --git a/src/features/vcs/__tests__/confirmations.test.ts b/src/features/vcs/__tests__/confirmations.test.ts new file mode 100644 index 000000000..181403923 --- /dev/null +++ b/src/features/vcs/__tests__/confirmations.test.ts @@ -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(); + 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]]); + }); +}); diff --git a/src/features/vcs/confirmations.tsx b/src/features/vcs/confirmations.tsx index b0a0ab7cb..b9145ad81 100644 --- a/src/features/vcs/confirmations.tsx +++ b/src/features/vcs/confirmations.tsx @@ -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(); 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) { 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(); + }; } diff --git a/src/features/vcs/unityMetadata.ts b/src/features/vcs/unityMetadata.ts new file mode 100644 index 000000000..8bef662cf --- /dev/null +++ b/src/features/vcs/unityMetadata.ts @@ -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(); + 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(); + let assets: Map | 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(); + }, + }; +}