feat(services): List run configurations in the Services tool window (#160)

The Services tool window lists run configurations next to Docker. Spring Boot configurations appear by default; other types (Application, npm, Maven, Gradle, …) are added with + › Run Configuration Type and removed with Remove from Services, remembered per project. Nodes group by type and show running / stopped, with Run / Rerun, Debug, Stop, Show in Run Tool Window and Edit Configuration… in the toolbar and context menu; the detail pane shows the same console as the Run tool window. Running Spring Boot apps show the HTTP port read from the embedded server's "started on port" line.

Rerun now waits for the old process to exit before starting the new one, so a restarted server no longer fails with its port already in use, and a process stopped by the user no longer raises an exit-code notification.

Closes #150

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 01:06:32 +08:00
1 джерело 11362a36f8
коміт cfd2970531
11 files changed
+248 -65

No files matched your search

+1 -1
Переглянути файл
@@ -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);
+5
Переглянути файл
@@ -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");
});
});
+15
Переглянути файл
@@ -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;
}
+47 -2
Переглянути файл
@@ -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();
});
});
+83 -55
Переглянути файл
@@ -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 }) {
</div>
</EmptyState>
);
const s = r.session;
const live = !!s && busy(s);
// The configuration ran: its console, as in the Run tool window.
if (r.session) return <SessionPanel session={r.session} />;
return (
<div className={styles.detail}>
<dl className={styles.facts}>
@@ -257,25 +293,16 @@ function RunDetail({ item }: { item: ServiceTreeItem }) {
<dt>
<T>Status</T>
</dt>
<dd>{txTemplate(sessionStatus(s) ?? "Not started")}</dd>
{s && (
<>
<dt>
<T>Started</T>
</dt>
<dd>{new Date(s.startedAt).toLocaleTimeString()}</dd>
</>
)}
<dd>
<T>Not started</T>
</dd>
</dl>
<div className={styles.actions}>
<Button icon={live ? "rerun" : "run"} onClick={() => run(r)}>
{live ? "Rerun" : "Run"}
<Button icon="run" disabled={isDebugOnly(r.config)} onClick={() => run(r)}>
Run
</Button>
<Button icon="stop" disabled={!live} onClick={() => s && void stopSession(s.id)}>
Stop
</Button>
<Button icon="toolwindow-run" disabled={!s} onClick={() => s && showOutput(s)}>
Show Output
<Button icon="debug" onClick={() => debug(r)}>
Debug
</Button>
</div>
</div>
@@ -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,
+28 -1
Переглянути файл
@@ -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");
+39 -2
Переглянути файл
@@ -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<void> {
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<void> {
}
}
/** Resolves once the given sessions' processes exited (or after `timeoutMs`). */
function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): Promise<void> {
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);
+10 -1
Переглянути файл
@@ -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" });
+17 -1
Переглянути файл
@@ -22,8 +22,24 @@ export const useServicesTree = create<TreeState>(() => ({
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<string>();
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) {
+1 -2
Переглянути файл
@@ -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": "脚本文件",
+2
Переглянути файл
@@ -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[];
}