дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +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,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
Заблокувати користувача