mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
* feat: Second pass aligning interactions with IntelliJ IDEA Result tool windows - Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO, Inspection and Hierarchy, with an "N of M" status hint - Find: reuse the unpinned tab, Pin Tab, source preview, group by file or directory, exclude moves to the next result, Rerun - Usages: tabs with pinning, exclude and preview - Problems, TODO, Inspection: no autoscroll by default, severity-first sorting, context menu with quick fixes and copy description - Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling Find in Files, bookmarks, navigation - Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select - Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓), Alt mnemonics, search context filter (comments / string literals) - Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions, ⌘-click gutter toggles a bookmark - Select In popup (⌥F1); Recent Locations double-click and Delete; Search Everywhere remembers the query per tab Code insight and refactoring - Extract/Introduce continue with in-place rename and name suggestions; expression and occurrence choosers with live highlighting - Safe Delete confirms first; numbered Refactor This - Quick Documentation history, in-popup links and a Documentation tool window; Quick Definition switches between targets and shows the whole declaration - Goto chooser lists symbol names and can pin to the Find window - Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary, inlay hint ⌘-click and context menu - Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup, language surrounders with mnemonics - File Structure popup as a filterable tree; Show Usages filter and preview; call hierarchy jumps to call sites Editor - Paste re-indents each line by default; Move Statement moves whole statements in and out of blocks and re-indents - Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions; ⌥⌫ deletes the preceding word - ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning - Visual-column block selection, nested Collapse All, recursive folding, fold selection toggle, Go to Matching Brace, gutter drag selects lines, sticky line and error stripe clicks, Insert toggles overwrite mode Run, VCS, workbench - Gutter icons show the last test result; Modify Run Configuration; test tree hides passed tests, context menu and assertion diff - Stack frames resolve by fully qualified class name; context run chooser - Inline Smart Step Into - Git Log multi-select filters, "me" and date range; favourite branches, Fetch and tags in the branch popup; stash Keep index and Unstash options - Terminal tab titles follow the shell; Distraction Free persists; gear menu; sticky balloons move to the notifications tool window after a while * fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out Conflicts from restoring stashed changes show the user's uncommitted changes on the left like other operations, and the checkout notification names the new branch instead of the previous one. * fix: Close the merge editor on Apply; confirm before terminating a running Run tab Like IDEA, applying a merge result closes its tab, and closing a Run tab whose process still runs asks before terminating it (with a Settings toggle and "Do not ask again"). * fix(terminal): Don't start a new session in the background after closing the last tab The hidden Terminal window stays mounted, so it reopened a session as soon as the last tab closed. It now opens the Local tab only once the window is shown again, as IDEA does. * fix(vcs): Start the merge result from a three-way merge; close the editor on Apply The result pane now begins with non-conflicting changes merged and each conflict showing the base text, resolved from the side panes, instead of raw conflict markers. Apply now reports success, so the tab closes. fix(search): Match File Structure filter on word starts * fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles Adapters that list every remaining call in the function (debugpy) made Smart Step Into fall back to a chooser that offered calls from later lines. Targets are now narrowed to the calls on the paused line, so they are picked in place. Anonymous functions and callbacks no longer get a 'no usages' hint, and intention titles are no longer cut short when there is room for them. * fix: Undo / Redo from the Edit menu and Find Action use the editor's history They ran the webview's native undo, which misses programmatic edits such as templates, completion and refactorings, so nothing happened in the editor.
333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
/**
|
|
* Running the context (IDEA's "Run '<name>'" / "Debug '<name>'" / "Modify Run
|
|
* Configuration…"): ⌃⇧R / ⌃⇧D, the editor, project view and editor tab
|
|
* context menus and the run gutter all go through `produceContext`, then the
|
|
* produced configuration is adopted as the selected temporary configuration.
|
|
*/
|
|
import { activeEditor } from "@/editor/api";
|
|
import { executeAction, getAction } from "@/platform/actions";
|
|
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
|
|
import { useProject } from "@/platform/project";
|
|
import { isVirtualUri } from "@/platform/virtualDocs";
|
|
import { showContextMenu, type MenuItem } from "@/ui";
|
|
import {
|
|
maybeEditorService,
|
|
notifications,
|
|
runExecutors,
|
|
type ContextMenuId,
|
|
type MenuGroupContext,
|
|
} from "@/workbench/contributions";
|
|
import {
|
|
neededTools,
|
|
OPTIMISTIC,
|
|
produceContext,
|
|
produceContextChoices,
|
|
produceDirectory,
|
|
produceFile,
|
|
scriptLanguage,
|
|
type ContextInput,
|
|
type Produced,
|
|
} from "./producers";
|
|
import { runWithExecutor } from "./executors";
|
|
import { goModulePath, loadGoModules } from "./goModules";
|
|
import { toolEnv } from "./runtimes";
|
|
import { adoptTemporary, loadConfigs, modifyTemporary, startRun, useRun } from "./store";
|
|
|
|
const CONTENT_LIMIT = 400_000;
|
|
|
|
async function ensureConfigs() {
|
|
if (!useRun.getState().loaded) await loadConfigs();
|
|
}
|
|
|
|
/** The focused editor's file, text and caret line. */
|
|
export function editorInput(): ContextInput | null {
|
|
const path = maybeEditorService()?.activePath() ?? activeEditor()?.ctx.path ?? null;
|
|
if (!path || isVirtualUri(path)) return null;
|
|
const editor = activeEditor();
|
|
const same = editor && editor.ctx.path === path;
|
|
const state = same ? editor.view.state : null;
|
|
return {
|
|
path,
|
|
root: useProject.getState().project?.root ?? null,
|
|
configs: useRun.getState().configs,
|
|
content: state ? state.doc.sliceString(0, CONTENT_LIMIT) : undefined,
|
|
line: state ? state.doc.lineAt(state.selection.main.head).number - 1 : undefined,
|
|
goModule: goModulePath,
|
|
};
|
|
}
|
|
|
|
/** Production with the runtimes looked up (launches, Modify Run Configuration…). */
|
|
export async function produceResolved(input: ContextInput, dir = false): Promise<Produced | null> {
|
|
const configs = useRun.getState().configs;
|
|
if (dir || /\.go$/.test(input.path)) await loadGoModules(input.root, configs);
|
|
if (dir) return produceDirectory({ ...input, goModule: goModulePath });
|
|
const fresh = { ...input, configs, goModule: goModulePath };
|
|
const named = produceContext(fresh, OPTIMISTIC);
|
|
if (!named) return null;
|
|
// Project configurations (tests, cargo, gradle…) need no runtime lookup.
|
|
if (named.config && !named.config.id.startsWith("context:file:")) return named;
|
|
const lang = scriptLanguage(input.path, input.content);
|
|
const env = await toolEnv(neededTools(fresh), input.path, fresh.root, lang === "python" || lang === "shebang");
|
|
return produceContext(fresh, env);
|
|
}
|
|
|
|
function reportMissing(p: Produced) {
|
|
if (!p.missing) return;
|
|
notifications().notify({ group: "run", level: "warning", title: p.missing.title, body: p.missing.body, key: "run-context-missing" });
|
|
}
|
|
|
|
/** Builds with `build` in the Run window; resolves whether it succeeded. */
|
|
async function buildFirst(build: RunConfig): Promise<boolean> {
|
|
const session = await startRun(build);
|
|
if (!session) return false;
|
|
return new Promise((resolve) => {
|
|
const check = () => {
|
|
const s = useRun.getState().sessions.find((x) => x.id === session.id);
|
|
if (!s) return true;
|
|
if (s.status === "failed") return resolve(false), true;
|
|
if (s.status === "finished") return resolve(s.exitCode === 0), true;
|
|
return false;
|
|
};
|
|
if (check()) return;
|
|
const unsub = useRun.subscribe(() => {
|
|
if (check()) unsub();
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Runs or debugs a production (adopting it as the selected temporary configuration). */
|
|
export async function launchProduced(p: Produced, debug: boolean): Promise<void> {
|
|
if (p.missing || !p.config) {
|
|
reportMissing(p);
|
|
return;
|
|
}
|
|
const config = adoptTemporary(p.config);
|
|
if (!debug) {
|
|
await startRun(config);
|
|
return;
|
|
}
|
|
if (p.debug) {
|
|
if (!getAction("debug.startConfig")) {
|
|
notifications().notify({ group: "run", level: "warning", title: "Debugger is not available", key: "run-no-debugger" });
|
|
return;
|
|
}
|
|
if (!(await buildFirst(p.debug.build))) return;
|
|
await executeAction("debug.startConfig", p.debug.config);
|
|
return;
|
|
}
|
|
await executeAction("run.debugConfig", config);
|
|
}
|
|
|
|
/**
|
|
* The debug plan of a compiled single-file configuration (kinds `rustc` / `cc`)
|
|
* picked from the Run widget: re-produced from the source file in its id.
|
|
*/
|
|
export async function compiledDebugPlan(config: RunConfig): Promise<Produced | null> {
|
|
if (config.kind !== "rustc" && config.kind !== "cc") return null;
|
|
const m = /^context:file:(.+?)(?:#(.+))?$/.exec(config.id);
|
|
if (!m) return null;
|
|
const root = useProject.getState().project?.root ?? null;
|
|
const path = m[1]!.startsWith("/") || !root ? m[1]! : `${root}/${m[1]}`;
|
|
let content: string | undefined;
|
|
try {
|
|
content = isTauri() ? await call(commands.fsReadText(path)) : undefined;
|
|
} catch {
|
|
return null;
|
|
}
|
|
const input: ContextInput = { path, root, configs: useRun.getState().configs, content };
|
|
const env = await toolEnv(neededTools(input), path, root, false);
|
|
if (m[2]) {
|
|
// A test: find the target by name again.
|
|
const lines = (content ?? "").split("\n");
|
|
const line = lines.findIndex((l) => new RegExp(`\\bfn\\s+${m[2]!.split("::").pop()}\\b|\\bmod\\s+${m[2]}\\b`).test(l));
|
|
return line >= 0 ? produceContext({ ...input, line }, env) : null;
|
|
}
|
|
return produceFile(input, env);
|
|
}
|
|
|
|
/** ⌃⇧R / ⌃⇧D: the innermost context at the caret of the active editor. */
|
|
/** Reads the file when the context has no text yet (`#!` lines, `package main`, `fn main`…). */
|
|
async function withContent<T extends ContextInput & { dir?: boolean }>(input: T): Promise<T> {
|
|
if (input.content !== undefined || input.dir || !isTauri() || isVirtualUri(input.path)) return input;
|
|
try {
|
|
return { ...input, content: (await call(commands.fsReadText(input.path))).slice(0, CONTENT_LIMIT) };
|
|
} catch {
|
|
return input;
|
|
}
|
|
}
|
|
|
|
export async function runContextAction(debug: boolean, arg?: unknown): Promise<boolean> {
|
|
await ensureConfigs();
|
|
const target = await withArgContent(arg);
|
|
const input = target ?? editorInput();
|
|
if (!input) return false;
|
|
const dir = target?.dir ?? false;
|
|
const choices = dir ? [] : produceContextChoices({ ...input, configs: useRun.getState().configs, goModule: goModulePath }, OPTIMISTIC);
|
|
if (choices.length > 1) {
|
|
// Several test configurations match equally well: like IDEA, let the user choose.
|
|
showContextMenu(chooserPoint(target === null), choiceItems(choices, debug));
|
|
return true;
|
|
}
|
|
const p = await produceResolved(input, dir);
|
|
if (!p) return false;
|
|
await launchProduced(p, debug);
|
|
return true;
|
|
}
|
|
|
|
/** Menu entries for each of several productions: "Run '<name>'" with the configuration it derives from. */
|
|
export function choiceItems(choices: readonly Produced[], debug: boolean): MenuItem[] {
|
|
return [
|
|
{ kind: "header", label: debug ? "Choose configuration to debug" : "Choose configuration to run" },
|
|
...choices.map(
|
|
(p): MenuItem => ({
|
|
label: `${debug ? "Debug" : "Run"} '${p.name}'`,
|
|
verbatim: true,
|
|
icon: debug ? "debug" : "run",
|
|
shortcut: p.detail,
|
|
run: () => void launchProduced(p, debug),
|
|
}),
|
|
),
|
|
];
|
|
}
|
|
|
|
/** Below the caret for the editor context, else the middle of the window. */
|
|
function chooserPoint(fromEditor: boolean): { x: number; y: number } {
|
|
const editor = fromEditor ? activeEditor() : null;
|
|
const caret = editor?.view.coordsAtPos(editor.view.state.selection.main.head);
|
|
if (caret) return { x: caret.left, y: caret.bottom + 2 };
|
|
return { x: Math.round(window.innerWidth / 2 - 120), y: Math.round(window.innerHeight / 3) };
|
|
}
|
|
|
|
/** Runs the context with a run executor (Run with Coverage, Profile…). False when there is nothing to run. */
|
|
export async function executeContext(executor: string, arg?: unknown): Promise<boolean> {
|
|
await ensureConfigs();
|
|
const target = await withArgContent(arg);
|
|
const input = target ?? editorInput();
|
|
if (!input) return false;
|
|
const p = await produceResolved(input, target?.dir ?? false);
|
|
if (!p) return false;
|
|
if (p.missing || !p.config) {
|
|
reportMissing(p);
|
|
return true;
|
|
}
|
|
await runWithExecutor(adoptTemporary(p.config), executor);
|
|
return true;
|
|
}
|
|
|
|
/** "Run '<name>' with Coverage", "Profile '<name>'"… for a context the executors can run. */
|
|
export function executorItems(config: RunConfig | null | undefined, name: string, context: ContextArg): MenuItem[] {
|
|
if (!config) return [];
|
|
return runExecutors()
|
|
.filter((e) => e.supports(config))
|
|
.map((e) => ({ kind: "action", action: "run.withExecutor", arg: { executor: e.id, context }, label: e.label(name), icon: e.icon }));
|
|
}
|
|
|
|
export interface ContextArg {
|
|
path: string;
|
|
line?: number;
|
|
dir?: boolean;
|
|
content?: string;
|
|
}
|
|
|
|
async function withArgContent(arg: unknown) {
|
|
const target = contextArg(arg);
|
|
return target ? withContent(target) : null;
|
|
}
|
|
|
|
function contextArg(arg: unknown): (ContextInput & { dir?: boolean }) | null {
|
|
if (!arg || typeof arg !== "object" || typeof (arg as ContextArg).path !== "string") return null;
|
|
const a = arg as ContextArg;
|
|
const editor = activeEditor();
|
|
const state = editor && editor.ctx.path === a.path ? editor.view.state : null;
|
|
return {
|
|
path: a.path,
|
|
dir: a.dir,
|
|
root: useProject.getState().project?.root ?? null,
|
|
configs: useRun.getState().configs,
|
|
content: a.content ?? state?.doc.sliceString(0, CONTENT_LIMIT) ?? peekContent(a.path),
|
|
line: a.line,
|
|
goModule: goModulePath,
|
|
};
|
|
}
|
|
|
|
/** "Modify Run Configuration…": the would-be configuration in Edit Configurations. */
|
|
export async function modifyContext(arg?: unknown): Promise<void> {
|
|
await ensureConfigs();
|
|
const target = await withArgContent(arg);
|
|
const input = target ?? editorInput();
|
|
if (!input) return;
|
|
const p = await produceResolved(input, target?.dir ?? false);
|
|
if (!p) return;
|
|
if (!p.config) {
|
|
reportMissing(p);
|
|
return;
|
|
}
|
|
modifyTemporary(p.config);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context menus
|
|
|
|
/** File text read for the project view menu (files not open in an editor). */
|
|
const peeked = new Map<string, { at: number; text: string }>();
|
|
const PEEK_TTL = 10_000;
|
|
|
|
function peekContent(path: string): string | undefined {
|
|
const hit = peeked.get(path);
|
|
return hit && Date.now() - hit.at < PEEK_TTL ? hit.text : undefined;
|
|
}
|
|
|
|
/** Whether the file's runnability depends on its text (`package main`, `fn main`, `#!`…). */
|
|
function needsContent(path: string): boolean {
|
|
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
return /\.(go|rs|java|kt|c|cpp|cc|cxx)$/.test(name) || !name.includes(".");
|
|
}
|
|
|
|
export async function prepareMenu(_menu: ContextMenuId, ctx: MenuGroupContext): Promise<void> {
|
|
if (ctx.paths.length !== 1) return;
|
|
const path = ctx.paths[0]!;
|
|
if (ctx.dirs?.[0] || /\.go$/.test(path))
|
|
await loadGoModules(useProject.getState().project?.root ?? null, useRun.getState().configs).catch(() => {});
|
|
if (ctx.text !== undefined || ctx.dirs?.[0]) return;
|
|
if (!needsContent(path) || peekContent(path) !== undefined || !isTauri() || isVirtualUri(path)) return;
|
|
const text = await call(commands.fsReadText(path));
|
|
peeked.set(path, { at: Date.now(), text: text.slice(0, CONTENT_LIMIT) });
|
|
}
|
|
|
|
/** The run group of a context menu: Run / Debug '<name>', Modify Run Configuration…. */
|
|
export function contextMenuItems(menu: ContextMenuId, ctx: MenuGroupContext): MenuItem[] {
|
|
if (ctx.paths.length !== 1) return [];
|
|
const path = ctx.paths[0]!;
|
|
if (isVirtualUri(path)) return [];
|
|
const root = useProject.getState().project?.root ?? null;
|
|
if (!root) return [];
|
|
const dir = !!ctx.dirs?.[0];
|
|
const content = ctx.text ?? peekContent(path);
|
|
const line = menu === "editor" ? ctx.line : undefined;
|
|
const input: ContextInput = {
|
|
path,
|
|
root,
|
|
configs: useRun.getState().configs,
|
|
content: content?.slice(0, CONTENT_LIMIT),
|
|
line,
|
|
goModule: goModulePath,
|
|
};
|
|
const p = dir ? produceDirectory(input) : produceContext(input, OPTIMISTIC);
|
|
if (!p) return [];
|
|
const arg: ContextArg = { path, line, dir, content: dir ? undefined : input.content };
|
|
return [
|
|
{ kind: "action", action: "run.runContext", arg, label: `Run '${p.name}'`, icon: "run" },
|
|
// Like IDEA, Debug only where a debugger can run it.
|
|
...(p.debug || p.config?.debugAdapter || p.missing
|
|
? ([{ kind: "action", action: "run.debugContext", arg, label: `Debug '${p.name}'`, icon: "debug" }] as MenuItem[])
|
|
: []),
|
|
{
|
|
label: "More Run/Debug",
|
|
submenu: [
|
|
...executorItems(p.config, p.name, arg),
|
|
{ kind: "action", action: "run.modifyContext", arg, label: "Modify Run Configuration…", icon: "edit" },
|
|
],
|
|
},
|
|
];
|
|
}
|