feat(vcs): Delete… for unversioned files in the Changes view (#1314)

Unversioned files in the commit view could be added or ignored but not deleted. Their context menu now offers Delete…, as IDEA's Changes view does: it asks first and moves the files to the Trash, only when every selected file is unversioned.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 23:32:56 +08:00
1 джерело f35c781f42
коміт 4906bcb2a1
3 files changed
+83 -3

No files matched your search

+9 -3
Переглянути файл
@@ -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
? [
+48
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
return { ...real, call: mocks.call, commands: { ...real.commands, fsDelete: mocks.fsDelete } };
});
vi.mock("../dialogs", async (original) => ({
...(await original<typeof import("../dialogs")>()),
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"]);
});
});
+26
Переглянути файл
@@ -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}
<br />
<T>Items are moved to the Trash.</T>
</>
),
});
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[],