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 <[email protected]>
Цей коміт міститься в:
wangxgaтаwangxga authored and GitHub committed 2026-10-31 01:28:41 +08:00
1 джерело 0660f3c020
коміт a3cd99db1f
16 files changed
+985 -17

No files matched your search

+137
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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;
}