From 6f9279e7538ae82c2f6bb2f671801fb2f910cf4f Mon Sep 17 00:00:00 2001 From: Qing Date: Mon, 9 Nov 2026 01:53:40 +0800 Subject: [PATCH] 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 <5848026+qtvbwfn@users.noreply.github.com> --- src/features/run/RunServices.module.css | 9 + src/features/run/configs.test.ts | 33 ++++ src/features/run/configs.ts | 54 +++++- src/features/run/index.ts | 3 + src/features/run/serviceImport.tsx | 96 ++++++++++ src/features/run/services.test.ts | 183 ++++++++++++++++++- src/features/run/services.tsx | 230 +++++++++++++++++++++--- src/features/run/tomcat.test.tsx | 6 +- src/features/run/tomcat.ts | 4 +- src/platform/locales/text/en.json | 12 ++ src/platform/locales/text/zh.json | 11 ++ 11 files changed, 608 insertions(+), 33 deletions(-) create mode 100644 src/features/run/serviceImport.tsx diff --git a/src/features/run/RunServices.module.css b/src/features/run/RunServices.module.css index 288bb3362..7bc776f29 100644 --- a/src/features/run/RunServices.module.css +++ b/src/features/run/RunServices.module.css @@ -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; +} diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index bcfdd0eaa..06fbf9c06 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -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(); + }); +}); diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index fa4c39a96..322af2d2c 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -43,11 +43,61 @@ const KIND_LABELS: Record = { }; 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 = { + "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): string { diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 3460be206..2e13d07d2 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -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({ diff --git a/src/features/run/serviceImport.tsx b/src/features/run/serviceImport.tsx new file mode 100644 index 000000000..a5175e724 --- /dev/null +++ b/src/features/run/serviceImport.tsx @@ -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) => ( + {e.node} + ))} + + ); +} + +/** The configurations of `kind` to show; `null` when the dialog was cancelled. */ +export function pickServices( + kind: string, + configs: readonly RunConfig[], + shown: ReadonlySet, +): Promise | 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: ( + { + close(); + resolve(picked); + }} + /> + ), + }, + ], + })); + }); +} + +function ImportDialog(props: { + kind: string; + configs: readonly RunConfig[]; + shown: ReadonlySet; + onDone: (picked: Set | null) => void; +}) { + const [picked, setPicked] = useState>(() => 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 ( + props.onDone(null)} + width={420} + buttons={[ + { label: "OK", role: "default", disabled: picked.size === 0, onClick: () => props.onDone(picked) }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+ {props.configs.map((c) => ( + toggle(c.id)} label={{c.name}} /> + ))} +
+
+ ); +} diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 86c14bf7d..cf827b356 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -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()), 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 = { + 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 => { + 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) => 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); + }); +}); diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index 26ec0716d..b35c3dd34 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -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 } } +// 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 }>(() => ({ + location: null, + hidden: {}, +})); + +/** Hidden configuration ids per kind, persisted per project. */ +export function readHidden(location: string | null): Record { + if (!location) return {}; + try { + const raw: unknown = JSON.parse(localStorage.getItem(HIDDEN_KEY + location) ?? "{}"); + const hidden: Record = {}; + 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 { + const s = useHiddenConfigs.getState(); + const location = projectLocation(useProject.getState().project?.root); + return s.location === location ? s.hidden : readHidden(location); +} + +function saveHidden(hidden: Record) { + 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 = {}, ): 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:`). 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(({ 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 }) {
{kindLabel(r.config.kind)}
+ {r.config.module && ( + <> +
+ Module +
+
{r.config.module}
+ + )}
Command
@@ -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(); + 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", diff --git a/src/features/run/tomcat.test.tsx b/src/features/run/tomcat.test.tsx index 13b27bef7..a2599e484 100644 --- a/src/features/run/tomcat.test.tsx +++ b/src/features/run/tomcat.test.tsx @@ -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", () => { diff --git a/src/features/run/tomcat.ts b/src/features/run/tomcat.ts index caeca49ef..64cba9d5a 100644 --- a/src/features/run/tomcat.ts +++ b/src/features/run/tomcat.ts @@ -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", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index f95a2c079..45cb292ca 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 835641778..93cf42838 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "参数",