diff --git a/src/features/vcs/CommitView.tsx b/src/features/vcs/CommitView.tsx index 4419261c0..0112ddaab 100644 --- a/src/features/vcs/CommitView.tsx +++ b/src/features/vcs/CommitView.tsx @@ -52,6 +52,7 @@ import { stashTabOf, stashTabs, type ContentTab } from "./gitTabs"; import { openDiff, withChain, workingDiffData } from "./diffInput"; import { KIND_COLOR, KIND_LABEL, fileCount, formatTime, stashLabel, stripSlash } from "./model"; import { call, commands } from "@/platform/ipc"; +import type { FileStatus } from "@/bindings"; import { OperationBanner } from "./operations"; import { ShelfPanel, loadShelves, useShelf } from "./shelf"; import { refreshNow, repoName, selectRepo, useVcs } from "./store"; @@ -172,7 +173,9 @@ function openNode(n: ChangeNode, roots: readonly ChangeNode[]) { void openDiff(withChain(items, Math.max(0, paths.indexOf(n.path))), { preview: true }); } -function listMenu(id: string, active: boolean, paths: string[]): MenuItem[] { +/** Context menu of a change list's header: the list's own actions, then the bulk ones on its files. */ +export function listMenu(id: string, active: boolean, files: readonly FileStatus[]): MenuItem[] { + const paths = files.map((f) => stripSlash(f.path)); const count = paths.length; return [ ...(count ? [{ kind: "action" as const, action: "vcs.changelist.commit", arg: id, label: "Commit…" }] : []), @@ -186,6 +189,22 @@ function listMenu(id: string, active: boolean, paths: string[]): MenuItem[] { { kind: "action" as const, action: "vcs.changelist.delete", arg: id }, ]), { kind: "separator" }, + // The whole list's files, as IDEA offers on a change list's header and as file + // and directory nodes below it already offer (issue #473). + { kind: "action", action: "vcs.rollback", arg: paths, label: "Rollback…", disabled: !count }, + { + label: "Stage", + icon: "plus", + disabled: !files.some((f) => f.unstaged), + run: () => void call(commands.vcsStage(paths)).finally(() => void refreshNow()), + }, + { + label: "Unstage", + icon: "minus", + disabled: !files.some((f) => f.staged), + run: () => void call(commands.vcsUnstage(paths)).finally(() => void refreshNow()), + }, + { kind: "separator" }, ...(count ? [ { kind: "action" as const, action: "vcs.shelve", arg: paths }, @@ -486,8 +505,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) { onContextMenu={(node, sel, e: MouseEvent) => { if (!node) return; if (node.type === "group" && node.changelist) { - const paths = node.files.map((f) => stripSlash(f.path)); - showContextMenu(e, listMenu(node.changelist, !!node.active, paths)); + showContextMenu(e, listMenu(node.changelist, !!node.active, node.files)); return; } const nodes = [...sel].map((k) => byKey.get(k)).filter((n): n is ChangeNode => !!n); diff --git a/src/features/vcs/__tests__/changelistMenu.test.ts b/src/features/vcs/__tests__/changelistMenu.test.ts new file mode 100644 index 000000000..b5da8d0d2 --- /dev/null +++ b/src/features/vcs/__tests__/changelistMenu.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import type { FileStatus } from "@/bindings"; +import type { MenuItem } from "@/ui"; + +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, call: vi.fn(), commands: { ...real.commands, vcsStage: "vcsStage", vcsUnstage: "vcsUnstage" } }; +}); + +const { listMenu } = await import("../CommitView"); + +const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({ + path: `/p/${rel}`, + relPath: rel, + staged, + unstaged, + renamedFrom: null, +}); + +type Action = Extract; +type Item = Extract; + +/** Actions in the menu, by action id. */ +const actions = (items: MenuItem[]) => items.filter((i): i is Action => i.kind === "action"); +/** Labelled items in the menu, by label. */ +const item = (items: MenuItem[], label: string) => + items.find((i): i is Item => i.kind !== "separator" && i.kind !== "action" && i.label === label); + +const files = [fs("a.txt", null, "modified"), fs("b.txt", "modified", null)]; + +describe("change list header menu", () => { + it("rolls back, stages and unstages the whole list", () => { + const menu = listMenu("list-1", false, files); + const rollback = actions(menu).find((a) => a.action === "vcs.rollback"); + expect(rollback?.arg).toEqual(["/p/a.txt", "/p/b.txt"]); + // Both operations apply to the list's files, as on a file or directory node. + expect(item(menu, "Stage")?.disabled).toBe(false); + expect(item(menu, "Unstage")?.disabled).toBe(false); + expect(actions(menu).find((a) => a.action === "vcs.shelve")?.arg).toEqual(["/p/a.txt", "/p/b.txt"]); + }); + + it("keeps the change list's own actions", () => { + const menu = listMenu("list-1", false, files); + const actionsOf = (menu: MenuItem[]) => actions(menu).map((a) => a.action); + expect(actionsOf(menu)).toContain("vcs.changelist.commit"); + expect(actionsOf(menu)).toContain("vcs.changelist.delete"); + // An active list is not offered Set Active / Delete. + expect(actionsOf(listMenu("list-1", true, files))).not.toContain("vcs.changelist.delete"); + }); + + it("disables the operations nothing applies to in an empty list", () => { + const menu = listMenu("list-1", true, []); + expect(actions(menu).find((a) => a.action === "vcs.rollback")?.disabled).toBe(true); + expect(item(menu, "Stage")?.disabled).toBe(true); + expect(item(menu, "Unstage")?.disabled).toBe(true); + }); +});