fix(vcs): rollback, stage and unstage from a changelist's header menu (#480)

The context menu of a change list's header offered only commit, list management, shelve and patch actions, so rolling back or staging a whole list meant selecting every entry first. Rollback, Stage and Unstage now act on exactly that list's files, with the same enablement as file and directory nodes: Stage is disabled when nothing is unstaged, Unstage when nothing is staged.

Closes #473

Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
arookieofc
2026-10-30 23:48:49 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent e272131823
commit 35ba957893
2 changed files with 78 additions and 3 deletions
+21 -3
View File
@@ -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);
@@ -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<typeof import("@/platform/ipc")>();
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<MenuItem, { kind: "action" }>;
type Item = Extract<MenuItem, { kind?: "item" }>;
/** 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);
});
});