diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 84ca3d674..bd17083ae 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -47,7 +47,7 @@ import { toggledCheck, type ChangeNode, } from "./changes"; -import { applyStash, commitChanges, dropStash, showUnstashDialog, stashChanges } from "./changeOps"; +import { applyStash, commitChanges, deleteUnversioned, dropStash, showUnstashDialog, stashChanges } from "./changeOps"; import { CommitOptionsPopup } from "./CommitOptions"; import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; @@ -224,7 +224,7 @@ export function listMenu(id: string, active: boolean, files: readonly FileStatus ]; } -function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] { +export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] { const files = filesOf(nodes); const paths = files.map((f) => f.path); const first = nodes.find((n) => n.type === "file"); @@ -246,7 +246,13 @@ function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] { items.push( { kind: "action", action: "vcs.rollback", arg: paths, label: "Rollback…" }, ...(untracked.length - ? [{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }] + ? [ + { kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }, + // Only when every selected file is unversioned: versioned ones are rolled back, not deleted. + ...(untracked.length === files.length + ? [{ label: "Delete…", icon: "trash", run: () => void deleteUnversioned(untracked.map((f) => f.path)) }] + : []), + ] : []), ...(toIgnore.length ? [ diff --git a/src/features/vcs/__tests__/deleteUnversioned.test.ts b/src/features/vcs/__tests__/deleteUnversioned.test.ts new file mode 100644 index 000000000..9e517f619 --- /dev/null +++ b/src/features/vcs/__tests__/deleteUnversioned.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FileStatus } from "@/bindings"; +import type { MenuItem } from "@/ui"; +import type { ChangeNode } from "../changes"; + +const mocks = vi.hoisted(() => ({ confirm: vi.fn(), fsDelete: vi.fn(), call: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, call: mocks.call, commands: { ...real.commands, fsDelete: mocks.fsDelete } }; +}); +vi.mock("../dialogs", async (original) => ({ + ...(await original()), + confirmDialog: mocks.confirm, +})); + +const { nodeMenu } = await import("../CommitView"); +const { deleteUnversioned } = await import("../changeOps"); + +const file = (rel: string, unstaged: FileStatus["unstaged"]): ChangeNode => { + const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged: null, unstaged, renamedFrom: null }; + return { key: f.path, type: "file", label: rel, group: "unversioned", files: [f], path: f.path }; +}; +const deleteItem = (menu: MenuItem[]) => + menu.find((i) => i.kind !== "separator" && "label" in i && i.label === "Delete…"); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.fsDelete.mockImplementation((path: string) => path); + mocks.call.mockResolvedValue(null); +}); + +describe("Delete… for unversioned files", () => { + it("is offered only when every selected file is unversioned", () => { + expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("b.txt", "untracked")], ["/p"]))).toBeDefined(); + expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("c.txt", "modified")], ["/p"]))).toBeUndefined(); + expect(deleteItem(nodeMenu([file("c.txt", "modified")], ["/p"]))).toBeUndefined(); + }); + + it("asks first, then deletes each file", async () => { + mocks.confirm.mockResolvedValueOnce(false); + await deleteUnversioned(["/p/a.txt"]); + expect(mocks.fsDelete).not.toHaveBeenCalled(); + + mocks.confirm.mockResolvedValueOnce(true); + await deleteUnversioned(["/p/a.txt", "/p/b.txt"]); + expect(mocks.fsDelete.mock.calls.map((c) => c[0])).toEqual(["/p/a.txt", "/p/b.txt"]); + }); +}); diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index 48e74712f..3a2ad7a01 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -9,6 +9,7 @@ import { useChangelists, withListDefaults } from "./changelists"; import { runCommitChecks } from "./commitChecks"; import { forgetAmendDraft, keepMessageAfterCommit, rememberMessage, useCommit } from "./commitStore"; import { workflowSettings } from "@/platform/workflowSettings"; +import { notifyFileOperation } from "@/platform/fileOps"; import { confirmDialog, openDialog } from "./dialogs"; import { hunkKeys } from "./hunks"; import { KIND_COLOR, baseName, dirName, effectiveKind, relTo, rootOf, stripSlash } from "./model"; @@ -144,6 +145,31 @@ async function partialCommitFiles(paths: readonly string[]): Promise<{ files: Pa return { files, whole }; } +/** Delete… on unversioned files in the Changes view, as in IDEA: they move to the Trash. */ +export async function deleteUnversioned(paths: readonly string[]) { + if (!paths.length) return; + const question = paths.length === 1 ? tx`Delete '${baseName(paths[0]!)}'?` : tx`Delete ${paths.length} items?`; + const ok = await confirmDialog({ + title: "Delete", + okLabel: "Delete", + danger: true, + message: ( + <> + {question} +
+ Items are moved to the Trash. + + ), + }); + if (!ok) return; + await runOp("Delete", async () => { + for (const path of paths) { + await call(commands.fsDelete(path)); + notifyFileOperation({ kind: "deleted", paths: [path] }); + } + }); +} + /** Files the Rollback action applies to by default. */ export function rollbackCandidates( paths?: readonly string[],