feat: Second pass aligning interactions with IntelliJ IDEA (#23)

* 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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-25 09:44:46 +08:00
1 джерело 81e0e8e2d0
коміт 82b6980a59
262 files changed
+13085 -1596

No files matched your search

+36 -11
Переглянути файл
@@ -16,6 +16,7 @@ import { docOf } from "@/editor/lsp/lsp";
import { offsetsToRange, rangeToOffsets } from "@/editor/lsp/positions";
import { showHint } from "@/editor/lsp/hint";
import { caretAnchor, openPopup } from "@/editor/lsp/popupStore";
import { clearTemporaryHighlight, highlightRanges } from "@/editor/lsp/tempHighlight";
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { expressionWords, findTextOccurrences, normalizeExpression } from "./occurrences";
@@ -150,7 +151,8 @@ function isCallee(doc: Text, node: SyntaxNode, outer: SyntaxNode | undefined): b
return /^\s*(\(|<|::<)/.test(doc.sliceString(end, end + 4));
}
function preview(text: string): string {
/** Chooser label of an expression: one line, shortened. */
export function expressionPreview(text: string): string {
const one = text.replace(/\s+/g, " ").trim();
return one.length > 80 ? `${one.slice(0, 77)}…` : one;
}
@@ -194,7 +196,7 @@ async function introduce(view: EditorView, kind: IntroduceKind, reason: Fallback
const p: Prepared = { view, ctx, kind, reason, doc, at };
let candidates: SyntaxNode[];
if (sel.empty) {
candidates = at.candidates.filter((c, i) => !c.error && !c.missing && !isCallee(doc, c, at.candidates[i + 1]));
candidates = expressionCandidates(doc, at);
if (!candidates.length) return refuse(p, "no expression at the caret");
} else {
if (!at.expression || !at.exact) return refuse(p, "selected block should represent an expression");
@@ -208,11 +210,22 @@ async function introduce(view: EditorView, kind: IntroduceKind, reason: Fallback
kind: "list",
anchor: caretAnchor(view, sel.head),
title: "Expressions",
items: candidates.map((c, i) => ({ id: `${i}`, label: preview(nodeText(doc, c)), run: () => perform(p, c) })),
items: candidates.map((c, i) => ({
id: `${i}`,
label: expressionPreview(nodeText(doc, c)),
onActive: () => highlightRanges(view, [rangeToOffsets(doc, c.range)]),
run: () => perform(p, c),
})),
onClose: () => clearTemporaryHighlight(view),
});
return true;
}
/** The expressions at an empty selection, innermost first, that are worth extracting (no callee names). */
export function expressionCandidates(doc: Text, at: ExpressionAt): SyntaxNode[] {
return at.candidates.filter((c, i) => !c.error && !c.missing && !isCallee(doc, c, at.candidates[i + 1]));
}
async function brokenAround(
ctx: EditorDocContext,
doc: Text,
@@ -331,7 +344,7 @@ async function introduceVariable(p: Prepared, expr: SyntaxNode, chain: SyntaxNod
}
return { changes: state.changes(specs), decl, declAt: stmtFrom, refs };
};
await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names));
await chooseOccurrences(p, { from: exprFrom, to: exprTo }, others, (all) => applyAndName(p, plan(all), names));
}
async function variableContext(
@@ -469,7 +482,7 @@ async function introduceConstant(p: Prepared, expr: SyntaxNode, chain: SyntaxNod
}
return { changes: state.changes(specs), decl, declAt: place.insertAt, declIndent: place.indent.length, refs };
};
await chooseOccurrences(p, others.length, (all) => applyAndName(p, plan(all), names));
await chooseOccurrences(p, { from: exprFrom, to: exprTo }, others, (all) => applyAndName(p, plan(all), names));
}
/** Not an object key, type, import source…: a place where a named constant can stand. */
@@ -561,20 +574,32 @@ async function verifiedOccurrences(
}
/** IDEA's "Multiple occurrences found" choice; runs `apply` directly when there are none. */
async function chooseOccurrences(p: Prepared, others: number, apply: (all: boolean) => void) {
if (!others) {
async function chooseOccurrences(p: Prepared, self: Occurrence, others: Occurrence[], apply: (all: boolean) => void) {
if (!others.length) {
apply(false);
return;
}
const head = p.view.state.selection.main.head;
const { view } = p;
const head = view.state.selection.main.head;
openPopup({
kind: "list",
anchor: caretAnchor(p.view, head),
anchor: caretAnchor(view, head),
title: "Multiple occurrences found",
items: [
{ id: "one", label: "Replace this occurrence only", run: () => apply(false) },
{ id: "all", label: `Replace all ${others + 1} occurrences`, run: () => apply(true) },
{
id: "one",
label: "Replace this occurrence only",
onActive: () => highlightRanges(view, [self]),
run: () => apply(false),
},
{
id: "all",
label: `Replace all ${others.length + 1} occurrences`,
onActive: () => highlightRanges(view, [self, ...others]),
run: () => apply(true),
},
],
onClose: () => clearTemporaryHighlight(view),
});
}
+84 -4
Переглянути файл
@@ -11,7 +11,11 @@ let query: (request: number) => Promise<Result> = async () => ok({ actions: [],
let prepare: Result = ok(null);
const lspCodeActionQuery = vi.fn(async (request: number, _doc: number, _range: unknown, _only: string[]) => query(request));
const lspPrepareRename = vi.fn(async () => prepare);
const lspApplyCodeAction = vi.fn(async () => ok(null));
let onApply: (() => void) | null = null;
const lspApplyCodeAction = vi.fn(async () => {
onApply?.();
return ok(null);
});
const lspCodeActions = vi.fn(async () => ok([]));
const cancelRequest = vi.fn(async () => undefined);
const languages = vi.fn(async () => [{ id: "rust", name: "Rust", lineComment: "//", blockComment: ["/*", "*/"] }]);
@@ -25,16 +29,22 @@ vi.mock("@/platform/ipc", async (orig) => {
};
});
let expression = false;
let candidates: unknown[] = [];
vi.mock("@/editor/structure", () => ({
structure: {
support: vi.fn(async () => ({ tree: true, locals: false })),
expressionAt: vi.fn(async () => ({ expression: expression ? { kind: "call_expression" } : null })),
expressionAt: vi.fn(async () => ({ expression: expression ? { kind: "call_expression" } : null, exact: expression, candidates })),
identifiers: vi.fn(async () => []),
},
}));
const showHint = vi.fn();
vi.mock("@/editor/lsp/hint", () => ({ showHint }));
const introduceFallback = vi.fn(async () => true);
vi.mock("./introduce", () => ({ introduceFallback }));
vi.mock("./introduce", () => ({
introduceFallback,
expressionCandidates: (_doc: unknown, at: { candidates?: unknown[] }) => at.candidates ?? [],
expressionPreview: (text: string) => text,
}));
const safeDelete = vi.fn();
vi.mock("./safeDelete", () => ({ safeDelete }));
@@ -42,6 +52,8 @@ const { classifyAction, kindAdvertised, cannotPerform } = await import("./catalo
const { refactorThis, runRefactoring } = await import("./refactorThis");
const { useLspPopup, closePopup } = await import("@/editor/lsp/popupStore");
const { lspDoc } = await import("@/editor/lsp/lsp");
const { inplaceRenameExtension, inplaceSession } = await import("@/editor/lsp/inplaceRename");
const { temporaryHighlights } = await import("@/editor/lsp/tempHighlight");
// jsdom has no layout; popups anchor at the caret.
Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList;
@@ -66,7 +78,10 @@ function editor(text = "fn main() { let total = price * 2; }") {
state: EditorState.create({
doc: text,
selection: { anchor: text.indexOf("price") + 1 },
extensions: [lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false })],
extensions: [
lspDoc.of({ doc: 1, path: "/p/a.rs", language: "rust", uiMode: null, readOnly: false }),
inplaceRenameExtension(),
],
}),
});
return view;
@@ -80,6 +95,8 @@ beforeEach(() => {
query = answer([]);
prepare = ok(null);
expression = false;
candidates = [];
onApply = null;
});
afterEach(() => {
view?.destroy();
@@ -139,6 +156,8 @@ describe("Refactor This", () => {
]);
await refactorThis(editor(), vi.fn());
expect(labels()).toEqual(["Rename…", "Safe Delete…", "Introduce Variable…", "Inline…", "Convert to raw string"]);
// IDEA's numbered list: 1-9 run a row directly.
expect((useLspPopup.getState().popup as { numbered?: boolean }).numbered).toBe(true);
expect(lspCodeActionQuery).toHaveBeenCalledOnce();
expect(lspCodeActionQuery.mock.calls[0]![3]).toEqual(["refactor"]);
});
@@ -248,4 +267,65 @@ describe("Refactor menu actions", () => {
await runRefactoring(editor(), "extractField");
expect(lspCodeActions).not.toHaveBeenCalled();
});
it("with only a caret, choose the expression first (highlighted while choosing) and ask the server for it", async () => {
const text = "fn main() { let total = price * 2; }";
const node = (t: string) => {
const from = text.indexOf(t);
return { kind: "e", range: { start: { line: 0, col: from }, end: { line: 0, col: from + t.length } }, error: false, missing: false };
};
candidates = [node("price"), node("price * 2")];
const v = editor(text);
await runRefactoring(v, "extractVariable");
const popup = useLspPopup.getState().popup;
expect(popup?.kind === "list" && popup.title).toBe("Expressions");
expect(labels()).toEqual(["price", "price * 2"]);
expect(lspCodeActionQuery).not.toHaveBeenCalled();
if (popup?.kind !== "list") return;
popup.items[1]!.onActive!();
expect(temporaryHighlights(v)).toEqual([{ from: text.indexOf("price"), to: text.indexOf("2") + 1 }]);
query = answer([action("Extract into variable", "refactor.extract")]);
closePopup();
expect(temporaryHighlights(v)).toEqual([]);
popup.items[1]!.run();
await vi.waitFor(() => expect(lspCodeActionQuery).toHaveBeenCalledOnce());
expect(v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to)).toBe("price * 2");
expect(lspCodeActionQuery.mock.calls[0]![2]).toEqual((candidates[1] as { range: unknown }).range);
});
it("offer IDEA's occurrences choice when the server has both variants", async () => {
const text = "fn f() { g(price); h(price); }";
const v = editor(text);
v.dispatch({ selection: { anchor: text.indexOf("price"), head: text.indexOf("price") + 5 } });
query = answer([
action("Extract to local variable", "refactor.extract.variable"),
action("Extract to local variable (replace all occurrences)", "refactor.extract.variable"),
]);
await runRefactoring(v, "extractVariable");
expect(labels()).toEqual(["Replace this occurrence only", "Replace all 2 occurrences"]);
const popup = useLspPopup.getState().popup;
if (popup?.kind !== "list") return;
popup.items[1]!.onActive!();
expect(temporaryHighlights(v)).toHaveLength(2);
});
it("rename the introduced name in place after the server's edit", async () => {
const text = "fn main() { let total = price * 2; }";
const v = editor(text);
v.dispatch({ selection: { anchor: text.indexOf("price"), head: text.indexOf("2") + 1 } });
query = answer([action("Extract into variable", "refactor.extract")]);
onApply = () =>
v.dispatch({
changes: [
{ from: text.indexOf("let total"), insert: "let var_name = price * 2;\n " },
{ from: text.indexOf("price"), to: text.indexOf("2") + 1, insert: "var_name" },
],
});
await runRefactoring(v, "extractVariable");
await vi.waitFor(() => expect(inplaceSession(v.state)).not.toBeNull());
const session = inplaceSession(v.state)!;
expect(session.oldName).toBe("var_name");
expect(session.others).toHaveLength(1);
});
});
+28 -7
Переглянути файл
@@ -30,6 +30,13 @@ import {
} from "./catalog";
import { introduceFallback, type IntroduceKind } from "./introduce";
import { languageName, queryRefactorActions } from "./query";
import {
EXPRESSION_REFACTORINGS,
applyIntroduceAction,
chooseExpressionFirst,
chooseOccurrences,
introducesName,
} from "./serverIntroduce";
import { safeDelete } from "./safeDelete";
/** Outcome of the server query: its answer, or why there is none. */
@@ -66,9 +73,14 @@ export function groupActions(actions: readonly CodeAction[]): {
return { mapped, other };
}
/** Applies the single action, or lets the user pick one. */
function runActions(view: EditorView, title: string, actions: readonly CodeAction[]) {
if (actions.length === 1) return applyCodeAction(view, actions[0]!);
/**
* Applies the single action, or lets the user pick one. Introduce / Extract
* actions offer IDEA's occurrences choice and rename the new name in place.
*/
function runActions(view: EditorView, title: string, actions: readonly CodeAction[], id: RefactoringId | null = null) {
if (id && EXPRESSION_REFACTORINGS.has(id) && chooseOccurrences(view, actions, id)) return;
const apply = (a: CodeAction) => (id && introducesName(id) ? applyIntroduceAction(view, a, id) : applyCodeAction(view, a));
if (actions.length === 1) return apply(actions[0]!);
openPopup({
kind: "list",
anchor: caretAnchor(view, view.state.selection.main.head),
@@ -78,7 +90,7 @@ function runActions(view: EditorView, title: string, actions: readonly CodeActio
label: a.title,
icon: "bulb",
emphasis: a.isPreferred,
run: () => applyCodeAction(view, a),
run: () => apply(a),
})),
});
}
@@ -127,6 +139,10 @@ export async function runRefactoring(view: EditorView, id: RefactoringId): Promi
showHint(view, cannotPerform("The file is read-only"));
return;
}
// IDEA picks the expression first (and the server then extracts exactly that one).
if (EXPRESSION_REFACTORINGS.has(id) && view.state.selection.main.empty) {
if (await chooseExpressionFirst(view, () => runRefactoring(view, id))) return;
}
menuRun?.abort();
const run = (menuRun = new AbortController());
const state = view.state;
@@ -143,7 +159,7 @@ export async function runRefactoring(view: EditorView, id: RefactoringId): Promi
const all = answer.status === "ok" ? answer.query.actions : [];
const actions = groupActions(all).mapped.get(id) ?? [];
if (actions.length) {
await runActions(view, r.label.replace(/…$/, ""), actions);
await runActions(view, r.label.replace(/…$/, ""), actions, id);
return;
}
const introduce = INTRODUCE[id];
@@ -195,10 +211,15 @@ export function refactorThisItems(
const { mapped, other } = groupActions(answer.status === "ok" ? answer.query.actions : []);
const serverUsable = answer.status === "ok";
const items: ListPopupItem[] = [];
const caretOnly = view.state.selection.main.empty;
for (const r of REFACTORINGS) {
const actions = mapped.get(r.id) ?? [];
const expression = EXPRESSION_REFACTORINGS.has(r.id);
let go: (() => unknown) | null = null;
if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions);
// With only a caret the expression is chosen first, then the server asked for it.
if (expression && caretOnly && serverUsable && (actions.length || facts.expression))
go = () => runRefactoring(view, r.id);
else if (actions.length) go = () => runActions(view, r.label.replace(/…$/, ""), actions, r.id);
else if (r.id === "rename" && facts.symbol) go = () => run.rename(view);
else if (r.id === "safeDelete" && facts.symbol && !docOf(view)?.readOnly) go = () => run.safeDelete(view);
else if (INTRODUCE[r.id] && facts.expression && !serverUsable)
@@ -243,5 +264,5 @@ export async function refactorThis(view: EditorView, rename: (view: EditorView)
showHint(view, "No refactorings available at the caret");
return;
}
openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items });
openPopup({ kind: "list", anchor: caretAnchor(view, state.selection.main.head), title: "Refactor This", items, numbered: true });
}
+50 -3
Переглянути файл
@@ -47,10 +47,12 @@ vi.mock("@/platform/ipc", async (orig) => {
let locals: Locals = { status: "noIdentifier" };
let statement: SyntaxNode | null = null;
let identifiers: { range: Range; qualified: boolean }[] = [];
vi.mock("@/editor/structure", () => ({
structure: {
locals: vi.fn(async () => locals),
structuralRanges: vi.fn(async (): Promise<Partial<StructuralRanges>> => ({ statement })),
identifiers: vi.fn(async () => identifiers),
},
}));
const showHint = vi.fn();
@@ -59,11 +61,13 @@ const executeAction = vi.fn();
vi.mock("@/platform/actions", () => ({ executeAction }));
let answer = "cancel";
const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false }));
vi.mock("@/workbench/messages", () => ({ ask }));
let confirmAnswer: { checked: boolean } | null = { checked: false };
const confirmChecked = vi.fn(async (_opts: { title: string; message: string; checkbox?: string; checked?: boolean }) => confirmAnswer);
vi.mock("@/workbench/messages", () => ({ ask, confirmChecked }));
const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage } =
const { deletionRange, localDeclaration, outlineItemAt, safeDelete, fileUsages, usagesMessage, textOccurrences } =
await import("./safeDelete");
const { closePopup } = await import("@/editor/lsp/popupStore");
const { closePopup, useLspPopup } = await import("@/editor/lsp/popupStore");
const { lspDoc } = await import("@/editor/lsp/lsp");
// --- helpers ----------------------------------------------------------------
@@ -112,6 +116,8 @@ beforeEach(() => {
references = ok([]);
locals = { status: "noIdentifier" };
statement = null;
identifiers = [];
confirmAnswer = { checked: false };
});
afterEach(() => {
@@ -192,6 +198,16 @@ describe("localDeclaration", () => {
});
});
describe("textOccurrences", () => {
it("finds whole-word mentions that are not identifier tokens, outside the declaration", () => {
const text = 'foo(); // foo and food\nlet s = "foo";\nfn foo() {}';
const ids = [0, text.lastIndexOf("foo")].map((from) => ({ from, to: from + 3 }));
const decl = text.indexOf("fn foo");
const hits = textOccurrences(text, "foo", ids, { from: decl, to: text.length });
expect(hits.map((h) => text.slice(h.from - 3, h.to))).toEqual(["// foo", '= "foo']);
});
});
describe("usagesMessage", () => {
it("says how many usages and where", () => {
const loc = (path: string): Location => ({ path, range: rangeOf("x", 0, 1) });
@@ -245,6 +261,37 @@ describe("safeDelete in the editor", () => {
expect((await run("delete")).state.doc.toString()).toBe("fn unused() {\n unused();\n}\n\nfn main() {}\n");
});
it("confirms first ('Delete function …?') and searches nothing when cancelled", async () => {
const v = editor(text, "unused() {");
outline = ok([item(text, "unused", "fn unused() {\n unused();\n}")]);
confirmAnswer = null;
await safeDelete(v);
expect(confirmChecked).toHaveBeenCalledTimes(1);
const opts = confirmChecked.mock.calls[0]![0];
expect(opts.title).toBe("Safe Delete");
expect(opts.message).toBe("Delete function 'unused'?");
expect(opts.checkbox).toBe("Search in comments and strings");
expect(lspReferences).not.toHaveBeenCalled();
expect(v.state.doc.toString()).toBe(text);
});
it("with 'Search in comments and strings' previews mentions before deleting", async () => {
const src = "// calls unused\nfn unused() {}\n\nfn main() {}\n";
const v = editor(src, "unused() {");
outline = ok([item(src, "unused", "fn unused() {}")]);
const decl = src.indexOf("unused() {");
identifiers = [{ range: rangeOf(src, decl, decl + 6), qualified: false }];
confirmAnswer = { checked: true };
await safeDelete(v);
const popup = useLspPopup.getState().popup;
expect(popup?.kind).toBe("preview");
if (popup?.kind !== "preview") return;
expect(popup.occurrences).toEqual([{ line: 0, text: "// calls unused", from: 9, to: 15 }]);
expect(v.state.doc.toString()).toBe(src);
popup.onConfirm();
expect(v.state.doc.toString()).not.toContain("fn unused");
});
it("refuses a declaration in another file instead of doing something else", async () => {
const v = editor(text, "main");
goto = ok([{ path: "/p/other.rs", range: rangeOf(text, 0, 1) }]);
+105 -10
Переглянути файл
@@ -2,7 +2,8 @@
* Safe Delete (⌘⌦): resolves the declaration at the caret (LSP definition +
* document symbols; the tree-sitter structure service for local declarations
* only), searches its usages (LSP references, the declaration's own body
* excluded), then deletes it — with attached doc comments / annotations and
* excluded) once the user confirmed ("Delete 'x'?", optionally searching
* comments and strings too), then deletes it — with attached doc comments / annotations and
* the trailing blank line — as one undo step, or, when it is still used,
* asks like IDEA: "Usages Detected" → View Usages / Delete Anyway / Cancel.
*
@@ -20,7 +21,8 @@ import { structure } from "@/editor/structure";
import { docOf, isUnavailable, lspRequest } from "@/editor/lsp/lsp";
import { comparePos, offsetToPos, rangeContains, rangeToOffsets } from "@/editor/lsp/positions";
import { showHint } from "@/editor/lsp/hint";
import { ask } from "@/workbench/messages";
import { ask, confirmChecked } from "@/workbench/messages";
import { openPopup, type PreviewOccurrence } from "@/editor/lsp/popupStore";
import { cannotPerform } from "./catalog";
import { cancelled, languageName } from "./query";
@@ -333,6 +335,77 @@ function applyDeletion(view: EditorView, docBefore: Text, target: SafeDeleteTarg
}
let running: EditorView | null = null;
/** "Search in comments and strings", remembered like IDEA does (on by default). */
let searchInComments = true;
/**
* Mentions of the name in this file's comments and strings: whole-word text
* matches that are not identifier tokens, outside the declaration itself.
*/
export function textOccurrences(
text: string,
name: string,
identifiers: readonly { from: number; to: number }[],
exclude: { from: number; to: number },
): { from: number; to: number }[] {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(?<![\\p{L}\\p{N}_$])${escaped}(?![\\p{L}\\p{N}_$])`, "gu");
const ids = new Set(identifiers.map((o) => o.from));
const out: { from: number; to: number }[] = [];
for (let m = re.exec(text); m; m = re.exec(text)) {
const from = m.index;
if (ids.has(from) || (from >= exclude.from && from < exclude.to)) continue;
out.push({ from, to: from + name.length });
}
return out;
}
async function commentAndStringOccurrences(
ctx: EditorDocContext,
doc: Text,
target: SafeDeleteTarget,
): Promise<{ from: number; to: number }[]> {
let identifiers;
try {
identifiers = await structure.identifiers(ctx.doc, target.name);
} catch (e) {
if (isCancelled(e)) throw e;
// Without a syntax tree comments can't be told from code.
return [];
}
const ids = identifiers.map((o) => rangeToOffsets(doc, o.range));
return textOccurrences(doc.toString(), target.name, ids, rangeToOffsets(doc, target.range));
}
/** No code usages, but mentions in comments / strings: show them before deleting (IDEA's preview). */
function previewTextOccurrences(
ctx: EditorDocContext,
doc: Text,
target: SafeDeleteTarget,
hits: readonly { from: number; to: number }[],
apply: () => void,
) {
const occurrences: PreviewOccurrence[] = hits.map((h) => {
const line = doc.lineAt(h.from);
return { line: line.number - 1, text: line.text, from: h.from - line.from, to: Math.min(h.to, line.to) - line.from };
});
openPopup({
kind: "preview",
title: `Safe Delete ${target.kind.toLowerCase()} '${target.name}'`,
summary: {
files: 1,
occurrences: occurrences.length,
hasFileOperations: false,
entries: [{ path: ctx.path, edits: occurrences.length }],
},
warnings: [
`'${target.name}' is mentioned in comments or strings. The declaration will be deleted; these mentions stay.`,
],
occurrences,
confirmLabel: "Delete",
onConfirm: apply,
});
}
export async function safeDelete(view: EditorView): Promise<void> {
const ctx = docOf(view);
@@ -353,22 +426,44 @@ export async function safeDelete(view: EditorView): Promise<void> {
return;
}
const { target } = resolved;
const [usages, comments] = await Promise.all([findUsages(ctx, target), commentSyntax(ctx.language)]);
if (view.state.doc !== docBefore || !view.state.selection.eq(selBefore)) return;
const comments = await commentSyntax(ctx.language);
// Validate before asking: a declaration that can't be removed by lines is refused up front.
const { from, to } = rangeToOffsets(docBefore, target.range);
if (!deletionRange(docBefore, from, to, comments)) {
showHint(view, cannotPerform(`'${target.name}' shares its line with other code`));
return;
}
if (!usages.length) {
applyDeletion(view, docBefore, target, comments);
// IDEA always confirms first; usages are searched after OK.
const confirmed = await confirmChecked({
title: "Safe Delete",
message: `Delete ${target.kind.toLowerCase()} '${target.name}'?`,
ok: "OK",
checkbox: "Search in comments and strings",
checked: searchInComments,
});
if (!confirmed) {
view.focus();
return;
}
const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages);
if (answer === "view") viewUsages(target.name, usages);
else if (answer === "delete") applyDeletion(view, docBefore, target, comments);
else view.focus();
searchInComments = confirmed.checked;
if (view.state.doc !== docBefore || !view.state.selection.eq(selBefore)) return;
const [usages, textHits] = await Promise.all([
findUsages(ctx, target),
confirmed.checked ? commentAndStringOccurrences(ctx, docBefore, target) : Promise.resolve([]),
]);
if (view.state.doc !== docBefore) return;
if (usages.length) {
const answer = await askUsagesDetected(`${target.kind} '${target.name}'`, usages);
if (answer === "view") viewUsages(target.name, usages);
else if (answer === "delete") applyDeletion(view, docBefore, target, comments);
else view.focus();
return;
}
if (textHits.length) {
previewTextOccurrences(ctx, docBefore, target, textHits, () => applyDeletion(view, docBefore, target, comments));
return;
}
applyDeletion(view, docBefore, target, comments);
} catch (e) {
if (isCancelled(e)) return;
showHint(view, isUnavailable(e) ? cannotPerform("No language server is available") : cannotPerform(errorText(e)));
+57
Переглянути файл
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { CodeAction } from "@/bindings";
import { introducedName, occurrenceVariants } from "./serverIntroduce";
const spansText = (text: string, spans: { from: number; to: number }[]) => spans.map((s) => text.slice(s.from, s.to));
describe("introducedName", () => {
it("finds the server's variable: declaration and replaced occurrence", () => {
const before = "function f() {\n return price * 2 + 1;\n}\n";
const after = "function f() {\n const newLocal = price * 2;\n return newLocal + 1;\n}\n";
const found = introducedName(before, after)!;
expect(found.name).toBe("newLocal");
expect(found.spans).toHaveLength(2);
expect(spansText(after, found.spans)).toEqual(["newLocal", "newLocal"]);
});
it("ignores names that were already there and keywords / types written once", () => {
const before = "void f() {\n g(a.b(), a.b());\n}\n";
const after = "void f() {\n String b = a.b();\n g(b, b);\n}\n";
const found = introducedName(before, after)!;
expect(found.name).toBe("b");
// `b` already existed in `a.b()`; only the three new tokens count.
expect(found.spans.map((s) => after.slice(s.from - 1, s.to + 1))).toEqual([" b ", "(b,", " b)"]);
});
it("handles an extracted function placed after its call", () => {
const before = "fn main() {\n let x = 1 + 2;\n}\n";
const after = "fn main() {\n let x = fun_name();\n}\n\nfn fun_name() -> i32 {\n 1 + 2\n}\n";
expect(introducedName(before, after)?.name).toBe("fun_name");
});
it("is null without a new name used twice", () => {
expect(introducedName("a + b", "(a + b)")).toBeNull();
expect(introducedName("x", "x")).toBeNull();
});
});
describe("occurrenceVariants", () => {
const action = (title: string): CodeAction => ({
title,
kind: "refactor.extract.variable",
isPreferred: false,
disabledReason: null,
edit: null,
command: null,
data: null,
server: 1,
});
it("pairs the single and the all-occurrences action", () => {
const one = action("Extract to local variable");
const all = action("Extract to local variable (replace all occurrences)");
expect(occurrenceVariants([one, all])).toEqual({ one, all });
expect(occurrenceVariants([one])).toBeNull();
expect(occurrenceVariants([one, action("Extract to field"), all])).toBeNull();
});
});
+327
Переглянути файл
@@ -0,0 +1,327 @@
/**
* Introduce Variable / Constant / Field / Parameter and Extract Method through
* the language server, with IDEA's interaction around the server's edit:
* - with only a caret, the expression is chosen first (each candidate is
* highlighted while the chooser moves) and selected before the server is
* asked, so it extracts exactly that expression;
* - when the server offers both a "this occurrence" and an "all
* occurrences" variant, IDEA's "Multiple occurrences found" chooser picks;
* - after the edit the introduced name is renamed in place, with name
* suggestions from the expression (↑ / ↓).
*/
import { EditorSelection } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import type { CodeAction, SyntaxNode } from "@/bindings";
import { isCancelled } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { whenSynced } from "@/editor/api";
import { structure, type ExpressionAt } from "@/editor/structure";
import { docOf } from "@/editor/lsp/lsp";
import { offsetsToRange, rangeToOffsets } from "@/editor/lsp/positions";
import { showHint } from "@/editor/lsp/hint";
import { caretAnchor, openPopup } from "@/editor/lsp/popupStore";
import { applyCodeAction } from "@/editor/lsp/intentions";
import { putSuggestion, startInplaceRename, type Span } from "@/editor/lsp/inplaceRename";
import { clearTemporaryHighlight, highlightRanges } from "@/editor/lsp/tempHighlight";
import type { RefactoringId } from "./catalog";
import { expressionCandidates, expressionPreview } from "./introduce";
import { findTextOccurrences } from "./occurrences";
import { isIdentifier, isKeyword, suggestNames, uniqueNames } from "./names";
/** Refactorings that take an expression (chosen first when nothing is selected). */
export const EXPRESSION_REFACTORINGS: ReadonlySet<RefactoringId> = new Set([
"extractVariable",
"extractConstant",
"extractField",
"extractParameter",
]);
/** Refactorings whose result is a new name renamed in place afterwards. */
const NAMING_REFACTORINGS: ReadonlySet<RefactoringId> = new Set([...EXPRESSION_REFACTORINGS, "extractMethod"]);
export function introducesName(id: RefactoringId | null): boolean {
return !!id && NAMING_REFACTORINGS.has(id);
}
// ---------------------------------------------------------------------------
// Expression chooser
/**
* With an empty selection: selects the expression at the caret (asking which
* one when there are several) and then runs `then`. False when there is no
* expression to choose (the caller goes on as before).
*/
export async function chooseExpressionFirst(view: EditorView, then: () => unknown): Promise<boolean> {
const ctx = docOf(view);
const sel = view.state.selection.main;
if (!ctx || !sel.empty) return false;
const support = await structure.support(ctx.language).catch(() => null);
if (!support?.tree) return false;
await whenSynced(view);
const doc = view.state.doc;
let at: ExpressionAt;
try {
at = await structure.expressionAt(ctx.doc, offsetsToRange(doc, sel.from, sel.to));
} catch (e) {
if (isCancelled(e)) return true;
return false;
}
if (view.state.doc !== doc || !view.state.selection.main.eq(sel)) return true;
const candidates = expressionCandidates(doc, at);
if (!candidates.length) return false;
const select = (node: SyntaxNode) => {
if (view.state.doc !== doc) {
showHint(view, "The document changed; invoke the refactoring again");
return;
}
const { from, to } = rangeToOffsets(doc, node.range);
view.dispatch({ selection: EditorSelection.range(from, to), scrollIntoView: true });
view.focus();
void then();
};
if (candidates.length === 1) {
select(candidates[0]!);
return true;
}
openPopup({
kind: "list",
anchor: caretAnchor(view, sel.head),
title: "Expressions",
items: candidates.map((c, i) => ({
id: `${i}`,
label: expressionPreview(doc.sliceString(rangeToOffsets(doc, c.range).from, rangeToOffsets(doc, c.range).to)),
onActive: () => highlightRanges(view, [rangeToOffsets(doc, c.range)]),
run: () => select(c),
})),
onClose: () => clearTemporaryHighlight(view),
});
return true;
}
// ---------------------------------------------------------------------------
// Occurrences chooser
const ALL_OCCURRENCES = /\b(all|every) (the )?occurrences?\b|\breplace all\b/i;
/** The single-occurrence / all-occurrences pair among a refactoring's actions, if the server offers both. */
export function occurrenceVariants(actions: readonly CodeAction[]): { one: CodeAction; all: CodeAction } | null {
const all = actions.filter((a) => ALL_OCCURRENCES.test(a.title));
const one = actions.filter((a) => !ALL_OCCURRENCES.test(a.title));
return all.length === 1 && one.length === 1 ? { one: one[0]!, all: all[0]! } : null;
}
/**
* IDEA's "Multiple occurrences found" for a server that offers both variants;
* false when it does not (the caller applies / lists the actions as before).
*/
export function chooseOccurrences(view: EditorView, actions: readonly CodeAction[], id: RefactoringId): boolean {
const variants = occurrenceVariants(actions);
if (!variants) return false;
const state = view.state;
const sel = state.selection.main;
const text = state.sliceDoc(sel.from, sel.to);
const occurrences = text.trim() ? findTextOccurrences(state.doc.toString(), 0, state.doc.length, text, 200) : [];
const n = occurrences.length;
openPopup({
kind: "list",
anchor: caretAnchor(view, sel.head),
title: "Multiple occurrences found",
items: [
{
id: "one",
label: "Replace this occurrence only",
onActive: () => highlightRanges(view, [{ from: sel.from, to: sel.to }]),
run: () => applyIntroduceAction(view, variants.one, id),
},
{
id: "all",
label: n > 1 ? `Replace all ${n} occurrences` : "Replace all occurrences",
onActive: () => highlightRanges(view, n ? occurrences : [{ from: sel.from, to: sel.to }]),
run: () => applyIntroduceAction(view, variants.all, id),
},
],
onClose: () => clearTemporaryHighlight(view),
});
return true;
}
// ---------------------------------------------------------------------------
// The introduced name
const WORD = /[\p{L}\p{N}_$]/u;
const TOKEN = /[\p{L}_$][\p{L}\p{N}_$]*|\S/gu;
interface Token {
text: string;
from: number;
}
function tokens(text: string, offset: number): Token[] {
return [...text.matchAll(TOKEN)].map((m) => ({ text: m[0], from: offset + m.index! }));
}
/** Tokens of `after` that are not part of the longest common token subsequence with `before`. */
function insertedTokens(before: Token[], after: Token[]): Token[] {
const n = before.length;
const m = after.length;
if (n * m > 4_000_000) return after;
const lcs = new Uint32Array((n + 1) * (m + 1));
const at = (i: number, j: number) => i * (m + 1) + j;
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--)
lcs[at(i, j)] =
before[i]!.text === after[j]!.text ? lcs[at(i + 1, j + 1)]! + 1 : Math.max(lcs[at(i + 1, j)]!, lcs[at(i, j + 1)]!);
const out: Token[] = [];
let i = 0;
let j = 0;
while (j < m) {
if (i < n && before[i]!.text === after[j]!.text) {
i++;
j++;
} else if (i < n && lcs[at(i + 1, j)]! >= lcs[at(i, j + 1)]!) i++;
else out.push(after[j++]!);
}
return out;
}
/** Names servers give new declarations before the user names them. */
const SERVER_DEFAULT = /^(new[A-Z_]\w*|newLocal\w*|var_name\d*|fun_name\d*|extracted\w*|new_\w+|value\d*)$/;
/**
* The name a refactoring introduced, and every place the edit wrote it, from
* the text before and after (servers may apply the edit through a command,
* so the edit itself is not always known). Null when no new name appears at
* least twice (declaration and use).
*/
export function introducedName(before: string, after: string): { name: string; spans: Span[] } | null {
let start = 0;
const max = Math.min(before.length, after.length);
while (start < max && before[start] === after[start]) start++;
let end = 0;
while (end < max - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++;
// Whole words at both ends of the changed region.
while (start > 0 && WORD.test(after[start - 1]!)) start--;
let afterTo = after.length - end;
let beforeTo = before.length - end;
while (afterTo < after.length && WORD.test(after[afterTo]!)) {
afterTo++;
beforeTo++;
}
const inserted = insertedTokens(tokens(before.slice(start, beforeTo), start), tokens(after.slice(start, afterTo), start));
const byName = new Map<string, Span[]>();
for (const t of inserted) {
if (!WORD.test(t.text[0]!) || !isIdentifier(t.text) || isKeyword(t.text)) continue;
let list = byName.get(t.text);
if (!list) byName.set(t.text, (list = []));
list.push({ from: t.from, to: t.from + t.text.length });
}
let best: { name: string; spans: Span[] } | null = null;
const score = (name: string, spans: Span[]) => (SERVER_DEFAULT.test(name) ? 1000 : 0) + spans.length;
for (const [name, spans] of byName) {
if (spans.length < 2) continue;
if (!best || score(name, spans) > score(best.name, best.spans)) best = { name, spans };
}
if (!best) return null;
// The server picked a name free in scope, so every plain (non-member) use in the changed
// region is the new one; the token alignment alone may pair one with an old member name.
const name = best.name;
const spans: Span[] = [];
for (const t of tokens(after.slice(start, afterTo), start)) {
if (t.text !== name) continue;
const lead = after.slice(Math.max(0, t.from - 2), t.from);
if (/(\.|->|::)\s*$/.test(lead) && !best.spans.some((s) => s.from === t.from)) continue;
spans.push({ from: t.from, to: t.from + name.length });
}
return { name, spans };
}
async function nameSuggestions(view: EditorView, id: RefactoringId, expression: { kind: string; text: string } | null) {
const ctx = docOf(view);
if (!ctx || !expression || !EXPRESSION_REFACTORINGS.has(id)) return [];
const raw = suggestNames({ ...expression, language: ctx.language, constant: id === "extractConstant" });
if (!raw.length) return [];
return uniqueNames(raw, async (name) => {
const found = await structure.identifiers(ctx.doc, name);
return found.some((o) => !o.qualified);
});
}
async function expressionInfo(view: EditorView): Promise<{ kind: string; text: string } | null> {
const ctx = docOf(view);
const sel = view.state.selection.main;
if (!ctx || sel.empty) return null;
const text = view.state.sliceDoc(sel.from, sel.to);
try {
const at = await structure.expressionAt(ctx.doc, offsetsToRange(view.state.doc, sel.from, sel.to));
return { kind: at.exact && at.expression ? at.expression.kind : "", text };
} catch {
return { kind: "", text };
}
}
async function docChange(view: EditorView, before: EditorView["state"]["doc"], ms = 1500): Promise<boolean> {
for (let waited = 0; waited < ms && view.state.doc === before; waited += 20) await new Promise((r) => setTimeout(r, 20));
return view.state.doc !== before;
}
/**
* Applies a server extract / introduce action, then renames the introduced
* name in place (as IDEA does), with name suggestions for an expression.
*/
export async function applyIntroduceAction(view: EditorView, action: CodeAction, id: RefactoringId): Promise<void> {
const expression = await expressionInfo(view);
const before = view.state.doc;
await applyCodeAction(view, action);
if (!(await docChange(view, before))) return;
// The server's edit may arrive in more than one transaction.
await new Promise((r) => setTimeout(r, 30));
const doc = view.state.doc;
const found = introducedName(before.toString(), doc.toString());
if (!found) return;
const suggestions = await nameSuggestions(view, id, expression).catch(() => [] as string[]);
if (view.state.doc !== doc) return;
const caret = view.state.selection.main.head;
// The occurrence at the caret (where the expression was), else the declaration.
const primary = found.spans.find((s) => s.from <= caret && caret <= s.to) ?? found.spans[0]!;
const others = found.spans.filter((s) => s !== primary);
const keys = shortcutLabel("refactor.rename");
const names = suggestions.includes(found.name) ? suggestions : [...suggestions, found.name];
view.focus();
startInplaceRename(view, {
primary,
others,
suggestions: names.length > 1 ? names : undefined,
hint: keys ? `Press ${keys} to show dialog with more options` : "↵ to finish, Esc to keep the name",
commit: (name) => renameIntroduced(view, [primary, ...others], found.name, name),
dialog: (typed) =>
openPopup({
kind: "rename",
anchor: caretAnchor(view, primary.from),
title: `Rename '${found.name}'`,
initial: typed,
suggestions: names,
onSubmit: (name) => renameIntroduced(view, [primary, ...others], found.name, name),
}),
});
// As in IDEA the best suggestion replaces the server's placeholder name right away.
if (suggestions[0] && suggestions[0] !== found.name) putSuggestion(view, suggestions[0]);
}
/** Writes `name` over the introduced name's occurrences (the session restored `current` there). */
export function renameIntroduced(view: EditorView, spans: readonly Span[], current: string, name: string) {
if (!name || name === current) return;
if (!isIdentifier(name) || isKeyword(name)) {
showHint(view, `'${name}' is not a valid name; kept '${current}'`, { tone: "warning" });
return;
}
const state = view.state;
if (spans.some((s) => s.to > state.doc.length || state.sliceDoc(s.from, s.to) !== current)) {
showHint(view, "The document changed; the name was not applied");
return;
}
view.dispatch({
changes: spans.map((s) => ({ ...s, insert: name })),
userEvent: "refactor.rename",
scrollIntoView: true,
});
}