дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
Run oxfmt once over the whole repository (formatting only, no logic changes) and add `pnpm format:check` to the frontend CI job so unformatted code no longer builds up. `.oxfmtrc.json` now skips the generated theme docs, test fixtures and code blocks inside Markdown. Co-authored-by: jaw52 <[email protected]>
190 рядки
6.4 KiB
TypeScript
190 рядки
6.4 KiB
TypeScript
/**
|
||
* Settings › Tools › External Tools: every enabled tool is an action
|
||
* (`externalTools.<id>`, category = its group) that expands IDEA macros
|
||
* (`$FilePath$`, `$ProjectFileDir$`…) and runs in the Run tool window.
|
||
*/
|
||
import { activeEditor } from "@/editor/api";
|
||
import { executeAction, registerActions, type Action } from "@/platform/actions";
|
||
import { splitCommandLine } from "@/platform/commandLine";
|
||
import type { RunConfig } from "@/platform/ipc";
|
||
import { useProject } from "@/platform/project";
|
||
import { onWorkflowSettingsChange, workflowSettings, type ExternalToolValid } from "@/platform/workflowSettings";
|
||
import { notifications } from "@/workbench/contributions";
|
||
import { promptValue } from "./dialogs";
|
||
import { startRun, useRun } from "./store";
|
||
|
||
export interface MacroContext {
|
||
projectRoot: string;
|
||
projectName: string;
|
||
filePath: string | null;
|
||
line: number | null;
|
||
column: number | null;
|
||
selectedText: string;
|
||
}
|
||
|
||
const MACRO_RE = /\$([A-Za-z]+)\$/g;
|
||
|
||
function extOf(name: string): string {
|
||
const i = name.lastIndexOf(".");
|
||
return i > 0 ? name.slice(i + 1) : "";
|
||
}
|
||
|
||
/** Value of one macro, or `undefined` for unknown macros (left as typed). */
|
||
export function macroValue(name: string, ctx: MacroContext): string | undefined {
|
||
const file = ctx.filePath;
|
||
const dir = file ? file.slice(0, Math.max(file.lastIndexOf("/"), 0)) || "/" : "";
|
||
const base = file ? file.slice(file.lastIndexOf("/") + 1) : "";
|
||
const rel = (p: string) =>
|
||
p === ctx.projectRoot ? "" : p.startsWith(ctx.projectRoot + "/") ? p.slice(ctx.projectRoot.length + 1) : p;
|
||
switch (name) {
|
||
case "FilePath":
|
||
return file ?? "";
|
||
case "FileDir":
|
||
return dir;
|
||
case "FileName":
|
||
return base;
|
||
case "FileNameWithoutExtension": {
|
||
const ext = extOf(base);
|
||
return ext ? base.slice(0, -ext.length - 1) : base;
|
||
}
|
||
case "FileExt":
|
||
return extOf(base);
|
||
case "FileRelativePath":
|
||
return file ? rel(file) : "";
|
||
case "FileDirRelativeToProjectRoot":
|
||
return file ? rel(dir) : "";
|
||
case "ProjectFileDir":
|
||
return ctx.projectRoot;
|
||
case "ProjectName":
|
||
return ctx.projectName;
|
||
case "LineNumber":
|
||
return ctx.line === null ? "" : String(ctx.line);
|
||
case "ColumnNumber":
|
||
return ctx.column === null ? "" : String(ctx.column);
|
||
case "SelectedText":
|
||
return ctx.selectedText;
|
||
default:
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/** Expands macros in `text`; `$Prompt$` takes `prompt`. */
|
||
export function expandMacros(text: string, ctx: MacroContext, prompt = ""): string {
|
||
return text.replace(MACRO_RE, (all, name: string) => (name === "Prompt" ? prompt : (macroValue(name, ctx) ?? all)));
|
||
}
|
||
|
||
function currentContext(): MacroContext | null {
|
||
const project = useProject.getState().project;
|
||
if (!project) return null;
|
||
const ed = activeEditor();
|
||
let line: number | null = null;
|
||
let column: number | null = null;
|
||
let selectedText = "";
|
||
if (ed) {
|
||
const state = ed.view.state;
|
||
const sel = state.selection.main;
|
||
const l = state.doc.lineAt(sel.head);
|
||
line = l.number;
|
||
column = sel.head - l.from + 1;
|
||
selectedText = state.sliceDoc(sel.from, sel.to);
|
||
}
|
||
return {
|
||
projectRoot: project.root,
|
||
projectName: project.name,
|
||
filePath: ed?.ctx.path ?? null,
|
||
line,
|
||
column,
|
||
selectedText,
|
||
};
|
||
}
|
||
|
||
/** The run configuration a tool expands to (no `$Prompt$` handling). */
|
||
export function toolRunConfig(tool: ExternalToolValid, ctx: MacroContext, prompt = ""): RunConfig {
|
||
const program = expandMacros(tool.program.trim(), ctx, prompt);
|
||
const args = splitCommandLine(tool.arguments).map((a) => expandMacros(a, ctx, prompt));
|
||
const cwd = expandMacros(tool.workingDirectory.trim(), ctx, prompt) || ctx.projectRoot;
|
||
return {
|
||
id: `external:${tool.id}`,
|
||
name: tool.name,
|
||
kind: "external",
|
||
command: program,
|
||
args,
|
||
cwd,
|
||
env: {},
|
||
detected: false,
|
||
isTest: false,
|
||
debugAdapter: null,
|
||
};
|
||
}
|
||
|
||
/** The configuration running `tool` now (macros expanded, `$Prompt$` asked); null when cancelled or not possible. */
|
||
export async function externalToolConfig(tool: ExternalToolValid): Promise<RunConfig | null> {
|
||
const ctx = currentContext();
|
||
if (!ctx) return null;
|
||
if (!tool.program.trim()) {
|
||
notifications().notify({ level: "warning", title: `External tool '${tool.name}' has no program` });
|
||
return null;
|
||
}
|
||
const uses = (m: string) => [tool.program, tool.arguments, tool.workingDirectory].some((t) => t.includes(m));
|
||
let prompt = "";
|
||
if (uses("$Prompt$")) {
|
||
const value = await promptValue(tool.name);
|
||
if (value === null) return null;
|
||
prompt = value;
|
||
}
|
||
if (!ctx.filePath && ["$FilePath$", "$FileDir$", "$FileName$", "$FileRelativePath$"].some(uses)) {
|
||
notifications().notify({ level: "warning", title: `'${tool.name}' needs an open file` });
|
||
return null;
|
||
}
|
||
return toolRunConfig(tool, ctx, prompt);
|
||
}
|
||
|
||
export async function runExternalTool(tool: ExternalToolValid) {
|
||
const config = await externalToolConfig(tool);
|
||
if (!config) return;
|
||
const session = await startRun(config, undefined, undefined, { show: tool.openConsole });
|
||
if (!session || !tool.synchronizeAfter) return;
|
||
// "Synchronize files after execution": reload the project from disk once the tool exits.
|
||
let done = false;
|
||
const check = () => {
|
||
if (done) return;
|
||
const cur = useRun.getState().sessions.find((x) => x.id === session.id);
|
||
if (cur && cur.startedAt === session.startedAt && cur.status !== "finished" && cur.status !== "failed") return;
|
||
done = true;
|
||
unsub();
|
||
if (cur?.startedAt === session.startedAt) void executeAction("project.refresh");
|
||
};
|
||
const unsub = useRun.subscribe(check);
|
||
check();
|
||
}
|
||
|
||
function toolActions(tools: readonly ExternalToolValid[]): Action[] {
|
||
return tools
|
||
.filter((t) => t.enabled)
|
||
.map((t) => ({
|
||
id: `externalTools.${t.id}`,
|
||
title: t.name,
|
||
category: t.group || "External Tools",
|
||
icon: "run",
|
||
when: "projectOpen",
|
||
keywords: ["external tool", "tools", ...(t.description ? [t.description] : [])],
|
||
run: () => void runExternalTool(t),
|
||
}));
|
||
}
|
||
|
||
/** Keeps one action per enabled tool registered. */
|
||
export function registerExternalTools(): () => void {
|
||
let dispose = registerActions(toolActions(workflowSettings().externalTools));
|
||
const unsub = onWorkflowSettingsChange(
|
||
(s) => s.externalTools,
|
||
(tools) => {
|
||
dispose();
|
||
dispose = registerActions(toolActions(tools));
|
||
},
|
||
);
|
||
return () => {
|
||
unsub();
|
||
dispose();
|
||
};
|
||
}
|