From ed4a8b50c1028f47897905ef8896225d6d1222bf Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:50:20 +0800 Subject: [PATCH] feat(vcs): Delete key deletes unversioned files in the Changes view (#1483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Changes view's Delete… for unversioned files had no key. When every selected file is unversioned, Delete/Backspace now opens the same confirmation (files go to the Trash) and the menu item shows the platform's delete key; Delete on a changelist group still deletes the changelist. Closes #1477 Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- src/features/vcs/CommitView.tsx | 28 ++++++++++++++++--- .../vcs/__tests__/deleteUnversioned.test.ts | 12 +++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index bd17083ae..51054122f 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -3,6 +3,7 @@ * parts also make the Git window's Local Changes / Stash tabs and the Commit dialog. */ import { useWorkflowSettings } from "@/platform/workflowSettings"; +import { formatBinding, isMacHost } from "@/platform/keymap"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from "react"; import { tx } from "@/platform/i18n"; import clsx from "clsx"; @@ -224,8 +225,15 @@ 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[] { + const files = filesOf(nodes); + return files.length && files.every((f) => f.unstaged === "untracked") ? files.map((f) => f.path) : []; +} + export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] { const files = filesOf(nodes); + const deletable = deletableUnversioned(nodes); const paths = files.map((f) => f.path); const first = nodes.find((n) => n.type === "file"); const untracked = files.filter((f) => f.unstaged === "untracked"); @@ -248,9 +256,15 @@ export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuIte ...(untracked.length ? [ { 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)) }] + ...(deletable.length + ? [ + { + label: "Delete…", + icon: "trash", + shortcut: formatBinding(isMacHost() ? "Backspace" : "Delete"), + run: () => void deleteUnversioned(deletable), + }, + ] : []), ] : []), @@ -518,11 +532,17 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { e.stopPropagation(); toggle(selectedNodes()); } else if ((e.key === "Delete" || e.key === "Backspace") && !e.metaKey) { - const group = selectedNodes().find((x) => x.type === "group" && x.changelist); + const nodes = selectedNodes(); + const group = nodes.find((x) => x.type === "group" && x.changelist); + const deletable = group ? [] : deletableUnversioned(nodes); if (group?.changelist) { e.preventDefault(); e.stopPropagation(); void executeAction("vcs.changelist.delete", group.changelist); + } else if (deletable.length) { + e.preventDefault(); + e.stopPropagation(); + void deleteUnversioned(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__/deleteUnversioned.test.ts index 9e517f619..63fa36d94 100644 --- a/src/features/vcs/__tests__/deleteUnversioned.test.ts +++ b/src/features/vcs/__tests__/deleteUnversioned.test.ts @@ -13,7 +13,7 @@ vi.mock("../dialogs", async (original) => ({ confirmDialog: mocks.confirm, })); -const { nodeMenu } = await import("../CommitView"); +const { deletableUnversioned, nodeMenu } = await import("../CommitView"); const { deleteUnversioned } = await import("../changeOps"); const file = (rel: string, unstaged: FileStatus["unstaged"]): ChangeNode => { @@ -36,6 +36,16 @@ describe("Delete… for unversioned files", () => { expect(deleteItem(nodeMenu([file("c.txt", "modified")], ["/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([]); + }); + it("asks first, then deletes each file", async () => { mocks.confirm.mockResolvedValueOnce(false); await deleteUnversioned(["/p/a.txt"]);