feat(vcs): Delete key deletes unversioned files in the Changes view (#1483)

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 <[email protected]>
This commit is contained in:
Jaw authored and GitHub committed 2026-11-05 14:50:20 +08:00
1 parent e7952ce3d8
commit ed4a8b50c1
2 files changed
+35 -5

No files matched your search

+24 -4
View File
@@ -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");
@@ -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"]);