mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user