дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
75607c0bbf
коміт
6f9279e753
11 files changed
+608
-33
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "参数",
|
||||
|
||||
Reference in new issue
Заблокувати користувача