diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index f45f471aa..acc964e22 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -42,7 +42,7 @@ function sessionIcon(s: RunSession): string { return s.config.isTest ? "check" : "run"; } -function SessionPanel({ session }: { session: RunSession }) { +export function SessionPanel({ session }: { session: RunSession }) { const version = useRun((s) => s.versions[session.id] ?? 0); const rt = runtime(session.id); const [follow, setFollow] = useState(true); diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 6258c3f86..81026a41b 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -82,6 +82,11 @@ describe("config merge / override", () => { cfg({ id: "cargo:run:app", name: "cargo run app" }), ]; expect(defaultSelection(list)?.id).toBe("cargo:run:app"); + const spring = [ + cfg({ id: "maven:spring-boot:run", kind: "maven" }), + cfg({ id: "application:demo.App", kind: "spring-boot" }), + ]; + expect(defaultSelection(spring)?.id).toBe("application:demo.App"); }); }); diff --git a/src/features/run/serverPort.ts b/src/features/run/serverPort.ts new file mode 100644 index 000000000..8ed67ebab --- /dev/null +++ b/src/features/run/serverPort.ts @@ -0,0 +1,15 @@ +/** + * The HTTP port of a running Spring Boot application, from the embedded web + * server's startup line: "Tomcat started on port 8080 (http) with context path '/'", + * "Netty started on port 8080", "Undertow started on port(s) 8080 (http)", + * "Jetty started on port(s) 8080 (http/1.1)". + */ +const STARTED = /\b(?:Tomcat|Netty|Undertow|Jetty)\b.*?\bstarted on port(?:\(s\))?:?\s*(\d{1,5})/i; + +export function startedPort(line: string): number | null { + if (!line.includes("tarted on port")) return null; + const m = STARTED.exec(line); + if (!m) return null; + const port = Number(m[1]); + return port > 0 && port < 65536 ? port : null; +} diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 3390e5735..b17c04c67 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { RunConfig } from "@/platform/ipc"; -import { runServiceItems, sessionStatus } from "./services"; +import { DEFAULT_TYPES, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services"; +import { startedPort } from "./serverPort"; import type { RunSession } from "./store"; const config = (id: string, kind: string): RunConfig => ({ @@ -73,4 +74,48 @@ describe("run configurations in Services", () => { expect(sessionStatus(session("a", build, "failed", 0, { stopped: true }))).toBe("Stopped"); expect(sessionStatus(session("a", build, "finished", 0))).toBe("Finished"); }); + + it("shows Spring Boot by default, hidden while the project has none", () => { + const app = config("spring:app", "spring-boot"); + expect(runServiceItems([build], [], DEFAULT_TYPES)).toEqual([]); + const items = runServiceItems([build, app], [], DEFAULT_TYPES); + expect(items.map((i) => [i.key, i.label, i.expanded])).toEqual([["run:type:spring-boot", "Spring Boot", true]]); + expect(items[0]!.children!.map((c) => [c.label, c.detail, c.dim])).toEqual([["spring:app", undefined, true]]); + }); + + it("shows the HTTP port of a running application instead of its state", () => { + const app = config("spring:app", "spring-boot"); + const running = session("s1", app, "running", 1, { httpPort: 8080 }); + expect(sessionDetail(running)).toBe(":8080"); + expect(sessionDetail(session("s1", app, "running", 1))).toBe("Running"); + expect(sessionDetail(session("s1", app, "finished", 1, { httpPort: 8080, stopped: true }))).toBe("Stopped"); + expect(runServiceItems([app], [running], DEFAULT_TYPES)[0]!.children![0]!.detail).toBe(":8080"); + }); +}); + +describe("types added to Services", () => { + beforeEach(() => localStorage.clear()); + + it("defaults to Spring Boot and persists the user's list per project", () => { + expect(readTypes("/p")).toEqual(["spring-boot"]); + localStorage.setItem("openidea.services.runTypes:/p", JSON.stringify(["npm"])); + expect(readTypes("/p")).toEqual(["npm"]); + localStorage.setItem("openidea.services.runTypes:/p", "[]"); + expect(readTypes("/p")).toEqual([]); + expect(readTypes("/q")).toEqual(["spring-boot"]); + }); +}); + +describe("embedded web server port", () => { + it("reads the port from the started line", () => { + expect(startedPort("TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'")).toBe(8080); + expect(startedPort("Tomcat started on port(s): 8081 (http) with context path ''")).toBe(8081); + expect(startedPort("o.s.b.web.embedded.netty.NettyWebServer : Netty started on port 9090")).toBe(9090); + expect(startedPort("Netty started on port(s): 9091")).toBe(9091); + expect(startedPort("Undertow started on port(s) 8082 (http)")).toBe(8082); + expect(startedPort("Jetty started on port 8083 (http/1.1) with context path '/'")).toBe(8083); + expect(startedPort("Tomcat initialized with port 8080 (http)")).toBeNull(); + expect(startedPort("Started DemoApplication in 1.2 seconds")).toBeNull(); + expect(startedPort("Tomcat started on port 99999")).toBeNull(); + }); }); diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index 348b0a16e..b0a4c5b21 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -1,8 +1,9 @@ /** - * Run configurations in the Services tool window (IDEA's Run Dashboard): the - * configuration types added there ("Add Service › Run Configuration Type") and - * the types of this window's run sessions, one group each, with run / stop / - * rerun / show output per configuration. + * Run configurations in the Services tool window (IDEA's Run Dashboard): Spring + * Boot by default, the configuration types added there ("Add Service › Run + * Configuration Type") and the types of this window's run sessions, one group + * each, with run / debug / stop / rerun per configuration. A configuration that + * ran shows its console in the detail pane, a running web application its HTTP port. */ import { useEffect, useMemo } from "react"; import { create } from "zustand"; @@ -14,30 +15,38 @@ import { Button, EmptyState, T } from "@/ui"; import type { MenuItem } from "@/ui"; import { toolWindowService, + useRunConfigTypes, type ServiceItemAction, type ServiceProviderContribution, type ServiceTreeItem, } from "@/workbench/contributions"; import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs"; -import { rerunSession, stopSession, useRun, type RunSession } from "./store"; +import { SessionPanel } from "./RunView"; +import { startRun, stopSession, useRun, type RunSession } from "./store"; +import { isDebugOnly } from "./types"; import styles from "./RunServices.module.css"; export const RUN_PROVIDER = "runConfigurations"; const TYPE_PREFIX = "run:type:"; const CONFIG_PREFIX = "run:config:"; const STORAGE_KEY = "openidea.services.runTypes:"; +/** Types shown in Services until the user changes the list. */ +export const DEFAULT_TYPES: readonly string[] = ["spring-boot"]; // --------------------------------------------------------------------------- // Types added to Services, per project const useAddedTypes = create<{ root: string | null; types: string[] }>(() => ({ root: null, types: [] })); -function readTypes(root: string): string[] { +/** The project's types; Spring Boot until the user changes the list. */ +export function readTypes(root: string): string[] { try { - const v: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY + root) ?? "[]"); + const raw = localStorage.getItem(STORAGE_KEY + root); + if (raw === null) return [...DEFAULT_TYPES]; + const v: unknown = JSON.parse(raw); return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; } catch { - return []; + return [...DEFAULT_TYPES]; } } @@ -57,13 +66,15 @@ function setAddedTypes(types: string[]) { } } +const currentTypes = () => addedTypes(useProject.getState().project?.root ?? null); + export function addType(kind: string) { - const types = addedTypes(useProject.getState().project?.root ?? null); + const types = currentTypes(); if (!types.includes(kind)) setAddedTypes([...types, kind]); } export function removeType(kind: string) { - setAddedTypes(addedTypes(useProject.getState().project?.root ?? null).filter((t) => t !== kind)); + setAddedTypes(currentTypes().filter((t) => t !== kind)); } // --------------------------------------------------------------------------- @@ -89,7 +100,15 @@ export function sessionStatus(s: RunSession | undefined): string | undefined { return "Finished"; } -/** Groups by configuration type: added types list all their configurations, other types only those that ran. */ +/** A node's grey text: the HTTP port of a running web application, else the state. */ +export function sessionDetail(s: RunSession | undefined): string | undefined { + return s && busy(s) && s.httpPort ? `:${s.httpPort}` : sessionStatus(s); +} + +/** + * Groups by configuration type: added types list all their configurations, other + * types only those that ran. A default type (Spring Boot) without configurations is hidden. + */ export function runServiceItems( configs: readonly RunConfig[], sessions: readonly RunSession[], @@ -98,17 +117,18 @@ export function runServiceItems( const latest = latestSessions(sessions); const kinds = [...added]; for (const s of sessions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind); - return kinds.map((kind) => { + return kinds.flatMap((kind) => { const entries: RunConfig[] = added.includes(kind) ? configs.filter((c) => c.kind === kind) : []; for (const s of latest.values()) if (s.config.kind === kind && !entries.some((c) => c.id === s.config.id)) entries.push(s.config); + if (!entries.length && DEFAULT_TYPES.includes(kind)) return []; const children = entries.map((c): ServiceTreeItem => { const s = latest.get(c.id); return { key: CONFIG_PREFIX + c.id, label: c.name, icon: s && busy(s) ? "run" : configIcon(c), - detail: sessionStatus(s), + detail: sessionDetail(s), dim: !s || !busy(s), }; }); @@ -116,13 +136,16 @@ export function runServiceItems( const s = latest.get(c.id); return !!s && busy(s); }).length; - return { - key: TYPE_PREFIX + kind, - label: kindLabel(kind), - icon: configIcon({ kind, isTest: false }), - detail: running ? tx`${running} running` : undefined, - children, - }; + return [ + { + key: TYPE_PREFIX + kind, + label: kindLabel(kind), + icon: configIcon({ kind, isTest: false }), + detail: running ? tx`${running} running` : undefined, + expanded: true, + children, + }, + ]; }); } @@ -167,9 +190,15 @@ function showOutput(s: RunSession) { toolWindowService().show("run", true); } +/** Runs (or reruns, in its tab) a configuration; the output stays in Services instead of opening Run. */ function run(r: Resolved) { - if (r.session && r.session.config.id === r.config?.id) rerunSession(r.session.id); - else if (r.config) void executeAction("run.runConfig", r.config); + if (r.session && r.session.config.id === r.config?.id) + void startRun(r.session.config, r.session.id, r.session.baseConfig, { show: false }); + else if (r.config) void startRun(r.config, undefined, undefined, { show: false }); +} + +function debug(r: Resolved) { + if (r.config) void executeAction("run.debugConfig", r.config); } function actions(item: ServiceTreeItem): ServiceItemAction[] { @@ -185,15 +214,15 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] { if (!r.config) return [stop]; const live = !!r.session && busy(r.session); return [ - { id: "run", title: live ? "Rerun" : "Run", icon: live ? "rerun" : "run", run: () => run(r) }, - stop, { - id: "output", - title: "Show Output", - icon: "toolwindow-run", - disabled: !r.session, - run: () => r.session && showOutput(r.session), + id: "run", + title: live ? "Rerun" : "Run", + icon: live ? "rerun" : "run", + disabled: isDebugOnly(r.config), + run: () => run(r), }, + { id: "debug", title: "Debug", icon: "debug", run: () => debug(r) }, + stop, ]; } @@ -205,14 +234,21 @@ function menu(item: ServiceTreeItem): MenuItem[] { run: a.run, })); const r = resolve(item); - if (!r.config && r.kind && addedTypes(useProject.getState().project?.root ?? null).includes(r.kind)) { + if (!r.config && r.kind && currentTypes().includes(r.kind)) { const kind = r.kind; items.push({ kind: "separator" }, { label: "Remove from Services", icon: "close", run: () => removeType(kind) }); } if (r.config) { const config = r.config; + const session = r.session; items.push( { kind: "separator" }, + { + label: "Show in Run Tool Window", + icon: "toolwindow-run", + disabled: !session, + run: () => session && showOutput(session), + }, { label: "Edit Configuration…", icon: "edit", run: () => void executeAction("run.editConfigs", config.id) }, ); } @@ -235,8 +271,8 @@ function RunDetail({ item }: { item: ServiceTreeItem }) { ); - const s = r.session; - const live = !!s && busy(s); + // The configuration ran: its console, as in the Run tool window. + if (r.session) return ; return (
@@ -257,25 +293,16 @@ function RunDetail({ item }: { item: ServiceTreeItem }) {
Status
-
{txTemplate(sessionStatus(s) ?? "Not started")}
- {s && ( - <> -
- Started -
-
{new Date(s.startedAt).toLocaleTimeString()}
- - )} +
+ Not started +
- - -
@@ -284,11 +311,10 @@ function RunDetail({ item }: { item: ServiceTreeItem }) { /** "Add Service › Run Configuration Type": the project's types first, then the rest. */ function addMenu(): MenuItem[] { - const added = addedTypes(useProject.getState().project?.root ?? null); + const added = currentTypes(); const used = [...new Set(useRun.getState().configs.map((c) => c.kind))]; - const kinds = [...used, ...KIND_OPTIONS.map((o) => o.value).filter((k) => !used.includes(k))].filter( - (k) => !added.includes(k), - ); + 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)); const item = (kind: string): MenuItem => ({ label: kindLabel(kind), icon: configIcon({ kind, isTest: false }), @@ -297,7 +323,10 @@ function addMenu(): MenuItem[] { 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)).map(item), + ...kinds + .filter((k) => !used.includes(k)) + .sort((a, b) => kindLabel(a).localeCompare(kindLabel(b))) + .map(item), ]; return [{ label: "Run Configuration Type", icon: "toolwindow-run", disabled: !submenu.length, submenu }]; } @@ -310,8 +339,7 @@ export const runServiceProvider: ServiceProviderContribution = { menu, activate: (item) => { const r = resolve(item); - if (r.session) showOutput(r.session); - else if (r.config) run(r); + if (r.config && !(r.session && busy(r.session)) && !isDebugOnly(r.config)) run(r); }, detail: RunDetail, addMenu, diff --git a/src/features/run/session.test.tsx b/src/features/run/session.test.tsx index c3a57b27d..f278da6d0 100644 --- a/src/features/run/session.test.tsx +++ b/src/features/run/session.test.tsx @@ -13,7 +13,7 @@ import { ConsoleModel } from "./console"; import { ConsoleView, type ConsoleInput } from "./ConsoleView"; import { buildRows, mnemonics } from "./ConfigChooser"; import { activate } from "./index"; -import { closeSessionTab, resetRuns, startRun, useRun, type RunSession } from "./store"; +import { closeSessionTab, resetRuns, runtime, startRun, useRun, type RunSession } from "./store"; const confirm = vi.hoisted(() => vi.fn(async () => true)); const confirmTerminate = vi.hoisted(() => vi.fn(async () => true)); @@ -91,6 +91,33 @@ describe("single-instance runs", () => { }); }); +describe("restart", () => { + it("starts the new process only after the old one exited", async () => { + const app = config("app"); + useRun.setState({ sessions: [running("run1", app, 7)] }); + let restarted = false; + const rerun = startRun(app, "run1").then(() => (restarted = true)); + await new Promise((r) => setTimeout(r, 20)); + expect(restarted).toBe(false); + expect(useRun.getState().sessions[0]!.stopped).toBe(true); + // The exit event of the old process. + useRun.setState((s) => ({ sessions: s.sessions.map((x) => ({ ...x, status: "finished" as const })) })); + await rerun; + expect(useRun.getState().sessions.map((s) => [s.id, s.runId])).toEqual([["run1", null]]); + }); +}); + +describe("HTTP port", () => { + it("is taken from the web server's started line and cleared on rerun", async () => { + const app = config("app", { kind: "spring-boot" }); + const session = (await startRun(app))!; + runtime(session.id)!.console.append({ kind: "stdout", text: "Tomcat started on port 8085 (http)\n" }); + expect(useRun.getState().sessions[0]!.httpPort).toBe(8085); + await startRun(app, session.id); + expect(useRun.getState().sessions[0]!.httpPort).toBeUndefined(); + }); +}); + describe("closing a Run tab", () => { it("asks before terminating a running process and keeps the tab when cancelled", async () => { const app = config("app"); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 70990a87d..44119de27 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -37,6 +37,7 @@ import { compoundChildren } from "./configs"; import { remoteLabel, startWithRemedies } from "./remote"; import { TerminalOutput } from "./terminalOutput"; import { typeFor } from "./types"; +import { startedPort } from "./serverPort"; import { tx } from "@/platform/i18n"; /** The permanent "Current File" pseudo-configuration of the chooser (runs the active file's context). */ @@ -59,6 +60,8 @@ export interface RunSession { baseConfig?: RunConfig; /** "Emulate terminal in output console": the output is a terminal. */ terminal?: boolean; + /** The HTTP port the application's embedded web server reported it started on. */ + httpPort?: number; } interface SessionRuntime { @@ -181,6 +184,7 @@ export async function loadConfigs(): Promise { export function defaultSelection(configs: readonly RunConfig[]): RunConfig | undefined { const score = (c: RunConfig) => { if (c.isTest) return 1; + if (c.kind === "spring-boot") return 4; const s = `${c.id} ${c.name}`.toLowerCase(); if (/bootrun|spring-boot:run|:run\b|run:|\bdev\b|\bstart\b|\bserve\b|runserver/.test(s)) return 3; if (/build|package|lint|check|format|clean/.test(s)) return 0; @@ -475,7 +479,13 @@ export async function startRun( await Promise.all(others.map((s) => stopSession(s.id))); session ??= others[0]; } - if (session && busy(session)) await stopSession(session.id); + const stopping = others.map((s) => s.id); + if (session && busy(session)) { + await stopSession(session.id); + stopping.push(session.id); + } + // The new process starts once the old one is gone (it may still hold a port or a lock). + await waitForExit(stopping); session ??= state.sessions.find((s) => s.config.id === config.id && !busy(s)); try { @@ -502,6 +512,7 @@ export async function startRun( endedAt: null, stdinClosed: false, stopped: false, + httpPort: undefined, }; const updated = session; useRun.setState((s) => ({ @@ -529,6 +540,11 @@ export async function startRun( const rt = runtimes.get(sessionId)!; const generation = rt.generation; if (options.onLine) rt.lineListeners.push(options.onLine); + rt.lineListeners.push((line) => { + const port = startedPort(line); + if (port !== null && runtimes.get(sessionId)?.generation === generation && getSession(sessionId)?.httpPort !== port) + patchSession(sessionId, { httpPort: port }); + }); rt.onExit = options.onExit; try { if (config.focusToolWindow) toolWindowService().show("run", true); @@ -731,7 +747,7 @@ export function testReportEvents(reports: readonly TestCaseReport[]): TestEvent[ function notifyExit(sessionId: string, code: number | null) { const session = getSession(sessionId); - if (!session || code === 0 || code === null) return; + if (!session || session.stopped || code === 0 || code === null) return; let visible = true; try { visible = toolWindowService().isVisible("run"); @@ -786,6 +802,27 @@ export async function stopSession(sessionId: string): Promise { } } +/** Resolves once the given sessions' processes exited (or after `timeoutMs`). */ +function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): Promise { + const live = () => + sessionIds.some((id) => { + const s = getSession(id); + return !!s && s.runId !== null && s.status === "running"; + }); + if (!live()) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + unsubscribe(); + resolve(); + }; + const timer = setTimeout(done, timeoutMs); + const unsubscribe = useRun.subscribe(() => { + if (!live()) done(); + }); + }); +} + export function rerunSession(sessionId: string) { const session = getSession(sessionId); if (session) void startRun(session.config, sessionId, session.baseConfig); diff --git a/src/features/services/services.test.ts b/src/features/services/services.test.ts index 8e12341cc..bed466a98 100644 --- a/src/features/services/services.test.ts +++ b/src/features/services/services.test.ts @@ -22,7 +22,7 @@ import { } from "./model"; import { endpointFrom } from "./dialogs"; import { dockerItems } from "./docker"; -import { addServiceMenu, expandableKeys, indexTree } from "./tree"; +import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree"; const container = (name: string, state: string, project?: string, service?: string): DockerContainer => ({ id: `${name}-id`, @@ -130,6 +130,15 @@ describe("services tree host", () => { ]); }); + it("expands nodes asking for it once, when they first appear", () => { + const group = () => ({ key: "run:type:npm", label: "npm", icon: "x", expanded: true, children: [] }); + setProviderRoots("run", [group()]); + expect(useServicesTree.getState().expanded.has("run:type:npm")).toBe(true); + useServicesTree.setState({ expanded: new Set() }); + setProviderRoots("run", [group()]); + expect(useServicesTree.getState().expanded.has("run:type:npm")).toBe(false); + }); + it("builds connection endpoints from the dialog fields", () => { const f = { path: " /var/run/docker.sock ", host: "", tls: "", context: "" }; expect(endpointFrom("socket", f)).toEqual({ kind: "socket", path: "/var/run/docker.sock" }); diff --git a/src/features/services/tree.ts b/src/features/services/tree.ts index 9d3d947b4..1bd11a455 100644 --- a/src/features/services/tree.ts +++ b/src/features/services/tree.ts @@ -22,8 +22,24 @@ export const useServicesTree = create(() => ({ expanded: new Set(["docker:default", "docker:default:group:containers"]), })); +/** Keys of nodes asking to start expanded that were shown once (collapsing them then sticks). */ +const seenExpanded = new Set(); + export function setProviderRoots(provider: string, items: ServiceTreeItem[]) { - useServicesTree.setState((s) => (s.roots[provider] === items ? s : { roots: { ...s.roots, [provider]: items } })); + useServicesTree.setState((s) => { + if (s.roots[provider] === items) return s; + const fresh: string[] = []; + const visit = (i: ServiceTreeItem) => { + if (i.expanded && !seenExpanded.has(i.key)) { + seenExpanded.add(i.key); + fresh.push(i.key); + } + i.children?.forEach(visit); + }; + items.forEach(visit); + const roots = { ...s.roots, [provider]: items }; + return fresh.length ? { roots, expanded: new Set([...s.expanded, ...fresh]) } : { roots }; + }); } export function clearProviderRoots(provider: string) { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d55c33d92..0dda47954 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2524,7 +2524,6 @@ "Run Configuration Type": "运行配置类型", "Docker Registry…": "Docker 注册表…", "Docker Connection…": "Docker 连接…", - "Show Output": "显示输出", "Address:": "地址:", "Username:": "用户名:", "Scope Based": "基于范围", @@ -2782,10 +2781,10 @@ "Stopped": "已停止", "Finished": "已完成", "Remove from Services": "从服务中移除", + "Show in Run Tool Window": "在运行工具窗口中显示", "Configuration": "配置", "Type": "类型", "Not started": "未启动", - "Started": "已启动", "Shell Script": "Shell 脚本", "Run a script file, script text or command line": "运行脚本文件、脚本文本或命令行", "Script file": "脚本文件", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 7053b1775..ad1a53581 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -146,6 +146,8 @@ export interface ServiceTreeItem { detail?: string; /** Greyed label (stopped, not running). */ dim?: boolean; + /** Expanded the first time the node appears. */ + expanded?: boolean; /** Undefined for leaves. */ children?: ServiceTreeItem[]; }