diff --git a/e2e/services.spec.ts b/e2e/services.spec.ts new file mode 100644 index 00000000..8b396890 --- /dev/null +++ b/e2e/services.spec.ts @@ -0,0 +1,95 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "./fixtures"; + +/** Supply detected configurations without launching desktop processes. */ +async function configure(page: Page, extra = 0) { + await page.evaluate(async (extra) => { + const storePath = "/src/features/run/store.ts"; + const projectPath = "/src/platform/project.ts"; + const { useRun } = await import(storePath); + const { useProject } = await import(projectPath); + localStorage.setItem(`openidea.services.runTypes:${useProject.getState().project.root}`, '["spring-boot","npm"]'); + useRun.setState({ + configs: [ + ["gateway", "spring-boot"], + ["portal", "spring-boot"], + ["web", "npm"], + ["worker", "npm"], + ...Array.from({ length: extra }, (_, i) => [`app-${i}`, "spring-boot"]), + ].map(([id, kind]) => ({ + id, + name: id, + kind, + command: kind, + args: [], + env: {}, + cwd: null, + detected: true, + isTest: false, + debugAdapter: null, + })), + }); + }, extra); + if (!(await page.getByRole("tree", { name: "Services", exact: true }).isVisible())) + await page.keyboard.press("Meta+8"); + await expect(page.getByRole("tree", { name: "Services", exact: true })).toBeVisible(); +} + +async function move(page: Page, source: string, target: string) { + const tree = page.getByRole("tree", { name: "Services", exact: true }); + const sourceRow = tree.getByRole("treeitem", { name: source, exact: true }); + await sourceRow.click(); + const from = await sourceRow.boundingBox(); + const to = await tree.getByRole("treeitem", { name: target, exact: true }).boundingBox(); + if (!from || !to) throw new Error("Service row is not visible"); + await page.mouse.move(from.x + 70, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + 70, to.y + 3, { steps: 8 }); + await page.mouse.up(); +} + +test("service pointer ordering works for Java and npm and survives reload", async ({ workbench: page }) => { + await configure(page); + const tree = page.getByRole("tree", { name: "Services", exact: true }); + await move(page, "portal", "gateway"); + await expect(tree.getByRole("treeitem")).toHaveText(["Spring Boot", "portal", "gateway", "npm", "web", "worker"]); + await expect(tree.getByRole("treeitem", { name: "portal", exact: true })).toHaveAttribute("aria-selected", "true"); + await move(page, "worker", "web"); + await expect(tree.getByRole("treeitem")).toHaveText(["Spring Boot", "portal", "gateway", "npm", "worker", "web"]); + await page.reload(); + await expect(page.getByRole("tree", { name: "Project" })).toBeVisible(); + await configure(page); + await expect(tree.getByRole("treeitem")).toHaveText(["Spring Boot", "portal", "gateway", "npm", "worker", "web"]); + await tree.getByRole("treeitem", { name: "web", exact: true }).click(); + await page.getByRole("button", { name: "Move Up", exact: true }).click(); + await expect(tree.getByRole("treeitem")).toHaveText(["Spring Boot", "portal", "gateway", "npm", "web", "worker"]); +}); + +test("service dragging scrolls across virtual rows without losing source identity", async ({ workbench: page }) => { + await configure(page, 100); + const tree = page.getByRole("tree", { name: "Services", exact: true }); + const source = tree.getByRole("treeitem", { name: "portal", exact: true }); + await source.click(); + const from = await source.boundingBox(); + const bounds = await tree.boundingBox(); + if (!from || !bounds) throw new Error("Service rows are not visible"); + await page.mouse.move(from.x + 70, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(bounds.x + 100, bounds.y + bounds.height - 5, { steps: 8 }); + await expect.poll(() => tree.evaluate((el) => el.scrollTop)).toBeGreaterThan(1000); + const target = tree.getByRole("treeitem", { name: "app-99", exact: true }); + await target.scrollIntoViewIfNeeded(); + const to = await target.boundingBox(); + if (!to) throw new Error("Drop target is not visible"); + await page.mouse.move(to.x + 70, to.y + to.height - 3); + await page.mouse.up(); + await expect(source).toHaveAttribute("aria-selected", "true"); + await expect + .poll(() => + page.evaluate(() => { + const key = Object.keys(localStorage).find((k) => k.startsWith("openidea.services.runOrder:")); + return key ? JSON.parse(localStorage.getItem(key)!)["spring-boot"].slice(-2) : []; + }), + ) + .toEqual(["app-99", "portal"]); +}); diff --git a/src/features/run/serviceOrder.test.ts b/src/features/run/serviceOrder.test.ts new file mode 100644 index 00000000..17697264 --- /dev/null +++ b/src/features/run/serviceOrder.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { moveServiceOrder, orderServices, readServiceOrder, saveServiceOrder, useServiceOrder } from "./serviceOrder"; + +beforeEach(() => localStorage.clear()); + +describe("service display order", () => { + it("orders known IDs, appends new ones and leaves configurations untouched", () => { + const entries = [{ id: "gateway" }, { id: "portal" }, { id: "new" }]; + expect(orderServices(entries, ["gone", "portal", "gateway"]).map((x) => x.id)).toEqual([ + "portal", + "gateway", + "new", + ]); + expect(entries.map((x) => x.id)).toEqual(["gateway", "portal", "new"]); + }); + + it("moves before or after the target while retaining temporarily hidden IDs", () => { + expect(moveServiceOrder(["a", "hidden", "b"], ["a", "b", "new"], "b", "a", "before")).toEqual([ + "b", + "a", + "hidden", + "new", + ]); + expect(moveServiceOrder([], ["a", "b", "c"], "a", "b", "after")).toEqual(["b", "a", "c"]); + }); + + it("ignores missing, self and stale drop targets", () => { + for (const [source, target] of [ + ["a", "a"], + ["gone", "b"], + ["a", "gone"], + ]) { + expect(moveServiceOrder(["b", "a"], ["a", "b"], source!, target!, "before")).toEqual(["b", "a"]); + } + }); +}); + +describe("service order storage", () => { + it("isolates project locations and normalizes malformed records and duplicate IDs", () => { + localStorage.setItem( + "openidea.services.runOrder:/app", + JSON.stringify({ npm: ["b", "b", 4, "", "a"], python: null }), + ); + expect(readServiceOrder("/app")).toEqual({ npm: ["b", "a"] }); + expect(readServiceOrder("ssh://other/app")).toEqual({}); + localStorage.setItem("openidea.services.runOrder:/app", "broken"); + expect(readServiceOrder("/app")).toEqual({}); + localStorage.setItem("openidea.services.runOrder:/app", "[]"); + expect(readServiceOrder("/app")).toEqual({}); + }); + + it("continues with default order when storage cannot be read", () => { + const read = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("denied"); + }); + try { + expect(readServiceOrder("/denied")).toEqual({}); + } finally { + read.mockRestore(); + } + }); + + it("updates mounted views, survives remounts, and reads changes from other windows", () => { + const { result, unmount } = renderHook(() => useServiceOrder("/persist")); + act(() => { + saveServiceOrder("/persist", { npm: ["b", "a"] }); + }); + expect(result.current).toEqual({ npm: ["b", "a"] }); + unmount(); + const next = renderHook(() => useServiceOrder("/persist")); + expect(next.result.current).toEqual({ npm: ["b", "a"] }); + act(() => { + localStorage.setItem("openidea.services.runOrder:/persist", '{"npm":["a","b"]}'); + window.dispatchEvent( + new StorageEvent("storage", { key: "openidea.services.runOrder:/persist", storageArea: localStorage }), + ); + }); + expect(next.result.current).toEqual({ npm: ["a", "b"] }); + act(() => { + localStorage.clear(); + window.dispatchEvent(new StorageEvent("storage", { key: null, storageArea: localStorage })); + }); + expect(next.result.current).toEqual({}); + }); + + it("switches projects without leaking the previous project's order", () => { + saveServiceOrder("/one", { npm: ["b", "a"] }); + saveServiceOrder("/two", { python: ["y", "x"] }); + const { result, rerender } = renderHook(({ location }) => useServiceOrder(location), { + initialProps: { location: "/one" }, + }); + expect(result.current).toEqual({ npm: ["b", "a"] }); + rerender({ location: "/two" }); + expect(result.current).toEqual({ python: ["y", "x"] }); + }); + + it("keeps the in-memory order when writing storage fails", () => { + const { result, unmount } = renderHook(() => useServiceOrder("/unavailable")); + const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + try { + act(() => { + expect(saveServiceOrder("/unavailable", { npm: ["b", "a"] })).toBe(false); + }); + expect(result.current).toEqual({ npm: ["b", "a"] }); + unmount(); + const next = renderHook(() => useServiceOrder("/unavailable")); + expect(next.result.current).toEqual({ npm: ["b", "a"] }); + } finally { + write.mockRestore(); + } + }); +}); diff --git a/src/features/run/serviceOrder.ts b/src/features/run/serviceOrder.ts new file mode 100644 index 00000000..9ee8b65c --- /dev/null +++ b/src/features/run/serviceOrder.ts @@ -0,0 +1,80 @@ +/** Per-project display order, independent of detected or saved run configurations. */ +import { useEffect } from "react"; +import { create } from "zustand"; + +export type ServiceOrder = Record; +const STORAGE_KEY = "openidea.services.runOrder:"; +const EMPTY: ServiceOrder = {}; +const unsaved = new Map(); +const useOrder = create<{ location: string | null; order: ServiceOrder }>(() => ({ location: null, order: EMPTY })); + +export function readServiceOrder(location: string): ServiceOrder { + const pending = unsaved.get(location); + if (pending) return pending; + try { + const raw: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY + location) ?? "{}"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw).flatMap(([kind, ids]) => + Array.isArray(ids) + ? [[kind, [...new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0))]]] + : [], + ), + ); + } catch { + return {}; + } +} + +export function orderServices(entries: readonly T[], saved: readonly string[] = []): T[] { + const rank = new Map(saved.map((id, i) => [id, i])); + return [...entries].sort((a, b) => (rank.get(a.id) ?? saved.length) - (rank.get(b.id) ?? saved.length)); +} + +export function moveServiceOrder( + saved: readonly string[], + ids: readonly string[], + source: string, + target: string, + position: "before" | "after", +): string[] { + if (source === target || !ids.includes(source) || !ids.includes(target)) return [...saved]; + const order = [...new Set([...saved, ...ids])].filter((id) => id !== source); + order.splice(order.indexOf(target) + (position === "after" ? 1 : 0), 0, source); + return order; +} + +export function currentServiceOrder(location: string): ServiceOrder { + const s = useOrder.getState(); + return s.location === location ? s.order : readServiceOrder(location); +} + +/** Returns false if only the in-memory order could be saved. */ +export function saveServiceOrder(location: string, order: ServiceOrder): boolean { + useOrder.setState({ location, order }); + try { + localStorage.setItem(STORAGE_KEY + location, JSON.stringify(order)); + unsaved.delete(location); + return true; + } catch { + unsaved.set(location, order); + return false; + } +} + +export function useServiceOrder(location: string | null): ServiceOrder { + const state = useOrder(); + useEffect(() => { + const reload = () => useOrder.setState({ location, order: location ? readServiceOrder(location) : EMPTY }); + reload(); + const storage = (e: StorageEvent) => { + if ((e.key === null || e.key === STORAGE_KEY + location) && (!e.storageArea || e.storageArea === localStorage)) { + if (location) unsaved.delete(location); + reload(); + } + }; + window.addEventListener("storage", storage); + return () => window.removeEventListener("storage", storage); + }, [location]); + return state.location === location ? state.order : location ? readServiceOrder(location) : EMPTY; +} diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index ee02d76d..ae210604 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -41,6 +41,58 @@ describe("run configurations in Services", () => { const dev = config("dev", "npm"); const scratch = config("context:file", "python"); + it("applies display order to detected and saved configurations of every language", () => { + const configs = [ + config("java-a", "spring-boot"), + config("java-b", "spring-boot"), + config("node-a", "npm"), + { ...config("node-b", "npm"), detected: false }, + config("py-a", "python"), + config("py-b", "python"), + ]; + const items = runServiceItems(configs, [], ["spring-boot", "npm", "python"], [], { + "spring-boot": ["java-b", "java-a"], + npm: ["node-b", "node-a"], + python: ["py-b", "py-a"], + }); + expect(items.map((g) => g.children!.map((c) => c.label))).toEqual([ + ["java-b", "java-a"], + ["node-b", "node-a"], + ["py-b", "py-a"], + ]); + expect(configs.map((c) => c.id)).toEqual(["java-a", "java-b", "node-a", "node-b", "py-a", "py-b"]); + }); + + it.each(["tomcat", "application", "compound"])("orders %s configurations across workspace members", (kind) => { + const first = { ...config("workspace:one:app", kind), cwd: "/workspace/one", module: "one" }; + const second = { ...config("workspace:two:app", kind), cwd: "/workspace/two", module: "two" }; + const fresh = config("workspace:two:new", kind); + const configs = [first, second, fresh]; + const items = runServiceItems(configs, [], [kind], [], { [kind]: [second.id, first.id] }); + expect(items[0]!.children!.map((c) => c.key)).toEqual([second, first, fresh].map((c) => `run:config:${c.id}`)); + expect(configs).toEqual([first, second, fresh]); + }); + + it("orders remembered configurations when their type was not explicitly added", () => { + const items = runServiceItems([build, test], [], [], ["build", "test"], { cargo: ["test", "build"] }); + expect(items[0]!.children!.map((c) => c.key)).toEqual(["run:config:test", "run:config:build"]); + }); + + it("keeps stable service identity and session details after refresh and rename", () => { + const renamed = { ...build, name: "Renamed build" }; + const items = runServiceItems( + [renamed, test], + [session("s1", renamed, "running", 1, { httpPort: 8080 })], + ["cargo"], + [], + { cargo: ["test", "build"] }, + ); + expect(items[0]!.children!.map((c) => [c.key, c.label, c.detail])).toEqual([ + ["run:config:test", "test", undefined], + ["run:config:build", "Renamed build", ":8080"], + ]); + }); + it("lists added types fully and other types only with their runs", () => { const sessions = [ session("s1", dev, "finished", 1), diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index a1b7b170..d11d6993 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -11,11 +11,13 @@ import { create } from "zustand"; import { executeAction } from "@/platform/actions"; import type { RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; +import { projectLocation, useRemote } from "@/platform/remote"; import { tx, useT, useTxTemplate } from "@/platform/i18n"; import { Button, EmptyState, T } from "@/ui"; import type { MenuItem } from "@/ui"; import { toolWindowService, + notifications, useRunConfigTypes, type ServiceItemAction, type ServiceProviderContribution, @@ -25,6 +27,14 @@ import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs"; import { SessionPanel } from "./RunView"; import { startRun, stopSession, useRun, type RunSession } from "./store"; import { isDebugOnly } from "./types"; +import { + currentServiceOrder, + moveServiceOrder, + orderServices, + saveServiceOrder, + useServiceOrder, + type ServiceOrder, +} from "./serviceOrder"; import styles from "./RunServices.module.css"; export const RUN_PROVIDER = "runConfigurations"; @@ -151,6 +161,7 @@ export function runServiceItems( sessions: readonly RunSession[], added: readonly string[], ran: readonly string[] = [], + order: ServiceOrder = {}, ): ServiceTreeItem[] { const latest = latestSessions(sessions); const kinds = [...added]; @@ -166,7 +177,7 @@ export function runServiceItems( for (const s of latest.values()) if (s.config.kind === kind && !entries.some((c) => c.id === s.config.id)) entries.push(s.config); if (!entries.length && DEFAULT_TYPES.includes(kind)) return []; - const children = entries.map((c): ServiceTreeItem => { + const children = orderServices(entries, order[kind]).map((c): ServiceTreeItem => { const s = latest.get(c.id); return { key: CONFIG_PREFIX + c.id, @@ -195,6 +206,8 @@ export function runServiceItems( function useRoots(): ServiceTreeItem[] { const root = useProject((s) => s.project?.root ?? null); + useRemote((s) => s.session); + const order = useServiceOrder(projectLocation(root)); const configs = useRun((s) => s.configs); const sessions = useRun((s) => s.sessions); const types = useAddedTypes((s) => s.types); @@ -206,8 +219,8 @@ function useRoots(): ServiceTreeItem[] { if (ranRoot !== root) useRanConfigs.setState({ root, ids: root ? readRan(root) : [] }); }, [root, typesRoot, ranRoot]); return useMemo( - () => runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : []), - [configs, sessions, types, typesRoot, ran, ranRoot, root], + () => runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order), + [configs, sessions, types, typesRoot, ran, ranRoot, root, order], ); } @@ -391,4 +404,42 @@ export const runServiceProvider: ServiceProviderContribution = { }, detail: RunDetail, addMenu, + reorder: { + canMove: (source, target) => { + if (!source.key.startsWith(CONFIG_PREFIX) || !target.key.startsWith(CONFIG_PREFIX) || source.key === target.key) + return false; + const from = resolve(source), + to = resolve(target); + return !!from.config && !!to.config && from.kind === to.kind; + }, + move: (source, target, position) => { + if (!runServiceProvider.reorder!.canMove(source, target)) return; + const location = projectLocation(useProject.getState().project?.root); + const kind = resolve(source).kind; + if (!location || !kind) return; + const { configs, sessions } = useRun.getState(); + const order = currentServiceOrder(location); + const group = runServiceItems( + configs, + sessions, + currentTypes(), + ranIds(useProject.getState().project?.root ?? null), + order, + ).find((g) => g.key === TYPE_PREFIX + kind); + const ids = group?.children?.map((c) => c.key.slice(CONFIG_PREFIX.length)) ?? []; + const from = source.key.slice(CONFIG_PREFIX.length), + to = target.key.slice(CONFIG_PREFIX.length); + if (!ids.includes(from) || !ids.includes(to)) return; + if ( + !saveServiceOrder(location, { ...order, [kind]: moveServiceOrder(order[kind] ?? [], ids, from, to, position) }) + ) { + notifications().notify({ + group: "run", + level: "warning", + title: "Cannot save service order", + body: "The order will be kept for this session only.", + }); + } + }, + }, }; diff --git a/src/features/services/ServicesTree.reorder.test.tsx b/src/features/services/ServicesTree.reorder.test.tsx new file mode 100644 index 00000000..eb91d735 --- /dev/null +++ b/src/features/services/ServicesTree.reorder.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import type { RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { useRemote } from "@/platform/remote"; +import { validateSettings } from "@/features/settings/model/schema"; +import { runServiceProvider } from "@/features/run/services"; +import { useRun } from "@/features/run/store"; +import { registerServiceProvider } from "@/workbench/contributions"; +import { ServicesTreeView } from "./ServicesTree"; +import { useServicesTree } from "./tree"; + +const config = (id: string, kind: string): RunConfig => ({ + id, + name: id, + kind, + command: "java", + args: [], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, +}); +function project(root: string) { + useProject.setState({ project: { root, name: "app", isGit: false, settings: validateSettings({}) } }); +} +let dispose: Array<() => void>; +const dockerRoots = [{ key: "docker:one", label: "Docker", icon: "file-docker" }]; +beforeEach(() => { + localStorage.clear(); + project("/services"); + useRemote.setState({ session: null }); + useRun.setState({ + configs: [ + config("Gateway", "spring-boot"), + config("Portal", "spring-boot"), + config("Web", "npm"), + config("Worker", "npm"), + ], + sessions: [], + }); + localStorage.setItem("openidea.services.runTypes:/services", '["spring-boot","npm"]'); + useServicesTree.setState({ + roots: {}, + selectedKey: null, + selectedKeys: new Set(), + expanded: new Set(["run:type:spring-boot", "run:type:npm"]), + }); + dispose = [ + registerServiceProvider(runServiceProvider), + registerServiceProvider({ + id: "docker", + order: 10, + useRoots: () => dockerRoots, + }), + ]; +}); +afterEach(() => { + dispose.forEach((f) => f()); + useProject.setState({ project: null }); + useRemote.setState({ session: null }); +}); +const tree = () => screen.getByRole("tree", { name: "Services" }); +const names = () => + within(tree()) + .getAllByRole("treeitem") + .map((el) => el.textContent); +const select = (text: string) => fireEvent.mouseDown(within(tree()).getByText(text).closest('[role="treeitem"]')!); + +describe("service reordering integration", () => { + it("moves detected configurations with accessible controls and retains order after reopening", () => { + const original = useRun.getState().configs; + const { unmount } = render(); + select("Portal"); + fireEvent.click(screen.getByRole("button", { name: "Move Up" })); + expect(names()).toEqual(["Spring Boot", "Portal", "Gateway", "npm", "Web", "Worker", "Docker"]); + expect(useServicesTree.getState().selectedKey).toBe("run:config:Portal"); + expect(useRun.getState().configs).toBe(original); + expect(screen.getByRole("button", { name: "Move Up" })).toBeDisabled(); + unmount(); + render(); + expect(names()).toEqual(["Spring Boot", "Portal", "Gateway", "npm", "Web", "Worker", "Docker"]); + }); + + it("uses the same ordering for npm without enabling groups or Docker", () => { + render(); + select("Worker"); + fireEvent.click(screen.getByRole("button", { name: "Move Up" })); + expect(names()).toEqual(["Spring Boot", "Gateway", "Portal", "npm", "Worker", "Web", "Docker"]); + select("Docker"); + expect(screen.queryByRole("button", { name: "Move Up" })).toBeNull(); + select("Spring Boot"); + expect(screen.queryByRole("button", { name: "Move Up" })).toBeNull(); + }); + + it("preserves multi-selection without offering single-row moves", () => { + render(); + select("Portal"); + fireEvent.mouseDown(within(tree()).getByText("Gateway").closest('[role="treeitem"]')!, { metaKey: true }); + expect(useServicesTree.getState().selectedKeys.size).toBe(2); + expect(screen.queryByRole("button", { name: "Move Up" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Move Down" })).toBeNull(); + expect(names()).toEqual(["Spring Boot", "Gateway", "Portal", "npm", "Web", "Worker", "Docker"]); + }); + + it("cancels an active drag when the project changes", () => { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) { + return new DOMRect(0, Number(this.dataset.row ?? 0) * 24, 400, this.dataset.row === undefined ? 600 : 24); + }); + render(); + const source = within(tree()).getByText("Portal").closest('[role="treeitem"]')!; + const target = within(tree()).getByText("Gateway").closest('[role="treeitem"]')!; + Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => target }); + const pointer = (el: Element | Window, type: string, y: number) => + fireEvent(el, new MouseEvent(type, { bubbles: true, button: 0, clientX: 70, clientY: y })); + pointer(source, "pointerdown", 60); + pointer(window, "pointermove", 26); + expect(document.querySelector('[aria-hidden="true"][role="treeitem"]')).not.toBeNull(); + act(() => project("/other")); + pointer(window, "pointerup", 26); + expect(document.querySelector('[aria-hidden="true"][role="treeitem"]')).toBeNull(); + expect(localStorage.getItem("openidea.services.runOrder:/other")).toBeNull(); + expect(localStorage.getItem("openidea.services.runOrder:/services")).toBeNull(); + vi.restoreAllMocks(); + }); + + it("isolates local and remote projects that share the same root", () => { + render(); + select("Portal"); + fireEvent.click(screen.getByRole("button", { name: "Move Up" })); + act(() => useRemote.setState({ session: { root: "/services", sshConfig: "host", host: "host", commands: [] } })); + expect(names()).toEqual(["Spring Boot", "Gateway", "Portal", "npm", "Web", "Worker", "Docker"]); + act(() => useRemote.setState({ session: null })); + expect(names()).toEqual(["Spring Boot", "Portal", "Gateway", "npm", "Web", "Worker", "Docker"]); + }); +}); diff --git a/src/features/services/ServicesTree.test.tsx b/src/features/services/ServicesTree.test.tsx index 534d295c..efdef943 100644 --- a/src/features/services/ServicesTree.test.tsx +++ b/src/features/services/ServicesTree.test.tsx @@ -113,7 +113,7 @@ describe("Services selection → actions", () => { menu: () => [{ label: "Prune", submenu: [{ label: "Containers", run: () => run(id) }] }], })); const items = selectionMenu( - providers.map((p) => ({ provider: p.id, item: roots[0]! })), + providers.map((p) => ({ provider: p.id, item: roots[0]!, parent: null })), providers, ); const submenu = items[0]; @@ -129,8 +129,8 @@ it("ignores removed rows and avoids invoking a selected group's descendants twic 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" }], + [parent.key, { item: parent, provider: "test", parent: null }], + [child.key, { item: child, provider: "test", parent }], ]); const selection = selectedServices(new Set([parent.key, child.key, "removed"]), index); const actions = selectionActions(selection, [...useServiceProviders.getState().providers.values()]); @@ -146,9 +146,9 @@ it("disables actions when no selected row can run them", () => { 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 one = selectionMenu([{ provider: "a", item: roots[0]!, parent: null }], providers)[0] as { disabled?: boolean }; const many = selectionMenu( - roots.slice(0, 2).map((item) => ({ provider: "a", item })), + roots.slice(0, 2).map((item) => ({ provider: "a", item, parent: null })), providers, )[0] as { disabled?: boolean }; expect(one.disabled).toBeFalsy(); diff --git a/src/features/services/ServicesTree.tsx b/src/features/services/ServicesTree.tsx index ab5511cd..91bd4d4a 100644 --- a/src/features/services/ServicesTree.tsx +++ b/src/features/services/ServicesTree.tsx @@ -8,6 +8,8 @@ import clsx from "clsx"; import { setContext } from "@/platform/context"; import { useTxTemplate, tx } from "@/platform/i18n"; import { shortcutLabel } from "@/platform/keymap"; +import { useProject } from "@/platform/project"; +import { projectLocation, useRemote } from "@/platform/remote"; import { EmptyState, Icon, @@ -19,12 +21,14 @@ import { showContextMenu, T, type TreeRowState, + type TreeReorder, } from "@/ui"; import { useServiceProviders, serviceProviders, type ServiceProviderContribution, type ServiceTreeItem, + type ServiceItemAction, } from "@/workbench/contributions"; import { addServiceMenu, @@ -75,6 +79,9 @@ function Row({ item, state }: { item: ServiceTreeItem; state: TreeRowState }) { } export function ServicesTreeView() { + const root = useProject((s) => s.project?.root ?? null); + useRemote((s) => s.session); + const location = projectLocation(root); const providers = useProviders(); const rootsByProvider = useServicesTree((s) => s.roots); const selectedKey = useServicesTree((s) => s.selectedKey); @@ -86,7 +93,47 @@ 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 = selectionActions(selectedServices(selectedKeys, index), providers); + const reorder: TreeReorder = { + canMove: (source, target) => { + const from = index.get(source.key), + to = index.get(target.key); + return ( + !!from && + !!to && + source.key !== target.key && + from.provider === to.provider && + from.parent?.key === to.parent?.key && + !!providers.find((p) => p.id === from.provider)?.reorder?.canMove(source, target) + ); + }, + move: (source, target, position) => { + if (reorder.canMove(source, target)) + providers.find((p) => p.id === index.get(source.key)?.provider)?.reorder?.move(source, target, position); + }, + }; + const moveActions = (item: ServiceTreeItem): ServiceItemAction[] => { + const located = index.get(item.key); + const siblings = located?.parent?.children; + if (!located || !siblings || !providers.find((p) => p.id === located.provider)?.reorder) return []; + const at = siblings.findIndex((s) => s.key === item.key); + return ([-1, 1] as const).map((delta) => { + const target = siblings[at + delta]; + return { + id: delta < 0 ? "moveUp" : "moveDown", + title: delta < 0 ? "Move Up" : "Move Down", + icon: delta < 0 ? "arrow-up" : "arrow-down", + disabled: !target || !reorder.canMove(item, target), + run: () => { + if (target) reorder.move(item, target, delta < 0 ? "before" : "after"); + }, + }; + }); + }; + const actions = [ + ...selectionActions(selectedServices(selectedKeys, index), providers), + ...(selected && selectedKeys.size === 1 ? moveActions(selected.item) : []), + ]; + const Detail = owner?.detail; const add = shortcutLabel("services.addService"); @@ -156,6 +203,8 @@ export function ServicesTreeView() { onBlur={() => setContext(SERVICES_VIEW_FOCUS, false)} > + key={location} + reorder={reorder} roots={roots} getKey={(n) => n.key} getLabel={(n) => n.label} @@ -180,6 +229,13 @@ export function ServicesTreeView() { } useServicesTree.setState({ selectedKeys: keys, selectedKey: n.key }); const items = selectionMenu(selectedServices(keys, index), providers); + const moves = keys.size === 1 ? moveActions(n) : []; + if (moves.length) + items.push( + { kind: "separator" }, + ...moves.map((a) => ({ label: a.title, icon: a.icon, disabled: a.disabled, run: a.run })), + ); + if (items.length) showContextMenu({ x: e.clientX, y: e.clientY }, items); }} speedSearch diff --git a/src/features/services/tree.ts b/src/features/services/tree.ts index b365b747..451a04af 100644 --- a/src/features/services/tree.ts +++ b/src/features/services/tree.ts @@ -56,16 +56,17 @@ export function clearProviderRoots(provider: string) { export interface Located { item: ServiceTreeItem; provider: string; + parent: ServiceTreeItem | null; } /** Every node by key, with the provider owning it. */ export function indexTree(roots: Record): Map { const out = new Map(); - const visit = (provider: string, item: ServiceTreeItem) => { - out.set(item.key, { item, provider }); - item.children?.forEach((c) => visit(provider, c)); + const visit = (provider: string, item: ServiceTreeItem, parent: ServiceTreeItem | null) => { + out.set(item.key, { item, provider, parent }); + item.children?.forEach((c) => visit(provider, c, item)); }; - for (const [provider, items] of Object.entries(roots)) items.forEach((i) => visit(provider, i)); + for (const [provider, items] of Object.entries(roots)) items.forEach((i) => visit(provider, i, null)); return out; } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c09e63d1..5ee585a2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4999,5 +4999,7 @@ "Tomcat home must contain bin/catalina.sh or bin/catalina.bat and lib/catalina.jar": "Tomcat 安装目录必须包含 bin/catalina.sh 或 bin/catalina.bat,以及 lib/catalina.jar", "Can't rename '{name}' based on syntax: only local variables can be renamed this way": "无法基于语法重命名 '{name}':这种方式只能重命名局部变量", "Returns true when the value is falsey or the variable does not exist. Checking an undefined variable does not emit a warning.": "当值为假值或变量不存在时返回 true。检查未定义的变量不会产生警告。", - "Returns true only when every supplied variable exists and is not null. Checks stop at the first unset or null variable.": "仅当传入的每个变量都存在且不为 null 时返回 true。遇到第一个未设置或为 null 的变量即停止检查。" + "Returns true only when every supplied variable exists and is not null. Checks stop at the first unset or null variable.": "仅当传入的每个变量都存在且不为 null 时返回 true。遇到第一个未设置或为 null 的变量即停止检查。", + "Cannot save service order": "无法保存服务顺序", + "The order will be kept for this session only.": "此顺序仅在当前会话中保留。" } diff --git a/src/ui/Tree.module.css b/src/ui/Tree.module.css index 47e3fef4..3421e8ac 100644 --- a/src/ui/Tree.module.css +++ b/src/ui/Tree.module.css @@ -71,6 +71,23 @@ .loading { color: var(--fg-faint); } +.before::before, +.after::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 2px; + background: var(--focus-ring); + pointer-events: none; + z-index: 1; +} +.before::before { + top: 0; +} +.after::after { + bottom: 0; +} .chevron { flex: none; width: 16px; diff --git a/src/ui/Tree.reorder.test.tsx b/src/ui/Tree.reorder.test.tsx new file mode 100644 index 00000000..ef640bc8 --- /dev/null +++ b/src/ui/Tree.reorder.test.tsx @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useState } from "react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { Tree, type TreeReorder } from "./Tree"; +import { setDragRequiresAlt } from "./pointerDrag"; + +interface Node { + id: string; + children?: Node[]; +} + +let hit: Element | null; +beforeEach(() => { + hit = null; + Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => hit }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) { + const top = Number(this.dataset.row ?? 0) * 24; + return new DOMRect(0, top, 400, this.dataset.row === undefined ? 600 : 24); + }); + setDragRequiresAlt(() => false); +}); +afterEach(() => { + vi.restoreAllMocks(); + setDragRequiresAlt(() => false); +}); + +function Harness({ enabled = true }: { enabled?: boolean }) { + const [roots, setRoots] = useState([ + { id: "group", children: [{ id: "a" }, { id: "b" }, { id: "c" }] }, + { id: "other", children: [{ id: "d" }] }, + ]); + const [activated, setActivated] = useState(""); + const reorder: TreeReorder = { + canMove: (source, target) => !source.children && !target.children, + move: (source, target, position) => + setRoots((list) => + list.map((group) => { + if (!group.children?.some((n) => n.id === source.id)) return group; + const children = group.children.filter((n) => n.id !== source.id); + children.splice(children.findIndex((n) => n.id === target.id) + (position === "after" ? 1 : 0), 0, source); + return { ...group, children }; + }), + ), + }; + return ( + <> + n.id} + getLabel={(n) => n.id} + getChildren={(n) => n.children} + isExpandable={(n) => !!n.children} + defaultExpanded={["group", "other"]} + rowClassName={(n) => (n.id === "b" ? "custom-row" : undefined)} + reorder={enabled ? reorder : undefined} + onActivate={(n) => setActivated(n.id)} + aria-label="Reorder" + /> + {activated} + + ); +} + +const row = (name: string, tree = screen.getByRole("tree")) => within(tree).getByRole("treeitem", { name }); +const labels = () => + within(screen.getByRole("tree")) + .getAllByRole("treeitem") + .map((el) => el.textContent); +const pointer = (el: Element | Window, type: string, y: number, altKey = false) => + fireEvent(el, new MouseEvent(type, { bubbles: true, button: 0, clientX: 30, clientY: y, altKey })); +function drag(source: HTMLElement, target: HTMLElement, after = false, alt = false) { + pointer(source, "pointerdown", source.getBoundingClientRect().top + 12, alt); + hit = target; + const y = target.getBoundingClientRect().top + (after ? 20 : 2); + pointer(window, "pointermove", y, alt); + pointer(window, "pointerup", y, alt); +} + +describe("Tree pointer reordering", () => { + it("moves a sibling before or after its target, preserving its selected identity", () => { + render(); + fireEvent.mouseDown(row("b")); + drag(row("b"), row("a")); + expect(labels()).toEqual(["group", "b", "a", "c", "other", "d"]); + expect(row("b")).toHaveAttribute("aria-selected", "true"); + expect(row("b")).toHaveClass("custom-row"); + drag(row("b"), row("c"), true); + expect(labels()).toEqual(["group", "a", "c", "b", "other", "d"]); + }); + + it("does not reorder across parents or when the feature is absent", () => { + const { rerender } = render(); + drag(row("a"), row("d")); + expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]); + rerender(); + drag(row("b"), row("a")); + expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]); + }); + + it("leaves pointer transport and activation untouched without reordering", () => { + const listeners = vi.spyOn(window, "addEventListener"); + const frames = vi.spyOn(window, "requestAnimationFrame"); + render(); + listeners.mockClear(); + frames.mockClear(); + drag(row("b"), row("a")); + expect( + listeners.mock.calls.filter(([type]) => ["pointermove", "pointerup", "pointercancel"].includes(type)), + ).toEqual([]); + expect(frames).not.toHaveBeenCalled(); + expect(document.querySelector('[aria-hidden="true"][role="treeitem"]')).toBeNull(); + fireEvent.doubleClick(row("b")); + expect(screen.getByRole("status")).toHaveTextContent("b"); + }); + + it("cancels with Escape or pointercancel and never activates after a drag", () => { + render(); + for (const cancel of ["Escape", "pointercancel"]) { + pointer(row("b"), "pointerdown", 60); + hit = row("a"); + pointer(window, "pointermove", 26); + if (cancel === "Escape") fireEvent.keyDown(window, { key: "Escape" }); + else pointer(window, "pointercancel", 26); + pointer(window, "pointerup", 26); + expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]); + } + drag(row("b"), row("a")); + fireEvent.doubleClick(row("b")); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("honors Alt-only dragging and cleans up when unmounted mid-drag", () => { + setDragRequiresAlt(() => true); + const { unmount } = render(); + drag(row("b"), row("a")); + expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]); + drag(row("b"), row("a"), false, true); + expect(labels()).toEqual(["group", "b", "a", "c", "other", "d"]); + pointer(row("b"), "pointerdown", 36, true); + hit = row("c"); + pointer(window, "pointermove", 74, true); + unmount(); + expect(document.querySelector('[aria-hidden="true"]')).toBeNull(); + hit = null; + pointer(window, "pointerup", 74, true); + }); + + it("registers the current container after empty states and async loading", () => { + const move = vi.fn(); + const props = { + getKey: (n: Node) => n.id, + getLabel: (n: Node) => n.id, + getChildren: (n: Node) => n.children, + isExpandable: () => false, + empty: "Nothing", + reorder: { canMove: () => true, move }, + }; + const { rerender } = render(); + for (let i = 0; i < 2; i++) { + rerender(); + drag(row("b"), row("a")); + expect(move).toHaveBeenCalledTimes(i + 1); + rerender(); + } + }); + + it("scrolls at the edge and stops scrolling when cancelled", () => { + let frame: FrameRequestCallback | undefined; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((fn) => { + frame = fn; + return 1; + }); + const cancel = vi.spyOn(window, "cancelAnimationFrame"); + render(); + const tree = screen.getByRole("tree"); + Object.defineProperties(tree, { + scrollHeight: { configurable: true, value: 1200 }, + clientHeight: { configurable: true, value: 600 }, + }); + pointer(row("b"), "pointerdown", 60); + hit = row("c"); + pointer(window, "pointermove", 590); + act(() => frame?.(0)); + expect(tree.scrollTop).toBe(8); + act(() => frame?.(16)); + expect(tree.scrollTop).toBe(16); + fireEvent.keyDown(window, { key: "Escape" }); + expect(cancel).toHaveBeenCalledWith(1); + act(() => frame?.(32)); + expect(tree.scrollTop).toBe(16); + }); + + it("does not accept a drag from another tree with matching keys", () => { + render( + <> + + + , + ); + const trees = screen.getAllByRole("tree"); + drag(row("b", trees[0]!), row("a", trees[1]!)); + expect( + within(trees[0]!) + .getAllByRole("treeitem") + .map((el) => el.textContent), + ).toEqual(["group", "a", "b", "c", "other", "d"]); + expect( + within(trees[1]!) + .getAllByRole("treeitem") + .map((el) => el.textContent), + ).toEqual(["group", "a", "b", "c", "other", "d"]); + }); +}); diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index cf6a9ef4..a0a7ec67 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -29,6 +29,8 @@ import { acquirePopup } from "./popupState"; import styles from "./Tree.module.css"; import { T } from "./T"; import { tx } from "@/platform/i18n"; +import { useTreeReorder, type TreeReorder } from "./treeReorder"; +export type { TreeReorder } from "./treeReorder"; export interface TreeRowState { depth: number; @@ -107,6 +109,8 @@ export interface TreeProps { "aria-label"?: string; handleRef?: Ref; empty?: ReactNode; + /** Optional display reordering between siblings; the caller owns the order. */ + reorder?: TreeReorder; } interface Row { @@ -211,6 +215,8 @@ export function Tree(props: TreeProps) { }, [allRows, matchMap, query, speedSearchMode]); const byKey = useMemo(() => new Map(allRows.map((r) => [r.key, r])), [allRows]); + const emptyState = !rows.length && !!empty && !query; + const reordering = useTreeReorder(scrollRef, rows, emptyState ? undefined : props.reorder); // aria-posinset / aria-setsize among visible siblings (rows are virtualised). const positions = useMemo(() => siblingPositions(rows), [rows]); const indexOfKey = useMemo(() => new Map(rows.map((r, i) => [r.key, i])), [rows]); @@ -462,8 +468,7 @@ export function Tree(props: TreeProps) { const leadRow = leadIndex >= 0 ? rows[leadIndex] : undefined; const noMatch = !!query && matchMap.size === 0; - if (!rows.length && empty && !query) - return
{empty}
; + if (emptyState) return
{empty}
; return (
@@ -544,13 +549,18 @@ export function Tree(props: TreeProps) { rowClassName?.(row.node), isSelected && styles.selected, isLead && styles.lead, + reordering.drop?.key === row.key && styles[reordering.drop.position], )} style={style} + onPointerDown={props.reorder ? (e) => reordering.start(e, row) : undefined} onMouseDown={(e) => onRowMouseDown(e, v.index)} - onDoubleClick={(e) => activate(row, e)} + onDoubleClick={(e) => { + if (!props.reorder || !reordering.suppressActivation.current) activate(row, e); + }} > { if (!row.expandable || e.button !== 0) return; e.stopPropagation(); diff --git a/src/ui/pointerDrag.ts b/src/ui/pointerDrag.ts index 7f53b701..59099963 100644 --- a/src/ui/pointerDrag.ts +++ b/src/ui/pointerDrag.ts @@ -179,4 +179,5 @@ export function startPointerDrag( window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onCancel); window.addEventListener("keydown", onKey, true); + return onCancel; } diff --git a/src/ui/treeReorder.ts b/src/ui/treeReorder.ts new file mode 100644 index 00000000..9d01ed94 --- /dev/null +++ b/src/ui/treeReorder.ts @@ -0,0 +1,135 @@ +/** Optional sibling reordering using the same pointer transport as editor tabs. */ +import { useEffect, useId, useLayoutEffect, useRef, useState, type PointerEvent, type RefObject } from "react"; +import { registerDropTarget, startPointerDrag } from "./pointerDrag"; + +export interface TreeReorder { + canMove(source: T, target: T): boolean; + move(source: T, target: T, position: "before" | "after"): void; +} + +interface ReorderRow { + node: T; + key: string; + parentKey: string | null; + loading?: boolean; +} + +export function useTreeReorder( + container: RefObject, + rows: readonly ReorderRow[], + reorder: TreeReorder | undefined, +) { + const type = `application/x-openidea-tree-${useId()}`; + const latest = useRef({ rows, reorder }); + useLayoutEffect(() => { + latest.current = { rows, reorder }; + }); + const cancel = useRef<(() => void) | undefined>(undefined); + const suppressActivation = useRef(false); + const [drop, setDrop] = useState<{ key: string; position: "before" | "after" } | null>(null); + const enabled = !!reorder; + + useEffect(() => { + const el = container.current; + if (!el || !enabled) return; + let frame = 0; + let pointer: { key: string; x: number; y: number } | null = null; + const targetAt = (key: string, x: number, y: number) => { + const rowEl = document.elementFromPoint(x, y)?.closest("[data-row]"); + if (!rowEl || !el.contains(rowEl)) return null; + const { rows, reorder } = latest.current; + const source = rows.find((r) => r.key === key); + const target = rows[Number(rowEl.dataset.row)]; + if ( + !source || + !target || + source.loading || + target.loading || + source.key === target.key || + source.parentKey !== target.parentKey || + !reorder?.canMove(source.node, target.node) + ) + return null; + const rect = rowEl.getBoundingClientRect(); + const position = y < rect.top + rect.height / 2 ? "before" : "after"; + return { source, target, position } as const; + }; + const update = () => { + const target = pointer && targetAt(pointer.key, pointer.x, pointer.y); + setDrop(target ? { key: target.target.key, position: target.position } : null); + }; + const scroll = () => { + frame = 0; + if (!pointer) return; + const rect = el.getBoundingClientRect(); + const delta = pointer.y < rect.top + 24 ? -8 : pointer.y > rect.bottom - 24 ? 8 : 0; + const before = el.scrollTop; + el.scrollTop = Math.max(0, Math.min(el.scrollHeight - el.clientHeight, before + delta)); + if (el.scrollTop !== before) { + update(); + frame = requestAnimationFrame(scroll); + } + }; + const leave = () => { + pointer = null; + cancelAnimationFrame(frame); + frame = 0; + setDrop(null); + }; + const unregister = registerDropTarget(el, { + accepts: (candidate) => candidate === type, + over: (data, x, y) => { + pointer = { key: data.payload, x, y }; + update(); + if (!frame) frame = requestAnimationFrame(scroll); + }, + leave, + drop: (data, x, y) => { + const target = targetAt(data.payload, x, y); + leave(); + if (target) latest.current.reorder?.move(target.source.node, target.target.node, target.position); + }, + }); + return () => { + cancel.current?.(); + leave(); + unregister(); + }; + }, [container, enabled, type]); + + const start = (e: PointerEvent, row: ReorderRow) => { + suppressActivation.current = false; + if ( + !reorder || + e.button !== 0 || + e.ctrlKey || + e.metaKey || + e.shiftKey || + (e.target as Element).closest("[data-tree-toggle], button, input, a, textarea, select") + ) + return; + if ( + !rows.some( + (target) => + target.key !== row.key && + target.parentKey === row.parentKey && + !target.loading && + reorder.canMove(row.node, target.node), + ) + ) + return; + cancel.current?.(); + cancel.current = startPointerDrag( + e, + e.currentTarget, + { type, payload: row.key }, + { + onEnd: (dragged) => { + suppressActivation.current = dragged; + cancel.current = undefined; + }, + }, + ); + }; + return { drop, start, suppressActivation }; +} diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index c1450e9a..c2c14da9 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -19,6 +19,7 @@ import { } from "react"; import type { RunConfig } from "@/bindings"; import type { MenuItem } from "@/ui/Menu"; +import type { TreeReorder } from "@/ui/Tree"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; @@ -232,6 +233,8 @@ export interface ServiceProviderContribution { banner?: ComponentType; /** Entries of the "Add Service" (+, ⌘N) menu. */ addMenu?(): MenuItem[]; + /** Optional sibling display ordering; does not move services between groups. */ + reorder?: TreeReorder; } export const useServiceProviders = create<{ providers: Map }>(() => ({