Файли
openidea/src/features/run/services.tsx
T
wangxgaтаwangxga a3cd99db1f feat(services): reorder services within a run configuration type (#345)
Services followed configuration detection order, so frequently used services could not be kept together. Services can now be dragged before or after a sibling, with an insertion indicator and edge scrolling, or moved with Move Up/Down from the toolbar and context menu.

The order is stored per project location and type, separately from configuration definitions. New configurations append, windows stay in sync, and a failed save keeps the order for the session with a warning. Sorting in Tree is opt-in, so other trees are unchanged.

Co-authored-by: wangxga <[email protected]>
2026-10-31 01:28:41 +08:00

446 рядки
15 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* 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"), the types of this window's run sessions and the
* configurations that ran before (kept per project across restarts), 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";
import { executeAction } from "@/platform/actions";
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { projectLocation, useRemote } from "@/platform/remote";
import { tx, useT, useTxTemplate } from "@/platform/i18n";
import { Button, EmptyState, T } from "@/ui";
import type { MenuItem } from "@/ui";
import {
toolWindowService,
notifications,
useRunConfigTypes,
type ServiceItemAction,
type ServiceProviderContribution,
type ServiceTreeItem,
} from "@/workbench/contributions";
import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
import { SessionPanel } from "./RunView";
import { startRun, stopSession, useRun, type RunSession } from "./store";
import { isDebugOnly } from "./types";
import {
currentServiceOrder,
moveServiceOrder,
orderServices,
saveServiceOrder,
useServiceOrder,
type ServiceOrder,
} from "./serviceOrder";
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:";
const RAN_KEY = "openidea.services.ran:";
/** 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: [] }));
/** The project's types; Spring Boot until the user changes the list. */
export function readTypes(root: string): string[] {
try {
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 [...DEFAULT_TYPES];
}
}
function addedTypes(root: string | null): string[] {
const s = useAddedTypes.getState();
return s.root === root ? s.types : root ? readTypes(root) : [];
}
function setAddedTypes(types: string[]) {
const root = useProject.getState().project?.root ?? null;
useAddedTypes.setState({ root, types });
if (!root) return;
try {
localStorage.setItem(STORAGE_KEY + root, JSON.stringify(types));
} catch {
/* storage unavailable */
}
}
const currentTypes = () => addedTypes(useProject.getState().project?.root ?? null);
export function addType(kind: string) {
const types = currentTypes();
if (!types.includes(kind)) setAddedTypes([...types, kind]);
}
export function removeType(kind: string) {
setAddedTypes(currentTypes().filter((t) => t !== kind));
}
// ---------------------------------------------------------------------------
// Configurations that ran, per project (shown again after a restart)
const useRanConfigs = create<{ root: string | null; ids: string[] }>(() => ({ root: null, ids: [] }));
/** The project's previously run configuration ids, most recent last. */
export function readRan(root: string): string[] {
try {
const raw = localStorage.getItem(RAN_KEY + root);
if (raw === null) return [];
const v: unknown = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
function ranIds(root: string | null): string[] {
const s = useRanConfigs.getState();
return s.root === root ? s.ids : root ? readRan(root) : [];
}
/** Records a run, so its configuration is back in Services after a restart. */
export function recordRun(configId: string) {
const root = useProject.getState().project?.root ?? null;
const ids = [...ranIds(root).filter((id) => id !== configId), configId];
useRanConfigs.setState({ root, ids });
if (!root) return;
try {
localStorage.setItem(RAN_KEY + root, JSON.stringify(ids));
} catch {
/* storage unavailable */
}
}
// ---------------------------------------------------------------------------
// Tree
const busy = (s: RunSession) => s.status === "running" || s.status === "starting";
/** The newest session of each configuration id. */
function latestSessions(sessions: readonly RunSession[]): Map<string, RunSession> {
const out = new Map<string, RunSession>();
for (const s of sessions) {
const prev = out.get(s.config.id);
if (!prev || s.startedAt >= prev.startedAt) out.set(s.config.id, s);
}
return out;
}
export function sessionStatus(s: RunSession | undefined): string | undefined {
if (!s) return undefined;
if (busy(s)) return s.status === "starting" ? "Starting" : "Running";
if (s.stopped) return "Stopped";
if (s.status === "failed") return s.exitCode === null ? "Failed" : `Failed (exit ${s.exitCode})`;
return "Finished";
}
/** 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[],
added: readonly string[],
ran: readonly string[] = [],
order: ServiceOrder = {},
): ServiceTreeItem[] {
const latest = latestSessions(sessions);
const kinds = [...added];
for (const s of sessions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
for (const id of ran) {
const kind = configs.find((c) => c.id === id)?.kind;
if (kind && !kinds.includes(kind)) kinds.push(kind);
}
return kinds.flatMap((kind) => {
const entries: RunConfig[] = added.includes(kind)
? configs.filter((c) => c.kind === kind)
: 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 && !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 => {
const s = latest.get(c.id);
return {
key: CONFIG_PREFIX + c.id,
label: c.name,
icon: s && busy(s) ? "run" : configIcon(c),
detail: sessionDetail(s),
dim: !s || !busy(s),
};
});
const running = entries.filter((c) => {
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,
expanded: true,
children,
},
];
});
}
function useRoots(): ServiceTreeItem[] {
const root = useProject((s) => s.project?.root ?? null);
useRemote((s) => s.session);
const order = useServiceOrder(projectLocation(root));
const configs = useRun((s) => s.configs);
const sessions = useRun((s) => s.sessions);
const types = useAddedTypes((s) => s.types);
const typesRoot = useAddedTypes((s) => s.root);
const ran = useRanConfigs((s) => s.ids);
const ranRoot = useRanConfigs((s) => s.root);
useEffect(() => {
if (typesRoot !== root) useAddedTypes.setState({ root, types: root ? readTypes(root) : [] });
if (ranRoot !== root) useRanConfigs.setState({ root, ids: root ? readRan(root) : [] });
}, [root, typesRoot, ranRoot]);
return useMemo(
() => runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order),
[configs, sessions, types, typesRoot, ran, ranRoot, root, order],
);
}
interface Resolved {
kind: string | null;
config: RunConfig | null;
session: RunSession | undefined;
/** Sessions of a group's configurations. */
sessions: RunSession[];
}
function resolve(item: ServiceTreeItem): Resolved {
const { configs, sessions } = useRun.getState();
const latest = latestSessions(sessions);
if (item.key.startsWith(TYPE_PREFIX)) {
const kind = item.key.slice(TYPE_PREFIX.length);
return { kind, config: null, session: undefined, sessions: sessions.filter((s) => s.config.kind === kind) };
}
const id = item.key.slice(CONFIG_PREFIX.length);
const session = latest.get(id);
const config = configs.find((c) => c.id === id) ?? session?.config ?? null;
return { kind: config?.kind ?? null, config, session, sessions: session ? [session] : [] };
}
function showOutput(s: RunSession) {
useRun.setState({ activeSession: s.id });
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)
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[] {
const r = resolve(item);
const running = r.sessions.filter(busy);
const stop = {
id: "stop",
title: "Stop",
icon: "stop",
disabled: !running.length,
run: () => running.forEach((s) => void stopSession(s.id)),
};
if (!r.config) return [stop];
const live = !!r.session && busy(r.session);
return [
{
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,
];
}
function menu(item: ServiceTreeItem): MenuItem[] {
const items: MenuItem[] = actions(item).map((a) => ({
id: a.id,
label: a.title,
icon: a.icon,
disabled: a.disabled,
run: a.run,
}));
const r = resolve(item);
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) },
);
}
return items;
}
function RunDetail({ item }: { item: ServiceTreeItem }) {
// Re-render on session changes.
useRun((s) => s.sessions);
const t = useT();
const txTemplate = useTxTemplate();
const r = resolve(item);
if (!r.config)
return (
<EmptyState title={kindLabel(r.kind ?? "")}>
<div>
{item.children?.length
? t("run.services.configurationCount", { count: item.children.length })
: txTemplate("No configurations of this type")}
</div>
</EmptyState>
);
// 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}>
<dt>
<T>Configuration</T>
</dt>
<dd>{r.config.name}</dd>
<dt>
<T>Type</T>
</dt>
<dd>
<T>{kindLabel(r.config.kind)}</T>
</dd>
<dt>
<T>Command</T>
</dt>
<dd className={styles.mono}>{commandLine(r.config)}</dd>
<dt>
<T>Status</T>
</dt>
<dd>
<T>Not started</T>
</dd>
</dl>
<div className={styles.actions}>
<Button icon="run" disabled={isDebugOnly(r.config)} onClick={() => run(r)}>
Run
</Button>
<Button icon="debug" onClick={() => debug(r)}>
Debug
</Button>
</div>
</div>
);
}
/** "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));
const item = (kind: string): MenuItem => ({
label: kindLabel(kind),
icon: configIcon({ kind, isTest: false }),
run: () => addType(kind),
});
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),
];
return [{ label: "Run Configuration Type", icon: "toolwindow-run", disabled: !submenu.length, submenu }];
}
export const runServiceProvider: ServiceProviderContribution = {
id: RUN_PROVIDER,
order: 0,
useRoots,
actions,
menu,
activate: (item) => {
const r = resolve(item);
if (r.config && !(r.session && busy(r.session)) && !isDebugOnly(r.config)) run(r);
},
detail: RunDetail,
addMenu,
reorder: {
canMove: (source, target) => {
if (!source.key.startsWith(CONFIG_PREFIX) || !target.key.startsWith(CONFIG_PREFIX) || source.key === target.key)
return false;
const from = resolve(source),
to = resolve(target);
return !!from.config && !!to.config && from.kind === to.kind;
},
move: (source, target, position) => {
if (!runServiceProvider.reorder!.canMove(source, target)) return;
const location = projectLocation(useProject.getState().project?.root);
const kind = resolve(source).kind;
if (!location || !kind) return;
const { configs, sessions } = useRun.getState();
const order = currentServiceOrder(location);
const group = runServiceItems(
configs,
sessions,
currentTypes(),
ranIds(useProject.getState().project?.root ?? null),
order,
).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) })
) {
notifications().notify({
group: "run",
level: "warning",
title: "Cannot save service order",
body: "The order will be kept for this session only.",
});
}
},
},
};