From 38ff63658ef141db292e25bce0665c12336bf634 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:05:36 +0800 Subject: [PATCH] feat(vcs): delete changed files from the Changes view (#1596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete… (⌫ / Delete) in the Changes tree was only offered for unversioned files. It now applies to any selected files still on disk, moving them to the Trash; a tracked file then shows as a deleted change. --- src/features/vcs/CommitView.tsx | 34 +++++++++---------- ...nversioned.test.ts => deleteFiles.test.ts} | 32 ++++++++--------- src/features/vcs/changeOps.tsx | 3 +- 3 files changed, 35 insertions(+), 34 deletions(-) rename src/features/vcs/__tests__/{deleteUnversioned.test.ts => deleteFiles.test.ts} (62%) diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 51054122..33871d65 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -48,7 +48,7 @@ import { toggledCheck, type ChangeNode, } from "./changes"; -import { applyStash, commitChanges, deleteUnversioned, dropStash, showUnstashDialog, stashChanges } from "./changeOps"; +import { applyStash, commitChanges, deleteFiles, dropStash, showUnstashDialog, stashChanges } from "./changeOps"; import { CommitOptionsPopup } from "./CommitOptions"; import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists"; import { moveToChangelist } from "./changelistsUi"; @@ -226,14 +226,16 @@ export function listMenu(id: string, active: boolean, files: readonly FileStatus } /** Paths Delete… applies to: only when every selected file is unversioned (versioned ones are rolled back). */ -export function deletableUnversioned(nodes: ChangeNode[]): string[] { +/** The selected files that still exist in the working tree (Delete… removes them from disk). */ +export function deletableFiles(nodes: ChangeNode[]): string[] { const files = filesOf(nodes); - return files.length && files.every((f) => f.unstaged === "untracked") ? files.map((f) => f.path) : []; + const onDisk = (f: FileStatus) => f.unstaged !== "deleted" && !(f.staged === "deleted" && !f.unstaged); + return files.length && files.every(onDisk) ? files.map((f) => f.path) : []; } export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] { const files = filesOf(nodes); - const deletable = deletableUnversioned(nodes); + const deletable = deletableFiles(nodes); const paths = files.map((f) => f.path); const first = nodes.find((n) => n.type === "file"); const untracked = files.filter((f) => f.unstaged === "untracked"); @@ -254,18 +256,16 @@ export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuIte 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) }] + : []), + ...(deletable.length ? [ - { kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }, - ...(deletable.length - ? [ - { - label: "Delete…", - icon: "trash", - shortcut: formatBinding(isMacHost() ? "Backspace" : "Delete"), - run: () => void deleteUnversioned(deletable), - }, - ] - : []), + { + label: "Delete…", + icon: "trash", + shortcut: formatBinding(isMacHost() ? "Backspace" : "Delete"), + run: () => void deleteFiles(deletable), + }, ] : []), ...(toIgnore.length @@ -534,7 +534,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { } else if ((e.key === "Delete" || e.key === "Backspace") && !e.metaKey) { const nodes = selectedNodes(); const group = nodes.find((x) => x.type === "group" && x.changelist); - const deletable = group ? [] : deletableUnversioned(nodes); + const deletable = group ? [] : deletableFiles(nodes); if (group?.changelist) { e.preventDefault(); e.stopPropagation(); @@ -542,7 +542,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { } else if (deletable.length) { e.preventDefault(); e.stopPropagation(); - void deleteUnversioned(deletable); + void deleteFiles(deletable); } } else if (e.key === "F4") { const n = selectedNodes().find((x) => x.type === "file"); diff --git a/src/features/vcs/__tests__/deleteUnversioned.test.ts b/src/features/vcs/__tests__/deleteFiles.test.ts similarity index 62% rename from src/features/vcs/__tests__/deleteUnversioned.test.ts rename to src/features/vcs/__tests__/deleteFiles.test.ts index 63fa36d9..72d5ce9c 100644 --- a/src/features/vcs/__tests__/deleteUnversioned.test.ts +++ b/src/features/vcs/__tests__/deleteFiles.test.ts @@ -13,11 +13,11 @@ vi.mock("../dialogs", async (original) => ({ confirmDialog: mocks.confirm, })); -const { deletableUnversioned, nodeMenu } = await import("../CommitView"); -const { deleteUnversioned } = await import("../changeOps"); +const { deletableFiles, nodeMenu } = await import("../CommitView"); +const { deleteFiles } = await import("../changeOps"); -const file = (rel: string, unstaged: FileStatus["unstaged"]): ChangeNode => { - const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged: null, unstaged, renamedFrom: null }; +const file = (rel: string, unstaged: FileStatus["unstaged"], staged: FileStatus["staged"] = null): ChangeNode => { + const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged, unstaged, renamedFrom: null }; return { key: f.path, type: "file", label: rel, group: "unversioned", files: [f], path: f.path }; }; const deleteItem = (menu: MenuItem[]) => @@ -29,30 +29,30 @@ beforeEach(() => { mocks.call.mockResolvedValue(null); }); -describe("Delete… for unversioned files", () => { - it("is offered only when every selected file is unversioned", () => { +describe("Delete… in the Changes view", () => { + it("is offered when every selected file is still on disk", () => { 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(); + expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("c.txt", "modified")], ["/p"]))).toBeDefined(); + expect(deleteItem(nodeMenu([file("c.txt", "modified")], ["/p"]))).toBeDefined(); + expect(deleteItem(nodeMenu([file("d.txt", null, "added")], ["/p"]))).toBeDefined(); + expect(deleteItem(nodeMenu([file("c.txt", "modified"), file("e.txt", "deleted")], ["/p"]))).toBeUndefined(); + expect(deleteItem(nodeMenu([file("f.txt", null, "deleted")], ["/p"]))).toBeUndefined(); }); it("shows the Delete key and gives the key handler the same paths", () => { expect(deleteItem(nodeMenu([file("a.txt", "untracked")], ["/p"]))).toMatchObject({ shortcut: "⌫" }); - expect(deletableUnversioned([file("a.txt", "untracked"), file("b.txt", "untracked")])).toEqual([ - "/p/a.txt", - "/p/b.txt", - ]); - expect(deletableUnversioned([file("a.txt", "untracked"), file("c.txt", "modified")])).toEqual([]); - expect(deletableUnversioned([])).toEqual([]); + expect(deletableFiles([file("a.txt", "untracked"), file("c.txt", "modified")])).toEqual(["/p/a.txt", "/p/c.txt"]); + expect(deletableFiles([file("a.txt", "untracked"), file("e.txt", "deleted")])).toEqual([]); + expect(deletableFiles([])).toEqual([]); }); it("asks first, then deletes each file", async () => { mocks.confirm.mockResolvedValueOnce(false); - await deleteUnversioned(["/p/a.txt"]); + await deleteFiles(["/p/a.txt"]); expect(mocks.fsDelete).not.toHaveBeenCalled(); mocks.confirm.mockResolvedValueOnce(true); - await deleteUnversioned(["/p/a.txt", "/p/b.txt"]); + await deleteFiles(["/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 54569f79..6930959c 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -153,7 +153,8 @@ async function partialCommitFiles(paths: readonly string[]): Promise<{ files: Pa } /** Delete… on unversioned files in the Changes view, as in IDEA: they move to the Trash. */ -export async function deleteUnversioned(paths: readonly string[]) { +/** Delete… in the Changes view: moves the files to the Trash (a tracked file then shows as deleted). */ +export async function deleteFiles(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({