дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
1 джерело
0660f3c020
коміт
a3cd99db1f
16 files changed
+985
-17
No files matched your search
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача