feat(services): reorder services within a run configuration type (#345)

Services followed configuration detection order, so frequently used services could not be kept together. Services can now be dragged before or after a sibling, with an insertion indicator and edge scrolling, or moved with Move Up/Down from the toolbar and context menu.

The order is stored per project location and type, separately from configuration definitions. New configurations append, windows stay in sync, and a failed save keeps the order for the session with a warning. Sorting in Tree is opt-in, so other trees are unchanged.

Co-authored-by: wangxga <212103502+wangxga@users.noreply.github.com>
This commit is contained in:
wangxga
2026-10-31 01:28:41 +08:00
committed by GitHub
co-authored by wangxga
parent 0660f3c020
commit a3cd99db1f
16 changed files with 985 additions and 17 deletions
+95
View File
@@ -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"]);
});
+115
View File
@@ -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();
}
});
});
+80
View File
@@ -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<string, string[]>;
const STORAGE_KEY = "openidea.services.runOrder:";
const EMPTY: ServiceOrder = {};
const unsaved = new Map<string, ServiceOrder>();
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<T extends { id: string }>(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;
}
+52
View File
@@ -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),
+54 -3
View File
@@ -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.",
});
}
},
},
};
@@ -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(<ServicesTreeView />);
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(<ServicesTreeView />);
expect(names()).toEqual(["Spring Boot", "Portal", "Gateway", "npm", "Web", "Worker", "Docker"]);
});
it("uses the same ordering for npm without enabling groups or Docker", () => {
render(<ServicesTreeView />);
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(<ServicesTreeView />);
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(<ServicesTreeView />);
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(<ServicesTreeView />);
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"]);
});
});
+5 -5
View File
@@ -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();
+57 -1
View File
@@ -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<ServiceTreeItem> = {
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)}
>
<Tree<ServiceTreeItem>
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
+5 -4
View File
@@ -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<string, readonly ServiceTreeItem[]>): Map<string, Located> {
const out = new Map<string, Located>();
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;
}
+3 -1
View File
@@ -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.": "此顺序仅在当前会话中保留。"
}
+17
View File
@@ -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;
+213
View File
@@ -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<Node[]>([
{ id: "group", children: [{ id: "a" }, { id: "b" }, { id: "c" }] },
{ id: "other", children: [{ id: "d" }] },
]);
const [activated, setActivated] = useState("");
const reorder: TreeReorder<Node> = {
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 (
<>
<Tree
roots={roots}
getKey={(n) => 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"
/>
<output>{activated}</output>
</>
);
}
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(<Harness />);
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(<Harness />);
drag(row("a"), row("d"));
expect(labels()).toEqual(["group", "a", "b", "c", "other", "d"]);
rerender(<Harness enabled={false} />);
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(<Harness enabled={false} />);
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(<Harness />);
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(<Harness />);
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(<Tree {...props} roots={[]} />);
for (let i = 0; i < 2; i++) {
rerender(<Tree {...props} roots={[{ id: "a" }, { id: "b" }]} />);
drag(row("b"), row("a"));
expect(move).toHaveBeenCalledTimes(i + 1);
rerender(<Tree {...props} roots={[]} />);
}
});
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(<Harness />);
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(
<>
<Harness />
<Harness />
</>,
);
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"]);
});
});
+13 -3
View File
@@ -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<T> {
"aria-label"?: string;
handleRef?: Ref<TreeHandle>;
empty?: ReactNode;
/** Optional display reordering between siblings; the caller owns the order. */
reorder?: TreeReorder<T>;
}
interface Row<T> {
@@ -211,6 +215,8 @@ export function Tree<T>(props: TreeProps<T>) {
}, [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<T>(props: TreeProps<T>) {
const leadRow = leadIndex >= 0 ? rows[leadIndex] : undefined;
const noMatch = !!query && matchMap.size === 0;
if (!rows.length && empty && !query)
return <div className={clsx(styles.tree, styles.emptyWrap, className)}>{empty}</div>;
if (emptyState) return <div className={clsx(styles.tree, styles.emptyWrap, className)}>{empty}</div>;
return (
<div className={clsx(styles.wrap, className)}>
@@ -544,13 +549,18 @@ export function Tree<T>(props: TreeProps<T>) {
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);
}}
>
<span
className={styles.chevron}
data-tree-toggle
onMouseDown={(e) => {
if (!row.expandable || e.button !== 0) return;
e.stopPropagation();
+1
View File
@@ -179,4 +179,5 @@ export function startPointerDrag(
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onCancel);
window.addEventListener("keydown", onKey, true);
return onCancel;
}
+135
View File
@@ -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<T> {
canMove(source: T, target: T): boolean;
move(source: T, target: T, position: "before" | "after"): void;
}
interface ReorderRow<T> {
node: T;
key: string;
parentKey: string | null;
loading?: boolean;
}
export function useTreeReorder<T>(
container: RefObject<HTMLDivElement | null>,
rows: readonly ReorderRow<T>[],
reorder: TreeReorder<T> | 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<HTMLElement>("[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<HTMLElement>, row: ReorderRow<T>) => {
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 };
}
+3
View File
@@ -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<ServiceTreeItem>;
}
export const useServiceProviders = create<{ providers: Map<string, ServiceProviderContribution> }>(() => ({