feat(vcs): delete changed files from the Changes view

Delete… (⌫ / Delete) in the Commit view's 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.
This commit is contained in:
l0ng-ai
2026-11-06 01:05:22 +08:00
parent 7dbce43c88
commit 4f89cc7456
3 changed files with 35 additions and 34 deletions
+17 -17
View File
@@ -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");
@@ -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"]);
});
});
+2 -1
View File
@@ -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({