diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index b0a4c5b2..e7b25e91 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -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, diff --git a/src/features/services/ServicesTree.test.tsx b/src/features/services/ServicesTree.test.tsx new file mode 100644 index 00000000..534d295c --- /dev/null +++ b/src/features/services/ServicesTree.test.tsx @@ -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()), 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 }) =>
{item.label}
, + }, + ], + ]), + }); +}); + +function setup() { + render(); + 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); +}); diff --git a/src/features/services/ServicesTree.tsx b/src/features/services/ServicesTree.tsx index 615bc4fb..ab5511cd 100644 --- a/src/features/services/ServicesTree.tsx +++ b/src/features/services/ServicesTree.tsx @@ -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(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) => } 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 diff --git a/src/features/services/selection.ts b/src/features/services/selection.ts new file mode 100644 index 00000000..c7938050 --- /dev/null +++ b/src/features/services/selection.ts @@ -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, index: Map): Located[] { + const descendants = new Set(); + 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 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); +} diff --git a/src/features/services/tree.ts b/src/features/services/tree.ts index ad635ba1..b365b747 100644 --- a/src/features/services/tree.ts +++ b/src/features/services/tree.ts @@ -13,12 +13,14 @@ interface TreeState { /** Root nodes per provider id (reported by each provider's `useRoots`). */ roots: Record; selectedKey: string | null; + selectedKeys: Set; expanded: Set; } export const useServicesTree = create(() => ({ roots: {}, selectedKey: null, + selectedKeys: new Set(), expanded: new Set(["docker:default", "docker:default:group:containers"]), }));