Файли
openidea/src/features/run/configs.ts
T
l0ng-ai 1e00ea5ee2 feat: Third pass aligning core experience with IntelliJ IDEA
Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-25 21:24:00 +08:00

378 рядки
12 KiB
TypeScript

/** Run configuration helpers: grouping, overriding, validation and argument parsing. */
import type { RunConfig } from "@/platform/ipc";
import { useRunConfigTypes } from "@/workbench/contributions";
const KIND_LABELS: Record<string, string> = {
npm: "npm",
deno: "Deno",
bun: "Bun",
cargo: "Cargo",
gradle: "Gradle",
maven: "Maven",
go: "Go",
python: "Python",
pytest: "pytest",
make: "Make",
just: "Just",
shell: "Shell Script",
program: "Program",
compound: "Compound",
application: "Application",
"spring-boot": "Spring Boot",
node: "Node.js",
"java-file": "Java Single File",
rustc: "Rust Single File",
kotlinc: "Kotlin Single File",
cc: "C/C++ Single File",
native: "Native Executable",
ruby: "Ruby",
php: "PHP",
perl: "Perl",
lua: "Lua",
r: "R",
swift: "Swift",
dart: "Dart",
elixir: "Elixir",
"scala-cli": "Scala CLI",
kotlin: "Kotlin Script",
};
export const KIND_OPTIONS = Object.entries(KIND_LABELS).map(([value, label]) => ({ value, label }));
export function kindLabel(kind: string): string {
return (
useRunConfigTypes.getState().types.get(kind)?.label ??
KIND_LABELS[kind] ??
(kind ? kind[0]!.toUpperCase() + kind.slice(1) : "Other")
);
}
export function configIcon(config: Pick<RunConfig, "kind" | "isTest">): string {
if (config.isTest) return "check";
switch (config.kind) {
case "compound":
return "gutter-run-all";
case "program":
return "file-binary";
case "spring-boot":
return "spring-boot";
case "npm":
case "bun":
return "file-npm";
case "deno":
return "file-ts";
case "cargo":
return "file-cargo";
case "gradle":
return "file-gradle";
case "maven":
return "file-maven";
case "go":
return "file-go";
case "node":
return "file-js";
case "application":
return "class";
case "java-file":
return "file-java";
case "rustc":
return "file-rust";
case "cc":
return "file-cpp";
case "shell":
return "file-shell";
case "ruby":
return "file-ruby";
case "php":
return "file-php";
case "perl":
return "file-perl";
case "lua":
return "file-lua";
case "r":
return "file-r";
case "swift":
return "file-swift";
case "dart":
return "file-dart";
case "elixir":
return "file-elixir";
case "scala-cli":
return "file-scala";
case "kotlinc":
return "file-kotlin";
case "kotlin":
return "file-kotlin-script";
case "native":
return "file-binary";
case "python":
case "pytest":
return "file-python";
default:
return useRunConfigTypes.getState().types.get(config.kind)?.icon ?? "terminal";
}
}
export interface ConfigGroup {
kind: string;
label: string;
configs: RunConfig[];
}
/** Groups by kind, keeping the backend's order (detection order) within and across groups. */
export function groupConfigs(configs: readonly RunConfig[]): ConfigGroup[] {
const groups = new Map<string, ConfigGroup>();
for (const c of configs) {
let g = groups.get(c.kind);
if (!g) groups.set(c.kind, (g = { kind: c.kind, label: kindLabel(c.kind), configs: [] }));
g.configs.push(c);
}
return [...groups.values()];
}
/** Configs persisted in `.openidea/run.toml` (everything not regenerated by detection). */
export function userConfigs(configs: readonly RunConfig[]): RunConfig[] {
return configs.filter((c) => !c.detected);
}
/**
* Applies an edit. Editing a detected config produces a user copy with the
* same id, which then overrides the detected one (`run_configs` drops detected
* entries whose id a user config shares).
*/
export function upsertConfig(configs: readonly RunConfig[], edited: RunConfig): RunConfig[] {
const next: RunConfig = { ...edited, detected: false };
const i = configs.findIndex((c) => c.id === edited.id);
if (i < 0) return [...configs, next];
const out = [...configs];
out[i] = next;
return out;
}
export function removeConfig(configs: readonly RunConfig[], id: string): RunConfig[] {
return configs.filter((c) => c.id !== id);
}
/** Merges detected + user configs the way the backend does (user ids win, user configs last). */
export function mergeConfigs(detected: readonly RunConfig[], user: readonly RunConfig[]): RunConfig[] {
const ids = new Set(user.map((u) => u.id));
return [...detected.filter((d) => !ids.has(d.id)), ...user];
}
export function configsEqual(a: RunConfig, b: RunConfig): boolean {
return (
a.id === b.id &&
a.name === b.name &&
a.kind === b.kind &&
a.command === b.command &&
a.args.join("\u0000") === b.args.join("\u0000") &&
(a.cwd ?? "") === (b.cwd ?? "") &&
JSON.stringify(sortedEnv(a.env)) === JSON.stringify(sortedEnv(b.env)) &&
a.isTest === b.isTest &&
(a.debugAdapter ?? "") === (b.debugAdapter ?? "") &&
a.detected === b.detected
);
}
function sortedEnv(env: RunConfig["env"]) {
return Object.entries(env).sort(([a], [b]) => a.localeCompare(b));
}
let counter = 0;
export function uid(): string {
const rnd =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().slice(0, 8)
: Math.random().toString(36).slice(2, 10);
return `user:${rnd}${++counter}`;
}
export function uniqueName(base: string, configs: readonly RunConfig[]): string {
const names = new Set(configs.map((c) => c.name));
if (!names.has(base)) return base;
for (let i = 2; ; i++) {
const n = `${base} (${i})`;
if (!names.has(n)) return n;
}
}
export function newConfig(configs: readonly RunConfig[]): RunConfig {
return {
id: uid(),
name: uniqueName("Unnamed", configs),
kind: "shell",
command: "",
args: [],
cwd: null,
env: {},
detected: false,
isTest: false,
debugAdapter: null,
};
}
export function duplicateConfig(config: RunConfig, configs: readonly RunConfig[]): RunConfig {
return {
...structuredClone(config),
id: uid(),
name: uniqueName(`${config.name} (copy)`, configs),
detected: false,
};
}
/** Field errors: `name`, `command`, `env`, `cwd`, `compound`, `beforeLaunch` or a type param key. */
export type ConfigErrors = Partial<Record<string, string>>;
export function validateConfig(config: RunConfig, all: readonly RunConfig[] = []): ConfigErrors {
const errors: ConfigErrors = {};
if (!config.name.trim()) errors.name = "Name is required";
else if (all.some((c) => c.id !== config.id && c.name.trim() === config.name.trim()))
errors.name = "Another configuration has this name";
if (config.kind === "compound") {
const children = config.compound ?? [];
if (!children.length) errors.compound = "Add at least one configuration";
else if (compoundCycle(config, all)) errors.compound = "A compound configuration can't include itself";
else if (children.some((id) => !all.some((c) => c.id === id)))
errors.compound = "Some configurations no longer exist";
} else if (!config.command.trim()) errors.command = "Command is required";
for (const t of config.beforeLaunch ?? []) {
if (t.kind === "run" && (t.target === config.id || !all.some((c) => c.id === t.target)))
errors.beforeLaunch = "A before launch task runs a missing configuration or this one";
else if (t.kind !== "build" && t.kind !== "run" && !(t.target ?? "").trim())
errors.beforeLaunch = "A before launch task is incomplete";
}
for (const key of Object.keys(config.env)) {
if (!key.trim()) {
errors.env = "Environment variable names can't be empty";
break;
}
if (/[=\s\0]/.test(key)) {
errors.env = `Invalid environment variable name: ${key}`;
break;
}
}
if (config.cwd && /\0/.test(config.cwd)) errors.cwd = "Invalid directory";
return errors;
}
export const hasErrors = (e: ConfigErrors) => Object.keys(e).length > 0;
/** Splits a command line into arguments: whitespace, '…' and "…" quoting, backslash escapes. */
export function parseArgs(input: string): string[] {
const out: string[] = [];
let cur = "";
let has = false;
let quote: "'" | '"' | null = null;
for (let i = 0; i < input.length; i++) {
const ch = input[i]!;
if (quote) {
if (ch === quote) quote = null;
else if (ch === "\\" && quote === '"' && i + 1 < input.length && '"\\$`'.includes(input[i + 1]!)) cur += input[++i];
else cur += ch;
} else if (ch === "'" || ch === '"') {
quote = ch;
has = true;
} else if (ch === "\\" && i + 1 < input.length) {
cur += input[++i];
has = true;
} else if (/\s/.test(ch)) {
if (has || cur) out.push(cur);
cur = "";
has = false;
} else {
cur += ch;
}
}
if (has || cur) out.push(cur);
return out;
}
/** Inverse of `parseArgs` (quotes only when needed). */
export function formatArgs(args: readonly string[]): string {
return args
.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a))
.join(" ");
}
/** Display command line, e.g. `pnpm run dev`. */
export function commandLine(config: Pick<RunConfig, "command" | "args">): string {
return [config.command, formatArgs(config.args)].filter(Boolean).join(" ");
}
/** Whether compound `config` reaches itself through its children. */
export function compoundCycle(config: RunConfig, all: readonly RunConfig[]): boolean {
const byId = new Map(all.map((c) => [c.id, c]));
byId.set(config.id, config);
const seen = new Set<string>();
const visit = (id: string): boolean => {
if (seen.has(id)) return false;
seen.add(id);
return (byId.get(id)?.compound ?? []).some((child) => child === config.id || visit(child));
};
return visit(config.id);
}
/** The configurations a compound starts (nested compounds flattened, each once). */
export function compoundChildren(config: RunConfig, all: readonly RunConfig[]): RunConfig[] {
const out: RunConfig[] = [];
const seen = new Set<string>([config.id]);
const walk = (ids: readonly string[]) => {
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
const c = all.find((x) => x.id === id);
if (!c) continue;
if (c.kind === "compound") walk(c.compound ?? []);
else out.push(c);
}
};
walk(config.compound ?? []);
return out;
}
/** IDEA's one-line environment field: `A=1;B=2` (`;` and `\` in values escaped). */
export function formatEnv(env: RunConfig["env"]): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v.replace(/[\\;]/g, (c) => `\\${c}`)}`)
.join(";");
}
/**
* Parses `A=1;B=2`, or one `NAME=value` per line as pasted from a shell or a
* `.env` file (`export` prefixes, comments and surrounding quotes dropped).
*/
export function parseEnv(text: string): Array<{ key: string; value: string }> {
const multiline = /\r?\n/.test(text.trim());
const entries: string[] = [];
if (multiline) entries.push(...text.split(/\r?\n/));
else {
let cur = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === "\\" && (text[i + 1] === ";" || text[i + 1] === "\\")) cur += text[++i];
else if (ch === ";") {
entries.push(cur);
cur = "";
} else cur += ch;
}
entries.push(cur);
}
const out: Array<{ key: string; value: string }> = [];
for (const raw of entries) {
let line = multiline ? raw.trim() : raw.trimStart();
if (!line || (multiline && line.startsWith("#"))) continue;
if (multiline) line = line.replace(/^export\s+/, "");
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1);
if (multiline) {
value = value.trim();
const q = value[0];
if ((q === '"' || q === "'") && value.length > 1 && value.endsWith(q)) value = value.slice(1, -1);
}
out.push({ key, value });
}
return out;
}