mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 00:07:29 +00:00
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:
co-authored by
a-rookie-of-C-language
parent
e272131823
commit
35ba957893
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user