feat(run): grouped Add Service menu and the configuration picker (#2091)

The Services Add Service menu now groups run configuration types by category, with the project's types first. Adding a type with several configurations opens a picker; hidden configurations persist per project and reappear while they run. Tomcat moves to the Servers group with its type as a single source.

Co-authored-by: qtvbwfn <[email protected]>
Цей коміт міститься в:
Qingтаqtvbwfn authored and GitHub committed 2026-11-09 01:53:40 +08:00
1 джерело 75607c0bbf
коміт 6f9279e753
11 files changed
+608 -33

No files matched your search

+9
Переглянути файл
@@ -35,3 +35,12 @@
height: 100%;
min-height: 0;
}
.importList {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
max-height: 320px;
overflow: auto;
}
+33
Переглянути файл
@@ -1,7 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { registerRunConfigType } from "@/workbench/contributions";
import {
categoryOf,
kindLabel,
commandLine,
duplicateConfig,
formatArgs,
@@ -17,6 +20,9 @@ import {
import { contextConfig, fileRole } from "./context";
import { cacheRunTemplates } from "./runTemplates";
import { registerBuiltinTypes } from "./types";
// After "./types": tomcat → buildUpdate → store → types is a cycle; importing tomcat first
// would freeze tomcatType at undefined inside BUILTIN_TYPES.
import { tomcatType } from "./tomcat";
import { envFromRows } from "./EnvDialog";
import { configRemoved, defaultSelection, rerunConfig, useRun, type RunSession } from "./store";
@@ -527,3 +533,30 @@ describe("rerunning a session after its configuration changed (#1497)", () => {
useRun.setState({ configs: [] });
});
});
describe("categoryOf", () => {
it("maps kinds to their language categories and unlisted ones to Others", () => {
expect(categoryOf("spring-boot")).toBe("Java");
expect(categoryOf("maven")).toBe("Build Tools");
expect(categoryOf("npm")).toBe("JavaScript");
expect(categoryOf("cargo")).toBe("Rust");
expect(categoryOf("godot")).toBe("Others");
});
it("prefers a registered type's group (Servers, Docker) over the category map", () => {
const disposeTomcat = registerRunConfigType(tomcatType);
expect(categoryOf("tomcat")).toBe("Servers");
expect(kindLabel("tomcat")).toBe("Tomcat Server");
disposeTomcat();
const dispose = registerRunConfigType({
id: "fake-server",
label: "Fake",
icon: "server",
group: "Servers",
fields: [],
commandLine: () => null,
});
expect(categoryOf("fake-server")).toBe("Servers");
dispose();
});
});
+52 -2
Переглянути файл
@@ -43,11 +43,61 @@ const KIND_LABELS: Record<string, string> = {
};
export const KIND_OPTIONS = Object.entries(KIND_LABELS).map(([value, label]) => ({ value, label }));
/**
* The menu category of a run configuration kind, for the Services "Add Service"
* menu (IDEA's template groups): a registered type's group (Docker, Servers),
* else its language/tool category, else "Others".
*/
export const KIND_CATEGORIES: Record<string, string> = {
"spring-boot": "Java",
application: "Java",
native: "Java",
"java-file": "Java",
kotlin: "Java",
kotlinc: "Java",
"scala-cli": "Java",
maven: "Build Tools",
gradle: "Build Tools",
cmake: "Build Tools",
make: "Build Tools",
just: "Build Tools",
npm: "JavaScript",
vue: "JavaScript",
node: "JavaScript",
bun: "JavaScript",
deno: "JavaScript",
python: "Python",
pytest: "Python",
cargo: "Rust",
rustc: "Rust",
go: "Go",
cc: "C/C++",
"cmake-application": "C/C++",
shell: "Shell",
program: "Shell",
};
/** Category order of the Add Service menu; unlisted categories (plugins) sort after, then "Others". */
export const CATEGORY_ORDER: readonly string[] = [
"Java",
"Build Tools",
"JavaScript",
"Python",
"Rust",
"Go",
"C/C++",
"Shell",
"Servers",
"Docker",
];
export function categoryOf(kind: string): string {
return useRunConfigTypes.getState().types.get(kind)?.group ?? KIND_CATEGORIES[kind] ?? "Others";
}
export function kindLabel(kind: string): string {
const type = useRunConfigTypes.getState().types.get(kind);
// A grouped type ("Tomcat Server › Local") is named by its group outside the Add menu.
return type?.group ?? type?.label ?? KIND_LABELS[kind] ?? (kind ? kind[0]!.toUpperCase() + kind.slice(1) : "Other");
return type?.label ?? KIND_LABELS[kind] ?? (kind ? kind[0]!.toUpperCase() + kind.slice(1) : "Other");
}
export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
+3
Переглянути файл
@@ -44,6 +44,7 @@ import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { cleanupRemovedProject, recordRun, registerRunServiceActions, runServiceProvider } from "./services";
import { liveServiceSession, serviceSessions } from "./serviceSessions";
import { ServiceImportHost } from "./serviceImport";
import { cmakeBuildId } from "./cmake";
import { runBeforeLaunch } from "./beforeLaunch";
import { compoundChildren } from "./configs";
@@ -341,6 +342,8 @@ export function activate(): () => void {
component: lazyWhen(useEditConfigsOpen, () => import("./EditConfigsDialog").then((m) => m.EditConfigsOverlay)),
}),
registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }),
registerOverlay({ id: "run.serviceImport", component: ServiceImportHost }),
registerOverlay({ id: "run.serviceImport", component: ServiceImportHost }),
registerServiceProvider(runServiceProvider),
registerRunServiceActions(),
registerRunningProcessesProvider({
+96
Переглянути файл
@@ -0,0 +1,96 @@
/**
* "Add Service" of a configuration type with several configurations: pick the
* ones to show in Services (removed ones are restored the same way).
* Rendered by one overlay, like the Services view's dialogs.
*/
import { Fragment, useState, type ReactNode } from "react";
import { create } from "zustand";
import type { RunConfig } from "@/platform/ipc";
import { Checkbox, Dialog } from "@/ui";
import { kindLabel } from "./configs";
import { tx } from "@/platform/i18n";
import styles from "./RunServices.module.css";
interface Entry {
id: number;
node: ReactNode;
}
const useDialogs = create<{ stack: Entry[] }>(() => ({ stack: [] }));
let seq = 0;
export function ServiceImportHost() {
const stack = useDialogs((s) => s.stack);
return (
<>
{stack.map((e) => (
<Fragment key={e.id}>{e.node}</Fragment>
))}
</>
);
}
/** The configurations of `kind` to show; `null` when the dialog was cancelled. */
export function pickServices(
kind: string,
configs: readonly RunConfig[],
shown: ReadonlySet<string>,
): Promise<Set<string> | null> {
return new Promise((resolve) => {
const id = ++seq;
const close = () => useDialogs.setState((s) => ({ stack: s.stack.filter((e) => e.id !== id) }));
useDialogs.setState((s) => ({
stack: [
...s.stack,
{
id,
node: (
<ImportDialog
kind={kind}
configs={configs}
shown={shown}
onDone={(picked) => {
close();
resolve(picked);
}}
/>
),
},
],
}));
});
}
function ImportDialog(props: {
kind: string;
configs: readonly RunConfig[];
shown: ReadonlySet<string>;
onDone: (picked: Set<string> | null) => void;
}) {
const [picked, setPicked] = useState<Set<string>>(() => new Set(props.shown));
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<Dialog
open
title={tx`Add Service (${kindLabel(props.kind)})`}
onClose={() => props.onDone(null)}
width={420}
buttons={[
{ label: "OK", role: "default", disabled: picked.size === 0, onClick: () => props.onDone(picked) },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.importList}>
{props.configs.map((c) => (
<Checkbox key={c.id} checked={picked.has(c.id)} onChange={() => toggle(c.id)} label={<span>{c.name}</span>} />
))}
</div>
</Dialog>
);
}
+182 -1
Переглянути файл
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MenuItem } from "@/ui";
import { pickServices } from "./serviceImport";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
@@ -12,17 +14,20 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
import {
addType,
cleanupRemovedProject,
categorizedTypes,
runServiceProvider,
registerRunServiceActions,
DEFAULT_TYPES,
readHidden,
readRan,
readTypes,
runServiceItems,
sessionDetail,
sessionStatus,
} from "./services";
import { KIND_OPTIONS, categoryOf } from "./configs";
import { startedPort } from "@/platform/serverPort";
import { useDebugRunSessions, type DebugServiceSession } from "@/workbench/contributions";
import { registerRunConfigType, useDebugRunSessions, type DebugServiceSession } from "@/workbench/contributions";
import { useRun, type RunSession } from "./store";
const { startRun, stopServiceSessions, openUrl, showContextMenu } = vi.hoisted(() => ({
@@ -37,6 +42,7 @@ vi.mock("./serviceSessions", async (importOriginal) => ({
stopServiceSessions,
}));
vi.mock("./serviceImport", () => ({ pickServices: vi.fn() }));
vi.mock("./navigate", () => ({ openUrl }));
vi.mock("@/ui", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/ui")>()), showContextMenu }));
@@ -334,6 +340,118 @@ describe("embedded web server port", () => {
});
});
describe("hidden configurations", () => {
const a = config("a", "spring-boot");
const b = config("b", "spring-boot");
it("hides a configuration unless it runs", () => {
const hidden = { "spring-boot": ["a"] };
const stopped = runServiceItems([a, b], [], DEFAULT_TYPES, [], {}, [], () => false, hidden);
expect(stopped[0]!.children!.map((c) => c.key)).toEqual(["run:config:b"]);
const running = runServiceItems(
[a, b],
[session("s1", a, "running", 1)],
DEFAULT_TYPES,
[],
{},
[],
() => false,
hidden,
);
expect(running[0]!.children!.map((c) => c.key)).toEqual(["run:config:a", "run:config:b"]);
});
it("keeps an empty default group whose configurations are all hidden", () => {
const items = runServiceItems([a], [], DEFAULT_TYPES, [], {}, [], () => false, { "spring-boot": ["a"] });
expect(items).toHaveLength(1);
expect(items[0]!.children).toEqual([]);
});
it("persists hidden ids per project", () => {
localStorage.clear();
expect(readHidden("/p")).toEqual({});
localStorage.setItem("openidea.services.runHidden:/p", JSON.stringify({ "spring-boot": ["a"] }));
expect(readHidden("/p")).toEqual({ "spring-boot": ["a"] });
expect(readHidden("/q")).toEqual({});
expect(readHidden(null)).toEqual({});
});
});
describe("add service categories", () => {
it("categorizes every built-in kind; update the table when adding kinds", () => {
const expected: Record<string, string> = {
npm: "JavaScript",
vue: "JavaScript",
node: "JavaScript",
bun: "JavaScript",
deno: "JavaScript",
cargo: "Rust",
rustc: "Rust",
gradle: "Build Tools",
maven: "Build Tools",
cmake: "Build Tools",
make: "Build Tools",
just: "Build Tools",
go: "Go",
python: "Python",
pytest: "Python",
phpunit: "Others",
composer: "Others",
cc: "C/C++",
"cmake-application": "C/C++",
shell: "Shell",
program: "Shell",
compound: "Others",
application: "Java",
"spring-boot": "Java",
native: "Java",
"java-file": "Java",
kotlin: "Java",
kotlinc: "Java",
"scala-cli": "Java",
ruby: "Others",
php: "Others",
perl: "Others",
lua: "Others",
r: "Others",
swift: "Others",
dart: "Others",
elixir: "Others",
};
for (const { value } of KIND_OPTIONS) expect(categoryOf(value), value).toBe(expected[value]);
});
it("orders buckets: fixed order first, unlisted plugin categories after, Others last", () => {
const dispose = registerRunConfigType({
id: "fake-cloud",
label: "Cloud Run",
icon: "cloud",
group: "Clouds",
fields: [],
commandLine: () => null,
});
const buckets = categorizedTypes(["ruby", "spring-boot", "npm", "fake-cloud", "gradle"]);
expect(buckets.map((b) => b.category)).toEqual(["Java", "Build Tools", "JavaScript", "Clouds", "Others"]);
dispose();
});
it("keeps a single-member category as its own bucket", () => {
expect(categorizedTypes(["go", "ruby"])).toEqual([
{ category: "Go", kinds: ["go"] },
{ category: "Others", kinds: ["ruby"] },
]);
});
it("sorts each bucket's kinds by their label", () => {
const buckets = categorizedTypes(["spring-boot", "application", "java-file", "native"]);
expect(buckets[0]).toEqual({ category: "Java", kinds: ["application", "java-file", "native", "spring-boot"] });
});
it("groups nothing for an empty list", () => {
expect(categorizedTypes([])).toEqual([]);
});
});
describe("open in browser", () => {
const item = () => ({ key: "run:config:a", label: "a", icon: "" });
const hasBrowser = () => !runServiceProvider.actions!(item()).find((a) => a.id === "open-browser")?.disabled;
@@ -559,3 +677,66 @@ describe("removing a workspace member from Services", () => {
expect(readTypes("/w/main")).toEqual(["npm"]);
});
});
describe("service selection", () => {
const group = { key: "run:type:npm", label: "npm", icon: "" };
const item = (id: string) => ({ key: `run:config:${id}`, label: id, icon: "" });
const leaf = (items: MenuItem[], label: string): Extract<MenuItem, { run?: unknown }> => {
for (const entry of items) {
if (entry.kind === "separator" || entry.kind === "header" || entry.kind === "action") continue;
if (entry.label === label) return entry;
if (entry.submenu) {
const found = leaf(entry.submenu, label);
if (found) return found;
}
}
return undefined!;
};
beforeEach(() => {
localStorage.clear();
useProject.setState({ project: { root: "/selection" } as never });
useRun.setState({ configs: [config("dev", "npm")], sessions: [] });
vi.mocked(pickServices).mockReset();
startRun.mockReset().mockResolvedValue(null);
});
it("restores a hidden single configuration through its group's Add Service", async () => {
leaf(runServiceProvider.menu!(item("dev")), "Remove from Services").run!();
expect(readHidden("/selection")).toEqual({ npm: ["dev"] });
await leaf(runServiceProvider.menu!(group), "Add Service").run!();
expect(readHidden("/selection")).toEqual({ npm: [] });
expect(pickServices).not.toHaveBeenCalled();
});
it("keeps Run All and Restart All scoped to visible configurations", async () => {
addType("npm");
useRun.setState({ configs: [config("dev", "npm"), config("test", "npm")] });
leaf(runServiceProvider.menu!(item("dev")), "Remove from Services").run!();
for (const id of ["run-all", "restart-all"]) {
startRun.mockClear();
await runServiceProvider.actions!(group)
.find((a) => a.id === id)!
.run();
expect(startRun.mock.calls.map(([c]) => c.id)).toEqual(["test"]);
}
});
it("does not apply a picker result after switching projects", async () => {
useRun.setState({ configs: [config("dev", "npm"), config("test", "npm")] });
let finish!: (value: Set<string>) => void;
vi.mocked(pickServices).mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const pending = leaf(runServiceProvider.menu!(group), "Add Service").run!();
useProject.setState({ project: { root: "/other-selection" } as never });
finish(new Set(["dev"]));
await pending;
await Promise.resolve();
expect(readHidden("/other-selection")).toEqual({});
expect(readTypes("/other-selection")).toEqual(DEFAULT_TYPES);
});
});
+206 -24
Переглянути файл
@@ -8,7 +8,7 @@
*/
import { useEffect, useMemo } from "react";
import { create } from "zustand";
import { executeAction, registerActions } from "@/platform/actions";
import { executeAction, registerActions, type Action } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { projectLocation, useRemote } from "@/platform/remote";
@@ -25,7 +25,8 @@ import {
type ServiceProviderContribution,
type ServiceTreeItem,
} from "@/workbench/contributions";
import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
import { pickServices } from "./serviceImport";
import { CATEGORY_ORDER, categoryOf, commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
import { openUrl } from "./navigate";
import { SessionPanel } from "./RunView";
import { showApplicationUpdate } from "./applicationUpdate";
@@ -209,6 +210,71 @@ export async function cleanupRemovedProject(removedRoot: string): Promise<void>
}
}
// Configurations hidden from Services, per project (removed one by one; "Add Service" restores)
const HIDDEN_KEY = "openidea.services.runHidden:";
const useHiddenConfigs = create<{ location: string | null; hidden: Record<string, string[]> }>(() => ({
location: null,
hidden: {},
}));
/** Hidden configuration ids per kind, persisted per project. */
export function readHidden(location: string | null): Record<string, string[]> {
if (!location) return {};
try {
const raw: unknown = JSON.parse(localStorage.getItem(HIDDEN_KEY + location) ?? "{}");
const hidden: Record<string, string[]> = {};
if (raw && typeof raw === "object")
for (const [kind, ids] of Object.entries(raw))
if (Array.isArray(ids)) hidden[kind] = ids.filter((id): id is string => typeof id === "string");
return hidden;
} catch {
return {};
}
}
function currentHidden(): Record<string, string[]> {
const s = useHiddenConfigs.getState();
const location = projectLocation(useProject.getState().project?.root);
return s.location === location ? s.hidden : readHidden(location);
}
function saveHidden(hidden: Record<string, string[]>) {
const location = projectLocation(useProject.getState().project?.root);
useHiddenConfigs.setState({ location, hidden });
if (!location) return;
try {
localStorage.setItem(HIDDEN_KEY + location, JSON.stringify(hidden));
} catch {
/* storage unavailable */
}
}
/** Removes one configuration from Services; it reappears while it runs (its console stays reachable). */
function hideConfig(kind: string, id: string) {
const hidden = currentHidden();
if ((hidden[kind] ?? []).includes(id)) return;
saveHidden({ ...hidden, [kind]: [...(hidden[kind] ?? []), id] });
}
/** "Add Service" on a type group: pick which of the kind's configurations show. */
async function pickForKind(kind: string) {
const location = projectLocation(useProject.getState().project?.root);
const configs = useRun.getState().configs.filter((c) => c.kind === kind);
if (configs.length <= 1) {
saveHidden({ ...currentHidden(), [kind]: [] });
addType(kind);
return;
}
const hidden = new Set(currentHidden()[kind] ?? []);
const picked = await pickServices(kind, configs, new Set(configs.filter((c) => !hidden.has(c.id)).map((c) => c.id)));
if (!picked || projectLocation(useProject.getState().project?.root) !== location) return;
addType(kind);
const remaining = configs.filter((c) => !picked.has(c.id)).map((c) => c.id);
saveHidden({ ...currentHidden(), [kind]: remaining });
}
// ---------------------------------------------------------------------------
// Tree
@@ -255,6 +321,7 @@ export function runServiceItems(
order: ServiceOrder = {},
debug: readonly DebugServiceSession[] = [],
removed: (id: string) => boolean = () => false,
hidden: Record<string, readonly string[]> = {},
): ServiceTreeItem[] {
const executions = serviceSessions(sessions, debug);
const latest = currentServiceSessions(executions);
@@ -271,8 +338,16 @@ export function runServiceItems(
: ran.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c && c.kind === kind);
for (const s of latest.values())
if (s.config.kind === kind && shown(s) && !entries.some((c) => c.id === s.config.id)) entries.push(s.config);
if (!entries.length && DEFAULT_TYPES.includes(kind)) return [];
const children = orderServices(entries, order[kind]).map((c): ServiceTreeItem => {
// Hidden configurations reappear only while they run (their console stays reachable).
const hiddenIds = new Set(hidden[kind] ?? []);
const visible = entries.filter((c) => {
if (!hiddenIds.has(c.id)) return true;
const s = latest.get(c.id);
return !!s && liveServiceSession(s);
});
// Keep an empty group only when its existing configurations can be restored.
if (!visible.length && DEFAULT_TYPES.includes(kind) && !configs.some((c) => c.kind === kind)) return [];
const children = orderServices(visible, order[kind]).map((c): ServiceTreeItem => {
const s = latest.get(c.id);
return {
key: CONFIG_PREFIX + c.id,
@@ -282,7 +357,7 @@ export function runServiceItems(
dim: !s || !liveServiceSession(s),
};
});
const running = entries.filter((c) => {
const running = visible.filter((c) => {
const s = latest.get(c.id);
return !!s && liveServiceSession(s);
}).length;
@@ -302,7 +377,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 location = projectLocation(root);
const order = useServiceOrder(location);
const configs = useRun((s) => s.configs);
const sessions = useRun((s) => s.sessions);
const debug = useDebugRunSessions((s) => s.serviceSessions);
@@ -310,10 +386,13 @@ function useRoots(): ServiceTreeItem[] {
const typesRoot = useAddedTypes((s) => s.root);
const ran = useRanConfigs((s) => s.ids);
const ranRoot = useRanConfigs((s) => s.root);
const hidden = useHiddenConfigs((s) => s.hidden);
const hiddenLocation = useHiddenConfigs((s) => s.location);
useEffect(() => {
if (typesRoot !== root) useAddedTypes.setState({ root, types: root ? readTypes(root) : [] });
if (ranRoot !== root) useRanConfigs.setState({ root, ids: root ? readRan(root) : [] });
}, [root, typesRoot, ranRoot]);
if (hiddenLocation !== location) useHiddenConfigs.setState({ location, hidden: readHidden(location) });
}, [root, typesRoot, ranRoot, hiddenLocation, location]);
return useMemo(
() =>
runServiceItems(
@@ -324,8 +403,9 @@ function useRoots(): ServiceTreeItem[] {
order,
debug,
configRemoved,
hiddenLocation === location ? hidden : {},
),
[configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug],
[configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug, hidden, hiddenLocation, location],
);
}
@@ -340,14 +420,26 @@ interface Resolved {
function resolve(item: ServiceTreeItem): Resolved {
const { configs, sessions } = useRun.getState();
const executions = serviceSessions(sessions, useDebugRunSessions.getState().serviceSessions);
// A configuration type's group node (`run:type:spring-boot`).
if (item.key.startsWith(TYPE_PREFIX)) {
const kind = item.key.slice(TYPE_PREFIX.length);
return { kind, config: null, session: undefined, sessions: executions.filter((s) => s.config.kind === kind) };
return {
kind,
config: null,
session: undefined,
sessions: executions.filter((s) => s.config.kind === kind),
};
}
// A configuration (`run:config:<id>`).
const id = item.key.slice(CONFIG_PREFIX.length);
const session = currentServiceSession(id, executions);
const config = configs.find((c) => c.id === id) ?? session?.config ?? null;
return { kind: config?.kind ?? null, config, session, sessions: session ? [session] : [] };
return {
kind: config?.kind ?? null,
config,
session,
sessions: session ? [session] : [],
};
}
function showOutput(s: RunSession) {
@@ -385,6 +477,7 @@ function groupConfigs(kind: string): RunConfig[] {
location ? currentServiceOrder(location) : {},
debug,
configRemoved,
currentHidden(),
).find((item) => item.key === TYPE_PREFIX + kind);
return (group?.children ?? []).flatMap((item) => {
const id = item.key.slice(CONFIG_PREFIX.length);
@@ -523,8 +616,8 @@ const SERVICE_ACTIONS = [
];
export function registerRunServiceActions(): () => void {
return registerActions(
SERVICE_ACTIONS.map(({ id, action, title, icon }) => ({
return registerActions([
...SERVICE_ACTIONS.map<Action>(({ id, action, title, icon }) => ({
id: action,
title,
icon,
@@ -536,7 +629,29 @@ export function registerRunServiceActions(): () => void {
if (action && !action.disabled) await action.run();
},
})),
);
{
id: "run.services.addType",
title: "Add Service",
icon: "plus",
category: "Run",
hidden: true,
run: async ({ arg }) => {
if (typeof arg === "string") await pickForKind(arg);
},
},
{
id: "run.services.hideConfig",
title: "Remove from Services",
icon: "close",
category: "Run",
hidden: true,
run: ({ arg }) => {
if (typeof arg !== "string") return;
const { config } = resolve({ key: CONFIG_PREFIX + arg, label: "", icon: "" });
if (config) hideConfig(config.kind, config.id);
},
},
]);
}
function actions(item: ServiceTreeItem): ServiceItemAction[] {
@@ -562,9 +677,21 @@ function menu(item: ServiceTreeItem): MenuItem[] {
run: a.run,
}));
const r = resolve(item);
if (!r.config && r.kind && currentTypes().includes(r.kind)) {
if (!r.config && r.kind) {
const kind = r.kind;
items.push({ kind: "separator" }, { label: "Remove from Services", icon: "close", run: () => removeType(kind) });
items.push(
{ kind: "separator" },
{
label: "Add Service",
icon: "plus",
disabled: !useRun.getState().configs.some((c) => c.kind === kind),
run: async () => {
await executeAction("run.services.addType", kind);
},
},
);
if (currentTypes().includes(kind))
items.push({ label: "Remove from Services", icon: "close", run: () => removeType(kind) });
}
if (r.config) {
const config = r.config;
@@ -585,6 +712,11 @@ function menu(item: ServiceTreeItem): MenuItem[] {
run: () => debuggerSession?.show(),
},
{ label: "Edit Configuration…", icon: "edit", run: () => void executeAction("run.editConfigs", config.id) },
{
label: "Remove from Services",
icon: "close",
run: () => void executeAction("run.services.hideConfig", config.id),
},
);
}
return items;
@@ -643,6 +775,14 @@ function RunDetail({ item }: { item: ServiceTreeItem }) {
<dd>
<T>{kindLabel(r.config.kind)}</T>
</dd>
{r.config.module && (
<>
<dt>
<T>Module</T>
</dt>
<dd>{r.config.module}</dd>
</>
)}
<dt>
<T>Command</T>
</dt>
@@ -666,24 +806,61 @@ function RunDetail({ item }: { item: ServiceTreeItem }) {
);
}
/** The leaf label of a kind: the registered type's own label, else its kind label. */
const labelOf = (kind: string) => useRunConfigTypes.getState().types.get(kind)?.label ?? kindLabel(kind);
/**
* The kinds grouped by menu category, in display order: `CATEGORY_ORDER` first,
* then unlisted (plugin) categories alphabetically, then "Others" last; each
* bucket's kinds sorted by leaf label. One submenu is rendered per bucket, even
* for a single member, so adding groups and members later keeps the structure.
*/
export function categorizedTypes(kinds: readonly string[]): Array<{ category: string; kinds: string[] }> {
const byCategory = new Map<string, string[]>();
for (const kind of kinds) {
const category = categoryOf(kind);
byCategory.set(category, [...(byCategory.get(category) ?? []), kind]);
}
const rank = (category: string) => {
const i = CATEGORY_ORDER.indexOf(category);
// Unlisted (plugin) categories sort after the fixed order, before "Others".
return i === -1 ? CATEGORY_ORDER.length : i;
};
const categories = [...byCategory.keys()].sort(
(a, b) => rank(a) - rank(b) || (a === "Others" ? 1 : b === "Others" ? -1 : 0) || a.localeCompare(b),
);
return categories.map((category) => ({
category,
kinds: [...byCategory.get(category)!].sort((a, b) => labelOf(a).localeCompare(labelOf(b))),
}));
}
/** "Add Service › Run Configuration Type": the project's types first, then the rest. */
function addMenu(): MenuItem[] {
const added = currentTypes();
const used = [...new Set(useRun.getState().configs.map((c) => c.kind))];
const known = [...KIND_OPTIONS.map((o) => o.value), ...useRunConfigTypes.getState().types.keys()];
const kinds = [...new Set([...used, ...known])].filter((k) => k !== "compound" && !added.includes(k));
/** A leaf item: the type's own label; grouped types live in their group's submenu. */
const item = (kind: string): MenuItem => ({
label: kindLabel(kind),
label: useRunConfigTypes.getState().types.get(kind)?.label ?? kindLabel(kind),
icon: configIcon({ kind, isTest: false }),
run: () => addType(kind),
run: async () => {
await executeAction("run.services.addType", kind);
},
});
const grouped = (list: readonly string[]) =>
categorizedTypes(list).map(({ category, kinds: members }) => ({
label: category,
icon: configIcon({ kind: members[0]!, isTest: false }),
submenu: members.map(item),
}));
const submenu: MenuItem[] = [
...kinds.filter((k) => used.includes(k)).map(item),
...(used.some((k) => kinds.includes(k)) ? [{ kind: "separator" } as MenuItem] : []),
...kinds
.filter((k) => !used.includes(k))
.sort((a, b) => kindLabel(a).localeCompare(kindLabel(b)))
.map(item),
...grouped(kinds.filter((k) => used.includes(k))),
...(used.some((k) => kinds.includes(k)) && kinds.some((k) => !used.includes(k))
? [{ kind: "separator" } as MenuItem]
: []),
...grouped(kinds.filter((k) => !used.includes(k))),
];
return [{ label: "Run Configuration Type", icon: "toolwindow-run", disabled: !submenu.length, submenu }];
}
@@ -722,13 +899,18 @@ export const runServiceProvider: ServiceProviderContribution = {
ranIds(useProject.getState().project?.root ?? null),
order,
useDebugRunSessions.getState().serviceSessions,
configRemoved,
currentHidden(),
).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) })
!saveServiceOrder(location, {
...order,
[kind]: moveServiceOrder(order[kind] ?? [], ids, from, to, position),
})
) {
notifications().notify({
group: "run",
+2 -4
Переглянути файл
@@ -51,7 +51,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => ({
afterEach(cleanup);
describe("Tomcat Local", () => {
it("offers a Local configuration grouped under Tomcat Server with build and browser defaults", () => {
it("offers a Tomcat Server configuration grouped under Servers with build and browser defaults", () => {
const config = newConfigOfType(tomcatType, []);
expect(config.params).toMatchObject({
httpPort: "8080",
@@ -60,9 +60,7 @@ describe("Tomcat Local", () => {
openBrowser: "true",
});
expect(config.debugAdapter).toBe("java-debug");
expect(typeRows([tomcatType], "")).toEqual(
expect.arrayContaining([expect.objectContaining({ label: "Tomcat Server" })]),
);
expect(typeRows([tomcatType], "")).toEqual(expect.arrayContaining([expect.objectContaining({ label: "Servers" })]));
expect(missingParams(config, tomcatType)).toHaveProperty("tomcatHome");
});
it("validates ports and context paths and supports manually chosen web roots", () => {
+2 -2
Переглянути файл
@@ -11,8 +11,8 @@ const validateOptionalPort = (value: string) => (!value.trim() ? null : validate
export const tomcatType: RunConfigTypeContribution = {
id: "tomcat",
group: "Tomcat Server",
label: "Local",
group: "Servers",
label: "Tomcat Server",
icon: "tomcat",
description: "Run a WAR application in a local Tomcat server",
debugAdapter: "java-debug",
+12
Переглянути файл
@@ -1,4 +1,16 @@
{
"Docker": "Docker",
"Shell": "Shell",
"C/C++": "C/C++",
"Go": "Go",
"Rust": "Rust",
"Python": "Python",
"JavaScript": "JavaScript",
"Build Tools": "Build Tools",
"Java": "Java",
"Add Service ({kind})": "Add Service ({kind})",
"Others": "Others",
"Servers": "Servers",
"Skills": "Skills",
"Keyword": "Keyword",
"Identifiers": "Identifiers",
+11
Переглянути файл
@@ -1,4 +1,15 @@
{
"Docker": "Docker",
"Shell": "Shell",
"C/C++": "C/C++",
"Go": "Go",
"Rust": "Rust",
"Python": "Python",
"JavaScript": "JavaScript",
"Java": "Java",
"Add Service ({kind})": "添加服务({kind})",
"Others": "其他",
"Servers": "服务器",
"Skills": "技能",
"Identifiers": "标识符",
"Parameter": "参数",