feat(services): multi-selection and batch actions (#386)

The Services tree dropped the shared Tree's selection set and resolved actions for the lead row only. It now keeps the full selection (Ctrl/Cmd-click, Shift-click ranges, Shift+arrows); toolbar and context-menu actions common to the selection run on every selected service, skipping disabled targets and descendants of a selected group. Entries that open a dialog are disabled for a multi-selection.

Fixes #376
This commit is contained in:
l0ng-ai
2026-10-30 17:48:35 +08:00
committed by GitHub
parent e017154c75
commit fd5908e224
5 changed files with 243 additions and 7 deletions
+1
View File
@@ -228,6 +228,7 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] {
function menu(item: ServiceTreeItem): MenuItem[] {
const items: MenuItem[] = actions(item).map((a) => ({
id: a.id,
label: a.title,
icon: a.icon,
disabled: a.disabled,
+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useServiceProviders, type ServiceTreeItem } from "@/workbench/contributions";
import { ServicesTreeView } from "./ServicesTree";
import { useServicesTree } from "./tree";
import { selectedServices, selectionActions, selectionMenu } from "./selection";
const menu = vi.hoisted(() => vi.fn());
vi.mock("@/ui", async (original) => ({ ...(await original<typeof import("@/ui")>()), showContextMenu: menu }));
const roots: ServiceTreeItem[] = ["one", "two", "three"].map((key) => ({ key, label: key, icon: "run" }));
const run = vi.fn();
const stop = vi.fn();
const remove = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
useServicesTree.setState({ roots: {}, selectedKeys: new Set(), selectedKey: null, expanded: new Set() });
useServiceProviders.setState({
providers: new Map([
[
"test",
{
id: "test",
useRoots: () => roots,
actions: (item) => [
{ id: "run", title: "Run", icon: "run", run: () => run(item.key) },
{ id: "stop", title: "Stop", icon: "stop", disabled: item.key === "three", run: () => stop(item.key) },
],
menu: (item) => [
{ id: "run", label: item.key === "two" ? "Rerun" : "Run", run: () => run(item.key) },
{ label: "Remove from Services", run: () => remove(item.key) },
],
detail: ({ item }) => <div data-testid="detail">{item.label}</div>,
},
],
]),
});
});
function setup() {
render(<ServicesTreeView />);
const tree = screen.getByRole("tree", { name: "Services" });
const row = (name: string) => within(tree).getByText(name);
const selected = () =>
within(tree)
.getAllByRole("treeitem")
.filter((r) => r.getAttribute("aria-selected") === "true")
.map((r) => r.textContent);
const toolbar = screen.getByRole("toolbar");
return { tree, row, selected, toolbar };
}
describe("Services selection → actions", () => {
it.each(["ctrlKey", "metaKey"])("%s toggles rows and Run applies to the selection", (modifier) => {
const { row, selected, toolbar } = setup();
fireEvent.mouseDown(row("one"));
fireEvent.mouseDown(row("two"), { [modifier]: true });
expect(selected()).toEqual(["one", "two"]);
expect(screen.getByTestId("detail")).toHaveTextContent("two");
fireEvent.click(within(toolbar).getByRole("button", { name: "Run" }));
expect(run.mock.calls).toEqual([["one"], ["two"]]);
fireEvent.mouseDown(row("one"), { [modifier]: true });
expect(selected()).toEqual(["two"]);
fireEvent.mouseDown(row("two"), { [modifier]: true });
expect(selected()).toEqual([]);
expect(screen.queryByTestId("detail")).not.toBeInTheDocument();
});
it("Shift click selects a range; Stop skips disabled services", () => {
const { row, selected, toolbar } = setup();
fireEvent.mouseDown(row("one"));
fireEvent.mouseDown(row("three"), { shiftKey: true });
expect(selected()).toEqual(["one", "two", "three"]);
fireEvent.click(within(toolbar).getByRole("button", { name: "Stop" }));
expect(stop.mock.calls).toEqual([["one"], ["two"]]);
});
it("Shift arrows extend and shrink the selection used by Run", () => {
const { tree, row, selected, toolbar } = setup();
fireEvent.mouseDown(row("one"));
fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true });
fireEvent.keyDown(tree, { key: "ArrowDown", shiftKey: true });
expect(selected()).toEqual(["one", "two", "three"]);
fireEvent.keyDown(tree, { key: "ArrowUp", shiftKey: true });
expect(selected()).toEqual(["one", "two"]);
fireEvent.click(within(toolbar).getByRole("button", { name: "Run" }));
expect(run.mock.calls).toEqual([["one"], ["two"]]);
});
it("context menu preserves selected rows and batches stable ids and menu-only actions", () => {
const { row, selected } = setup();
fireEvent.mouseDown(row("one"));
fireEvent.mouseDown(row("two"), { metaKey: true });
fireEvent.contextMenu(row("one"));
expect(selected()).toEqual(["one", "two"]);
const items = menu.mock.calls.at(-1)![1];
items.find((item: { id?: string }) => item.id === "run").run();
items.find((item: { label?: string }) => item.label === "Remove from Services").run();
expect(run.mock.calls).toEqual([["one"], ["two"]]);
expect(remove.mock.calls).toEqual([["one"], ["two"]]);
fireEvent.contextMenu(row("three"));
expect(selected()).toEqual(["three"]);
expect(screen.getByTestId("detail")).toHaveTextContent("three");
menu.mock.calls.at(-1)![1][0].run();
expect(run).toHaveBeenLastCalledWith("three");
});
it("batches common submenu entries across providers", () => {
const providers = ["a", "b"].map((id) => ({
id,
useRoots: () => [],
menu: () => [{ label: "Prune", submenu: [{ label: "Containers", run: () => run(id) }] }],
}));
const items = selectionMenu(
providers.map((p) => ({ provider: p.id, item: roots[0]! })),
providers,
);
const submenu = items[0];
if (submenu && (submenu.kind === undefined || submenu.kind === "item")) {
const item = submenu.submenu?.[0];
if (item?.kind === undefined || item?.kind === "item") item?.run?.();
}
expect(run.mock.calls).toEqual([["a"], ["b"]]);
});
});
it("ignores removed rows and avoids invoking a selected group's descendants twice", () => {
const child = roots[0]!;
const parent = { key: "group", label: "group", icon: "run", children: [child] };
const index = new Map([
[parent.key, { item: parent, provider: "test" }],
[child.key, { item: child, provider: "test" }],
]);
const selection = selectedServices(new Set([parent.key, child.key, "removed"]), index);
const actions = selectionActions(selection, [...useServiceProviders.getState().providers.values()]);
actions.find((a) => a.id === "stop")!.run();
expect(stop.mock.calls).toEqual([["group"]]);
});
it("disables actions when no selected row can run them", () => {
const { row, toolbar } = setup();
fireEvent.mouseDown(row("three"));
expect(within(toolbar).getByRole("button", { name: "Stop" })).toBeDisabled();
});
it("disables dialog entries for a multi-selection", () => {
const providers = [{ id: "a", useRoots: () => [], menu: () => [{ label: "Edit Configuration…", run }] }];
const one = selectionMenu([{ provider: "a", item: roots[0]! }], providers)[0] as { disabled?: boolean };
const many = selectionMenu(
roots.slice(0, 2).map((item) => ({ provider: "a", item })),
providers,
)[0] as { disabled?: boolean };
expect(one.disabled).toBeFalsy();
expect(many.disabled).toBe(true);
});
+10 -7
View File
@@ -36,6 +36,7 @@ import {
useServicesTree,
} from "./tree";
import styles from "./Services.module.css";
import { selectionActions, selectionMenu, selectedServices } from "./selection";
export const SERVICES_VIEW_FOCUS = "servicesViewFocus";
@@ -77,6 +78,7 @@ export function ServicesTreeView() {
const providers = useProviders();
const rootsByProvider = useServicesTree((s) => s.roots);
const selectedKey = useServicesTree((s) => s.selectedKey);
const selectedKeys = useServicesTree((s) => s.selectedKeys);
const expanded = useServicesTree((s) => s.expanded);
const addRef = useRef<HTMLButtonElement>(null);
@@ -84,7 +86,7 @@ export function ServicesTreeView() {
const index = useMemo(() => indexTree(rootsByProvider), [rootsByProvider]);
const selected = selectedKey ? (index.get(selectedKey) ?? null) : null;
const owner = selected ? providers.find((p) => p.id === selected.provider) : undefined;
const actions = selected && owner?.actions ? owner.actions(selected.item) : [];
const actions = selectionActions(selectedServices(selectedKeys, index), providers);
const Detail = owner?.detail;
const add = shortcutLabel("services.addService");
@@ -161,22 +163,23 @@ export function ServicesTreeView() {
isExpandable={(n) => !!n.children?.length}
expanded={expanded}
onExpandedChange={(next) => useServicesTree.setState({ expanded: next })}
selected={selectedKey ? new Set([selectedKey]) : new Set()}
onSelectionChange={(_keys, lead) => useServicesTree.setState({ selectedKey: lead?.key ?? null })}
selected={selectedKeys}
onSelectionChange={(keys, lead) =>
useServicesTree.setState({ selectedKeys: keys, selectedKey: keys.size ? (lead?.key ?? null) : null })
}
renderNode={(n, state) => <Row item={n} state={state} />}
onActivate={(n) => {
const p = index.get(n.key);
providers.find((x) => x.id === p?.provider)?.activate?.(n);
}}
onContextMenu={(n, _sel, e) => {
onContextMenu={(n, keys, e) => {
e.preventDefault();
if (!n) {
showContextMenu({ x: e.clientX, y: e.clientY }, addServiceMenu(providers));
return;
}
useServicesTree.setState({ selectedKey: n.key });
const p = providers.find((x) => x.id === index.get(n.key)?.provider);
const items = p?.menu?.(n) ?? [];
useServicesTree.setState({ selectedKeys: keys, selectedKey: n.key });
const items = selectionMenu(selectedServices(keys, index), providers);
if (items.length) showContextMenu({ x: e.clientX, y: e.clientY }, items);
}}
speedSearch
+74
View File
@@ -0,0 +1,74 @@
import { executeAction, getAction, isActionEnabled } from "@/platform/actions";
import type { MenuItem } from "@/ui";
import type { ServiceItemAction, ServiceProviderContribution } from "@/workbench/contributions";
import type { Located } from "./tree";
/** A selected group already targets its descendants; don't invoke those twice. */
export function selectedServices(keys: Set<string>, index: Map<string, Located>): Located[] {
const descendants = new Set<string>();
const visit = (key: string) => {
for (const child of index.get(key)?.item.children ?? []) {
descendants.add(child.key);
visit(child.key);
}
};
keys.forEach(visit);
return [...keys].flatMap((key) => {
const node = index.get(key);
return node && !descendants.has(key) ? [node] : [];
});
}
function batch<T extends { disabled?: boolean; run?: () => void }>(items: T[]): T {
const enabled = items.filter((item) => !item.disabled);
return { ...items[0]!, disabled: !enabled.length, run: () => enabled.forEach((item) => item.run?.()) };
}
export function selectionActions(
selection: Located[],
providers: readonly ServiceProviderContribution[],
): ServiceItemAction[] {
const groups = selection.map(({ item, provider }) => providers.find((p) => p.id === provider)?.actions?.(item) ?? []);
return (groups[0] ?? []).flatMap((action) => {
const matches = groups.flatMap((group) => group.filter((a) => a.id === action.id));
return matches.length === groups.length ? [batch(matches)] : [];
});
}
function menuKey(item: MenuItem): string | undefined {
if (item.kind === "separator" || item.kind === "header") return undefined;
return item.kind === "action" ? item.action : (item.id ?? item.label);
}
function commonMenu(groups: MenuItem[][]): MenuItem[] {
return (groups[0] ?? []).flatMap((item): MenuItem[] => {
const key = menuKey(item);
if (!key) return [item];
const matches = groups.flatMap((group) => group.filter((entry) => menuKey(entry) === key));
if (matches.length !== groups.length) return [];
const entries = matches
.map((entry) => {
if (entry.kind !== "action") return entry;
const action = getAction(entry.action);
return {
label: entry.label ?? action?.title ?? entry.action,
icon: entry.icon ?? action?.icon,
disabled: entry.disabled || !action || !isActionEnabled(action),
run: () => void executeAction(entry.action, entry.arg),
};
})
.filter((entry) => entry.kind !== "separator" && entry.kind !== "header");
const combined = batch(entries);
// An ellipsis entry opens a dialog for one service; don't open one per selected row.
if (groups.length > 1 && combined.label?.endsWith("…")) combined.disabled = true;
if (entries.some((entry) => entry.submenu)) {
combined.submenu = commonMenu(entries.map((entry) => (entry.disabled ? [] : (entry.submenu ?? []))));
}
return [combined];
});
}
export function selectionMenu(selection: Located[], providers: readonly ServiceProviderContribution[]): MenuItem[] {
const groups = selection.map(({ item, provider }) => providers.find((p) => p.id === provider)?.menu?.(item) ?? []);
return groups.length === 1 ? groups[0]! : commonMenu(groups);
}
+2
View File
@@ -13,12 +13,14 @@ interface TreeState {
/** Root nodes per provider id (reported by each provider's `useRoots`). */
roots: Record<string, ServiceTreeItem[]>;
selectedKey: string | null;
selectedKeys: Set<string>;
expanded: Set<string>;
}
export const useServicesTree = create<TreeState>(() => ({
roots: {},
selectedKey: null,
selectedKeys: new Set(),
expanded: new Set(["docker:default", "docker:default:group:containers"]),
}));