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

+18
Переглянути файл
@@ -445,3 +445,21 @@ export function registerLanguageMode(desc: LanguageDescription): () => void {
export function _languageMode(name: string): LanguageDescription | undefined {
return languageModes.get(name);
}
// --- read-only code previews ------------------------------------------------------
let codePreviewProvider: ((path: string) => Promise<Extension>) | null = null;
/**
* Extensions for a read-only CodeMirror view showing `path` like an editor tab
* (editor font and colours, line numbers, syntax highlighting): previews
* outside editor tabs such as Find in Files. Empty before editor/core starts.
*/
export function codePreviewExtensions(path: string): Promise<Extension> {
return codePreviewProvider ? codePreviewProvider(path) : Promise.resolve([]);
}
/** @internal editor/core only. */
export function _provideCodePreview(provider: ((path: string) => Promise<Extension>) | null) {
codePreviewProvider = provider;
}
+30 -8
Переглянути файл
@@ -1,21 +1,22 @@
/** Editor actions (ids from keymaps/intellij-macos.ts). */
import { EditorSelection, type EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { addCursorAbove, addCursorBelow, deleteLine, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
import { foldAll, syntaxTree, toggleFold, unfoldAll } from "@codemirror/language";
import { addCursorAbove, addCursorBelow, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
import { syntaxTree, toggleFold } from "@codemirror/language";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
import { executeAction, registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { editorController, useGotoLine, useTabs } from "./controller";
import {
addCaretNextOccurrence,
addCaretsToLineEnds,
commentLine,
completeStatement,
deleteLine,
duplicateLineOrSelection,
joinLines,
moveStatementDown,
moveStatementUp,
nextOccurrence,
selectAllOccurrences,
startNewLine,
startNewLineBefore,
@@ -25,9 +26,10 @@ import {
import { emptyStack, extendSelection, shrinkSelection, type SelectionStack, type SimpleRange } from "./model/selectionStack";
import type { EditorSlot } from "./model/slot";
import { findAgain, openFindBar } from "./view/FindBar";
import { foldAtCaret, foldSelection, unfoldAtCaret } from "./cm/folding";
import { collapseAll, collapseRecursively, expandAll, expandRecursively, foldAtCaret, foldSelection, unfoldAtCaret } from "./cm/folding";
import { reverseLines, sortLines } from "./cm/lines";
import { cursorCodeBlockEnd, cursorCodeBlockStart, scrollToCenter, splitLine } from "./cm/caretCommands";
import { cursorCodeBlockEnd, cursorCodeBlockStart, cursorMatchBrace, scrollToCenter, splitLine } from "./cm/caretCommands";
import { isOverwrite, toggleOverwrite } from "./cm/overwrite";
import { isColumnMode, toggleColumnMode } from "./cm/columnMode";
import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras";
import { cyclicExpandWord } from "./wordCompletion";
@@ -289,6 +291,11 @@ function copyReference(view: EditorView, slot: EditorSlot) {
void executeAction("editor.showHint", { text: `Copied reference: ${reference}` });
}
/** ⌃G; when nothing more is found, a hint first and the search from the top on the next press (IDEA). */
function addNextOccurrence(view: EditorView) {
if (nextOccurrence(view) === "notFound") void executeAction("editor.showHint", { text: "Not found, press again to search from the top" });
}
// ---------------------------------------------------------------------------------
export function registerEditorActions(): () => void {
@@ -341,9 +348,11 @@ export function registerEditorActions(): () => void {
toggled: () => activeSlot()?.model.softWrapOn() ?? false,
run: () => activeSlot()?.model.toggleSoftWrap(),
},
text("code.foldAll", "Collapse All", (v) => foldAll(v), { category: "Folding", icon: "collapse-all" }),
text("code.unfoldAll", "Expand All", (v) => unfoldAll(v), { category: "Folding", icon: "expand-all" }),
text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addCaretNextOccurrence(v), { category: "Edit" }),
text("code.foldAll", "Collapse All", (v) => collapseAll(v), { category: "Folding", icon: "collapse-all" }),
text("code.unfoldAll", "Expand All", (v) => expandAll(v), { category: "Folding", icon: "expand-all" }),
text("code.collapseRecursively", "Collapse Recursively", (v) => collapseRecursively(v), { category: "Folding", keywords: ["fold"] }),
text("code.expandRecursively", "Expand Recursively", (v) => expandRecursively(v), { category: "Folding", keywords: ["unfold"] }),
text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addNextOccurrence(v), { category: "Edit" }),
text("code.selectAllOccurrences", "Select All Occurrences", (v) => selectAllOccurrences(v), { category: "Edit" }),
text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)),
text("code.startNewLine", "Start New Line", (v) => startNewLine(v)),
@@ -354,6 +363,19 @@ export function registerEditorActions(): () => void {
text("editor.codeBlockEnd", "Move Caret to Code Block End", (v) => cursorCodeBlockEnd(v), { category: "Editor Actions" }),
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => addCursorAbove(v), { category: "Editor Actions" }),
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => addCursorBelow(v), { category: "Editor Actions" }),
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
category: "Editor Actions",
// ⌃M is Show Commit Message History in the commit window.
keyWhen: "editorTextFocus",
}),
text("editor.toggleInsertState", "Toggle Insert/Overwrite", (v) => toggleOverwrite(v), {
category: "Editor Actions",
keywords: ["overwrite mode", "overtype"],
toggled: () => {
const view = activeSlot()?.view;
return view ? isOverwrite(view.state) : false;
},
}),
text("code.joinLines", "Join Lines", (v) => joinLines(v)),
text("code.toggleCase", "Toggle Case", (v) => toggleCase(v), { keywords: ["upper", "lower"] }),
text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), { category: "Edit" }),
+40 -2
Переглянути файл
@@ -1,8 +1,8 @@
/** IDEA caret commands that CodeMirror has no equivalent for: Split Line, Scroll to Center, code block navigation. */
/** IDEA caret commands that CodeMirror has no equivalent for: Split Line, Scroll to Center, code block and brace navigation. */
import { EditorSelection, type EditorState, type Transaction } from "@codemirror/state";
import { EditorView, type Command } from "@codemirror/view";
import { insertNewlineAndIndent } from "@codemirror/commands";
import { syntaxTree } from "@codemirror/language";
import { matchBrackets, syntaxTree } from "@codemirror/language";
/** Split Line: break the line at each caret, indented, leaving the caret before the break. */
export const splitLine: Command = (view) => {
@@ -90,3 +90,41 @@ function moveToBlock(find: (state: EditorState, pos: number) => number | null):
export const cursorCodeBlockStart = moveToBlock(codeBlockStart);
export const cursorCodeBlockEnd = moveToBlock(codeBlockEnd);
/**
* Where Go to Matching Brace (⌃M) goes from `pos`: from a brace next to the
* caret to its match; elsewhere to the unclosed opening brace before the caret.
*/
export function matchBraceTarget(state: EditorState, pos: number): number | null {
const found =
matchBrackets(state, pos, -1) ||
matchBrackets(state, pos, 1) ||
(pos > 0 && matchBrackets(state, pos - 1, 1)) ||
(pos < state.doc.length && matchBrackets(state, pos + 1, -1));
if (found && found.end) return found.start.from === pos ? found.end.to : found.end.from;
const tree = syntaxTree(state);
let depth = 0;
for (let p = pos - 1; p >= 0; p--) {
const ch = state.sliceDoc(p, p + 1);
const open = "([{".includes(ch);
if (!open && !")]}".includes(ch)) continue;
if (tree.length && NOT_CODE.test(tree.resolveInner(p, 1).name)) continue;
if (!open) depth++;
else if (depth-- === 0) return p;
}
return null;
}
export const cursorMatchBrace: Command = (view) => {
const { state } = view;
let moved = false;
const ranges = state.selection.ranges.map((r) => {
const target = matchBraceTarget(state, r.head);
if (target == null) return r;
moved = true;
return EditorSelection.cursor(target);
});
if (!moved) return false;
view.dispatch({ selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "select.navigate" });
return true;
};
+19 -5
Переглянути файл
@@ -2,9 +2,10 @@
* Edit › Column Selection Mode (⇧⌘8): while on, a plain mouse drag and
* ⇧+arrows make rectangular (block) selections, one range per line.
*/
import { EditorSelection, Prec, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { EditorSelection, Prec, StateEffect, StateField, countColumn, findColumn, type EditorState, type Extension } from "@codemirror/state";
import { EditorView, keymap, rectangularSelection } from "@codemirror/view";
/** A block between two visual columns (tabs expanded) of two lines. */
interface Block {
anchorLine: number;
anchorCol: number;
@@ -43,18 +44,30 @@ export function toggleColumnMode(view: EditorView): boolean {
return true;
}
/** One range per line from the anchor column to the head column (clamped to each line). */
/** One range per line from the anchor column to the head column (visual columns, clamped to each line). */
export function blockSelection(state: EditorState, b: Block): EditorSelection {
const ranges = [];
const step = b.headLine >= b.anchorLine ? 1 : -1;
const at = (text: string, col: number) => findColumn(text, col, state.tabSize);
for (let n = b.anchorLine; ; n += step) {
const line = state.doc.line(n);
ranges.push(EditorSelection.range(line.from + Math.min(b.anchorCol, line.length), line.from + Math.min(b.headCol, line.length)));
ranges.push(EditorSelection.range(line.from + at(line.text, b.anchorCol), line.from + at(line.text, b.headCol)));
if (n === b.headLine) break;
}
return EditorSelection.create(ranges, ranges.length - 1);
}
/** The visual column one character left / right of `col` (a tab is one step; past the line end, one column). */
export function stepColumn(text: string, col: number, d: number, tabSize: number): number {
if (!d) return col;
const width = countColumn(text, tabSize);
if (d > 0 ? col >= width : col > width) return Math.max(0, col + d);
const pos = findColumn(text, col, tabSize);
const at = countColumn(text, tabSize, pos);
if (d > 0) return at > col ? at : countColumn(text, tabSize, pos + 1);
return pos === 0 ? 0 : countColumn(text, tabSize, pos - 1);
}
/** ⇧+arrow in column mode: moves the block's head by a line / column. */
export function extendBlock(view: EditorView, dLine: number, dCol: number): boolean {
const state = view.state;
@@ -65,10 +78,11 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool
const main = state.selection.main;
const a = state.doc.lineAt(main.anchor);
const h = state.doc.lineAt(main.head);
b = { anchorLine: a.number, anchorCol: main.anchor - a.from, headLine: h.number, headCol: main.head - h.from };
const col = (text: string, offset: number) => countColumn(text, state.tabSize, offset);
b = { anchorLine: a.number, anchorCol: col(a.text, main.anchor - a.from), headLine: h.number, headCol: col(h.text, main.head - h.from) };
}
const headLine = Math.max(1, Math.min(state.doc.lines, b.headLine + dLine));
const next = { ...b, headLine, headCol: Math.max(0, b.headCol + dCol) };
const next = { ...b, headLine, headCol: stepColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize) };
view.dispatch({
selection: blockSelection(state, next),
effects: setBlock.of(next),
+4
Переглянути файл
@@ -151,6 +151,10 @@ describe("multiple carets", () => {
expect(show(v)).toBe("[a] [a] a");
expect(v.state.selection.mainIndex).toBe(1);
unselectOccurrence(v);
// A single selection collapses to its caret.
expect(show(v)).toBe("[a] a a");
expect(unselectOccurrence(v)).toBe(true);
expect(show(v)).toBe("a| a a");
expect(unselectOccurrence(v)).toBe(false);
});
+388 -69
Переглянути файл
@@ -1,21 +1,34 @@
/**
* IDEA editing commands on top of @codemirror/commands.
*/
import { EditorSelection, type ChangeSpec, type EditorState, type SelectionRange, type Transaction, type TransactionSpec } from "@codemirror/state";
import {
EditorSelection,
StateEffect,
StateField,
countColumn,
type ChangeSpec,
type EditorState,
type SelectionRange,
type Transaction,
type TransactionSpec,
} from "@codemirror/state";
import { EditorView, type Command } from "@codemirror/view";
import { syntaxTree, getIndentUnit, indentString, getIndentation } from "@codemirror/language";
import { foldedRanges, forceParsing, syntaxTree, getIndentUnit, indentString, getIndentation } from "@codemirror/language";
import {
copyLineDown,
deleteLine as deleteDocLine,
insertNewlineAndIndent,
moveLineDown,
moveLineUp,
} from "@codemirror/commands";
import { selectNextOccurrence, selectSelectionMatches } from "@codemirror/search";
import { humpLeft, humpRight } from "../model/camelHumps";
import { SearchCursor } from "@codemirror/search";
import { humpLeft, humpRight, wordStartLeft } from "../model/camelHumps";
type Node = ReturnType<ReturnType<typeof syntaxTree>["resolveInner"]>;
// --- CamelHumps -------------------------------------------------------------
function humpTarget(state: EditorState, pos: number, forward: boolean): number {
function humpTarget(state: EditorState, pos: number, forward: boolean, camel = true): number {
const line = state.doc.lineAt(pos);
const col = pos - line.from;
if (forward) {
@@ -23,7 +36,7 @@ function humpTarget(state: EditorState, pos: number, forward: boolean): number {
return line.from + humpRight(line.text, col);
}
if (col <= 0) return Math.max(0, line.from - 1);
return line.from + humpLeft(line.text, col);
return line.from + (camel ? humpLeft(line.text, col) : wordStartLeft(line.text, col));
}
function moveHump(forward: boolean, extend: boolean): Command {
@@ -46,12 +59,12 @@ export const cursorHumpRight = moveHump(true, false);
export const selectHumpLeft = moveHump(false, true);
export const selectHumpRight = moveHump(true, true);
function deleteHump(forward: boolean): Command {
function deleteHump(forward: boolean, camel = true): Command {
return (view) => {
if (view.state.readOnly) return false;
const tr = view.state.changeByRange((r) => {
if (!r.empty) return { changes: { from: r.from, to: r.to }, range: EditorSelection.cursor(r.from) };
const target = humpTarget(view.state, r.head, forward);
const target = humpTarget(view.state, r.head, forward, camel);
const from = Math.min(target, r.head);
const to = Math.max(target, r.head);
return { changes: { from, to }, range: EditorSelection.cursor(from) };
@@ -63,14 +76,65 @@ function deleteHump(forward: boolean): Command {
export const deleteHumpBackward = deleteHump(false);
export const deleteHumpForward = deleteHump(true);
/** ⌥⌫ / Ctrl+Backspace without CamelHumps: the whitespace before the caret and the word (or punctuation run) before it. */
export const deleteWordBackward = deleteHump(false, false);
// --- lines / selections -------------------------------------------------------
/**
* The whole lines `[from, to]` touches, grown to the visual lines they are
* part of: a folded region's lines go together with the line it is shown on.
*/
export function visualLineSpan(state: EditorState, from: number, to: number): { from: number; to: number } {
let a = state.doc.lineAt(from).from;
let b = state.doc.lineAt(to).to;
const folded = foldedRanges(state);
for (let changed = true; changed; ) {
changed = false;
folded.between(a, b, (f, t) => {
const na = Math.min(a, state.doc.lineAt(f).from);
const nb = Math.max(b, state.doc.lineAt(t).to);
if (na !== a || nb !== b) {
a = na;
b = nb;
changed = true;
}
});
}
return { from: a, to: b };
}
/** Visual line spans of the selection ranges, merged; `null` when no fold is involved. */
function foldedLineSpans(state: EditorState): Array<{ from: number; to: number }> | null {
const spans: Array<{ from: number; to: number }> = [];
let folds = false;
for (const r of state.selection.ranges) {
const end = r.to > r.from && state.doc.lineAt(r.to).from === r.to ? r.to - 1 : r.to;
const span = visualLineSpan(state, r.from, end);
if (span.from !== state.doc.lineAt(r.from).from || span.to !== state.doc.lineAt(end).to) folds = true;
const last = spans[spans.length - 1];
if (last && span.from <= last.to + 1) last.to = Math.max(last.to, span.to);
else spans.push(span);
}
return folds ? spans : null;
}
/** ⌘D: duplicate the line (caret moves to the copy) or the selection (copy selected). */
export const duplicateLineOrSelection: Command = (view) => {
const { state } = view;
if (state.readOnly) return false;
if (state.selection.ranges.every((r) => r.empty)) return copyLineDown(view);
if (state.selection.ranges.every((r) => r.empty)) {
const spans = foldedLineSpans(state);
if (!spans) return copyLineDown(view);
// On a folded region's line the whole folded block is duplicated.
const changes = state.changes(spans.map((s) => ({ from: s.to, insert: state.lineBreak + state.sliceDoc(s.from, s.to) })));
const ranges = state.selection.ranges.map((r) => {
const s = spans.find((sp) => sp.from <= r.head && r.head <= sp.to)!;
return EditorSelection.cursor(changes.mapPos(r.head, -1) + s.to - s.from + state.lineBreak.length);
});
view.dispatch({ changes, selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "input.duplicate" });
return true;
}
const tr = state.changeByRange((r) => {
if (r.empty) {
const line = state.doc.lineAt(r.head);
@@ -89,18 +153,139 @@ export const duplicateLineOrSelection: Command = (view) => {
return true;
};
/** ⌃G: add the next occurrence of the word / selection as a caret. */
export const addCaretNextOccurrence: Command = (view) => selectNextOccurrence(view);
/** ⌘⌫: Delete Line — on a folded region's line, every line of the folded block. */
export const deleteLine: Command = (view) => {
const { state } = view;
if (state.readOnly) return false;
const spans = foldedLineSpans(state);
if (!spans) return deleteDocLine(view);
const len = state.doc.length;
const changes = state.changes(
spans.map((s) => (s.to < len ? { from: s.from, to: s.to + 1 } : { from: Math.max(0, s.from - 1), to: s.to })),
);
const doc = changes.apply(state.doc);
const ranges = state.selection.ranges.map((r) => {
const col = r.head - state.doc.lineAt(r.head).from;
const line = doc.lineAt(changes.mapPos(r.head, -1));
return EditorSelection.cursor(line.from + Math.min(col, line.length));
});
view.dispatch({ changes, selection: EditorSelection.create(ranges, state.selection.mainIndex), scrollIntoView: true, userEvent: "delete.line" });
return true;
};
/** ⌃⌘G: select every occurrence of the word / selection. */
// --- occurrences --------------------------------------------------------------
interface OccurrenceSearch {
/** The sequence started from a bare caret: occurrences match whole words only. */
wholeWords: boolean;
/** The last search found nothing: the next one starts from the top. */
notFound: boolean;
}
const setOccurrenceSearch = StateEffect.define<OccurrenceSearch>();
/** State of a run of Add Selection for Next Occurrence; any other selection change or edit ends it. */
export const occurrenceSearch = StateField.define<OccurrenceSearch | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) if (e.is(setOccurrenceSearch)) return e.value;
return tr.docChanged || tr.selection ? null : value;
},
});
const IDENT = /[\p{L}\p{N}_$]/u;
const identAt = (state: EditorState, pos: number) => pos >= 0 && pos < state.doc.length && IDENT.test(state.sliceDoc(pos, pos + 1));
/** The identifier at or right before `pos`. */
export function identifierAt(state: EditorState, pos: number): { from: number; to: number } | null {
let from = pos;
let to = pos;
while (from > 0 && identAt(state, from - 1)) from--;
while (to < state.doc.length && identAt(state, to)) to++;
return from < to ? { from, to } : null;
}
function isWholeWord(state: EditorState, from: number, to: number): boolean {
if (identAt(state, from - 1) && identAt(state, from)) return false;
return !(identAt(state, to) && identAt(state, to - 1));
}
/** Case-sensitive matches of `text` from `from` on (whole words only when asked). */
function* matches(state: EditorState, text: string, from: number, wholeWords: boolean) {
const cursor = new SearchCursor(state.doc, text, from);
for (cursor.next(); !cursor.done; cursor.next()) {
const { from: a, to: b } = cursor.value;
if (!wholeWords || isWholeWord(state, a, b)) yield { from: a, to: b };
}
}
/** A selection of `m` with the head on the same side as in `like`. */
const rangeLike = (like: SelectionRange, m: { from: number; to: number }) =>
like.head === like.from && like.from !== like.to ? EditorSelection.range(m.to, m.from) : EditorSelection.range(m.from, m.to);
/**
* ⌃G: Add Selection for Next Occurrence. From a bare caret it selects the
* word (and the run then matches whole words); with a selection it adds the
* next match after the last added one. Nothing found reports `notFound` (the
* caller hints); the next invocation then searches from the top.
*/
export function nextOccurrence(view: EditorView): "selected" | "added" | "notFound" | "none" {
const { state } = view;
const main = state.selection.main;
if (main.empty) {
const word = identifierAt(state, main.head);
if (!word) return "none";
view.dispatch({
selection: state.selection.replaceRange(EditorSelection.range(word.from, word.to)),
effects: setOccurrenceSearch.of({ wholeWords: true, notFound: false }),
scrollIntoView: true,
userEvent: "select",
});
return "selected";
}
const run = state.field(occurrenceSearch, false) ?? null;
const wholeWords = !!run?.wholeWords;
const text = state.sliceDoc(main.from, main.to);
const hit = matches(state, text, run?.notFound ? 0 : main.to, wholeWords).next().value;
if (!hit) {
view.dispatch({ effects: setOccurrenceSearch.of({ wholeWords, notFound: true }) });
return "notFound";
}
if (state.selection.ranges.some((r) => r.from === hit.from && r.to === hit.to)) {
view.dispatch({ effects: setOccurrenceSearch.of({ wholeWords, notFound: !!run?.notFound }) });
return "none";
}
view.dispatch({
selection: state.selection.addRange(rangeLike(main, hit)),
effects: [setOccurrenceSearch.of({ wholeWords, notFound: false }), EditorView.scrollIntoView(hit.from)],
userEvent: "select",
});
return "added";
}
export const addCaretNextOccurrence: Command = (view) => nextOccurrence(view) !== "none";
/** ⌃⌘G: select every occurrence of the selection, or of the word at the caret (whole words). */
export const selectAllOccurrences: Command = (view) => {
const { state } = view;
if (state.selection.main.empty) {
const word = state.wordAt(state.selection.main.head);
let main = state.selection.main;
let wholeWords = false;
if (main.empty) {
const word = identifierAt(state, main.head);
if (!word) return false;
view.dispatch({ selection: EditorSelection.single(word.from, word.to) });
main = EditorSelection.range(word.from, word.to);
wholeWords = true;
}
return selectSelectionMatches(view);
const text = state.sliceDoc(main.from, main.to);
const ranges: SelectionRange[] = [];
let mainIndex = 0;
for (const m of matches(state, text, 0, wholeWords)) {
if (m.from === main.from) mainIndex = ranges.length;
ranges.push(rangeLike(main, m));
}
if (!ranges.length) return false;
view.dispatch({ selection: EditorSelection.create(ranges, mainIndex), userEvent: "select" });
return true;
};
// --- move statement -----------------------------------------------------------
@@ -110,65 +295,192 @@ interface LineSpan {
to: number; // line end (excl. break)
}
function wholeLines(state: EditorState, from: number, to: number): LineSpan {
return { from: state.doc.lineAt(from).from, to: state.doc.lineAt(to).to };
}
/** Nodes whose children are statements / members (plus the top node). */
const CONTAINER =
/^(Program|Script|Module|SourceFile|Chunk|Document|StyleSheet|JsonText|Object|Array|BlockMapping|BlockSequence|Suite|CompoundStatement|DeclarationList|FieldDeclarationList|StatementList)$|Block$|Body$/;
/** Function / class-like nodes: Move Statement never carries code into or out of them. */
const GUARD = /Function|Method|Class|Interface|Constructor|Enum|Struct|Trait|Impl|Lambda|Arrow|Closure|Func/;
const NOT_GUARD = /Body|Block|Call|Name|Type|Keyword|Modifier|List|Param|Signature|Invocation|Reference|Access/;
/** The statement-like syntax node starting on the caret's line, as whole lines. */
function statementAt(state: EditorState, pos: number): { span: LineSpan; node: { from: number; to: number; parent: unknown } } | null {
const line = state.doc.lineAt(pos);
const isContainer = (n: Node | null) => !n || !n.parent || CONTAINER.test(n.type.name);
const isGuard = (n: Node) => GUARD.test(n.type.name) && !NOT_GUARD.test(n.type.name);
function firstToken(state: EditorState, lineNo: number): number | null {
const line = state.doc.line(lineNo);
const indent = /^\s*/.exec(line.text)![0].length;
const start = line.from + indent;
if (start >= line.to) return null;
let node = syntaxTree(state).resolveInner(start, 1);
// Climb to the outermost node that starts at the line's first token.
while (node.parent && node.parent.from === start && node.parent.parent) node = node.parent;
if (node.from !== start) return null;
const span = wholeLines(state, node.from, node.to);
if (state.doc.lineAt(node.to).to !== span.to) return null;
return { span, node };
return indent === line.length ? null : line.from + indent;
}
function siblingStatement(state: EditorState, pos: number, up: boolean): LineSpan | null {
const cur = statementAt(state, pos);
if (!cur) return null;
const tree = syntaxTree(state);
let node = tree.resolveInner(cur.node.from, 1);
while (node.parent && node.parent.from === cur.node.from && node.parent.parent) node = node.parent;
let sib = up ? node.prevSibling : node.nextSibling;
// Skip punctuation / comments-only siblings on the same line.
while (sib && state.doc.lineAt(sib.from).number === state.doc.lineAt(node.from).number) sib = up ? sib.prevSibling : sib.nextSibling;
if (!sib) return null;
// Don't move a statement past its block's braces.
if (/^[{}()[\]]$/.test(state.sliceDoc(sib.from, sib.to))) return null;
const span = wholeLines(state, sib.from, sib.to);
if (state.doc.lineAt(sib.from).from !== span.from) return null;
return span;
/** The statement (or member, comment, block-closing brace) that starts at the first token of a line. */
function statementStartingAt(state: EditorState, lineNo: number): Node | null {
const pos = firstToken(state, lineNo);
if (pos === null) return null;
for (let n: Node | null = syntaxTree(state).resolveInner(pos, 1); n && n.parent; n = n.parent) {
if (n.from !== pos) return null;
if (isContainer(n.parent)) return n;
}
return null;
}
function swapSpans(state: EditorState, a: LineSpan, b: LineSpan, caretOffset: number, up: boolean): TransactionSpec {
// a is above b; the moved one is `up ? b : a`.
const textA = state.sliceDoc(a.from, a.to);
const textB = state.sliceDoc(b.from, b.to);
const between = state.sliceDoc(a.to, b.from);
const insert = textB + between + textA;
const changes: ChangeSpec = { from: a.from, to: b.to, insert };
const anchor = up ? a.from + caretOffset : a.from + textB.length + between.length + caretOffset;
return { changes, selection: { anchor }, scrollIntoView: true, userEvent: "move.line" };
/** Lines a moved statement stops at: blank lines, statement starts and closing braces of blocks. */
const isStopLine = (state: EditorState, lineNo: number) => firstToken(state, lineNo) === null || !!statementStartingAt(state, lineNo);
/** The statement containing the first token of the caret line. */
function statementAround(state: EditorState, pos: number): Node | null {
const start = firstToken(state, state.doc.lineAt(pos).number);
if (start === null) return null;
for (let n: Node | null = syntaxTree(state).resolveInner(start, 1); n && n.parent; n = n.parent) if (isContainer(n.parent)) return n;
return null;
}
/** The statement ending on a line (at its last token). */
function statementEndingOn(state: EditorState, lineNo: number): Node | null {
const line = state.doc.line(lineNo);
const end = line.from + line.text.trimEnd().length;
if (end === line.from) return null;
for (let n: Node | null = syntaxTree(state).resolveInner(end, -1); n && n.parent; n = n.parent) {
if (isContainer(n.parent)) return state.doc.lineAt(n.to).number === lineNo ? n : null;
}
return null;
}
/** A function / class (or a statement holding one, e.g. a call with a callback) moves as one unit. */
function holdsGuard(n: Node): boolean {
if (isGuard(n)) return true;
for (let c = n.firstChild; c; c = c.nextSibling) if (!CONTAINER.test(c.type.name) && holdsGuard(c)) return true;
return false;
}
/** Moving `cur` across lines `[s, e]` would carry it into or out of a function / class. */
function crossesGuard(state: EditorState, cur: Node, s: number, e: number): boolean {
const from = state.doc.line(s).from;
const to = state.doc.line(e).to;
let crosses = false;
syntaxTree(state).iterate({
from,
to,
enter: (n) => {
if (crosses) return false;
if (!isGuard(n.node)) return;
if (n.from <= cur.from && n.to >= cur.to) {
const gs = state.doc.lineAt(n.from).number;
const ge = state.doc.lineAt(n.to).number;
if ((gs >= s && gs <= e) || (ge >= s && ge <= e)) crosses = true;
} else if (n.from < from || n.to > to) crosses = true;
},
});
return crosses;
}
/** Comment lines directly above line `a` (a declaration's doc comment moves with it). */
function leadingCommentStart(state: EditorState, a: number): number {
while (a > 1) {
const n = statementStartingAt(state, a - 1);
if (!n || !/Comment/.test(n.type.name)) break;
a = state.doc.lineAt(n.from).number;
}
return a;
}
export type StatementMove = { spec: TransactionSpec } | "noop" | "line";
/**
* Move Statement as in IDEA: the statement around the caret (whole lines)
* moves past the next statement; into a block when the neighbour is a block
* header or closing brace, but never into or out of a function or class (then
* nothing happens). A neighbouring function / class (or a statement holding
* one) is passed as a unit. The moved lines take the indentation of their new
* place. `line` when there is no statement at the caret (move the line).
*/
export function planStatementMove(state: EditorState, up: boolean): StatementMove {
const head = state.selection.main.head;
const cur = statementAround(state, head);
if (!cur) return "line";
const doc = state.doc;
if (/^[{}]$/.test(cur.type.name)) {
for (let g: Node | null = cur.parent; g; g = g.parent)
if (isGuard(g)) {
const curLine = doc.lineAt(cur.from).number;
if (doc.lineAt(g.from).number === curLine || doc.lineAt(g.to).number === curLine) return "noop";
break;
}
}
let a = doc.lineAt(cur.from).number;
const b = doc.lineAt(cur.to).number;
if (holdsGuard(cur)) a = leadingCommentStart(state, a);
let s: number;
let e: number;
if (up) {
e = a - 1;
if (e < 1) return "noop";
const prev = statementEndingOn(state, e);
if (prev && prev.from < cur.from && holdsGuard(prev)) s = leadingCommentStart(state, doc.lineAt(prev.from).number);
else {
s = e;
while (s >= 1 && !isStopLine(state, s)) s--;
if (s < 1) return "noop";
}
} else {
s = b + 1;
if (s > doc.lines) return "noop";
const next = statementStartingAt(state, s);
if (next && next.to > cur.to && holdsGuard(next)) e = doc.lineAt(next.to).number;
else {
e = s + 1;
while (e <= doc.lines && !isStopLine(state, e)) e++;
e--;
}
}
if (crossesGuard(state, cur, s, e)) return "noop";
const moved: LineSpan = { from: doc.line(a).from, to: doc.line(b).to };
const other: LineSpan = { from: doc.line(s).from, to: doc.line(e).to };
const [first, second] = up ? [other, moved] : [moved, other];
const insert = state.sliceDoc(second.from, second.to) + state.lineBreak + state.sliceDoc(first.from, first.to);
const changes = state.changes({ from: first.from, to: second.to, insert });
const movedFrom = up ? first.from : first.from + (second.to - second.from) + state.lineBreak.length;
const after = state.update({ changes }).state;
const reindent = shiftToIndentation(after, movedFrom, movedFrom + (moved.to - moved.from));
const caret = movedFrom + (head - moved.from);
return {
spec: {
changes: reindent ? changes.compose(reindent) : changes,
selection: EditorSelection.cursor(reindent ? reindent.mapPos(caret, 1) : caret),
scrollIntoView: true,
userEvent: "move.line",
},
};
}
/** Shifts the lines of `[from, to]` so the first one gets the indentation the language computes for it. */
function shiftToIndentation(state: EditorState, from: number, to: number) {
if (!syntaxTree(state).length) return null;
const first = state.doc.lineAt(from);
const proper = getIndentation(state, first.from);
if (proper === null) return null;
const delta = proper - countColumn(/^\s*/.exec(first.text)![0], state.tabSize);
if (!delta) return null;
const changes: ChangeSpec[] = [];
for (let pos = from; pos <= to; ) {
const line = state.doc.lineAt(pos);
pos = line.to + 1;
const ws = /^\s*/.exec(line.text)![0];
if (ws.length === line.length) continue;
const col = Math.max(0, countColumn(ws, state.tabSize) + delta);
changes.push({ from: line.from, to: line.from + ws.length, insert: indentString(state, col) });
}
return state.changes(changes);
}
function moveStatement(up: boolean): Command {
return (view) => {
const { state } = view;
if (state.readOnly) return false;
if (state.selection.ranges.length === 1 && state.selection.main.empty) {
const head = state.selection.main.head;
const cur = statementAt(state, head);
const sib = cur && siblingStatement(state, head, up);
if (cur && sib && (cur.span.to - cur.span.from > 0)) {
const caretOffset = head - cur.span.from;
const spec = up ? swapSpans(state, sib, cur.span, caretOffset, true) : swapSpans(state, cur.span, sib, caretOffset, false);
view.dispatch(spec);
if (view.state.readOnly) return false;
if (view.state.selection.ranges.length === 1 && view.state.selection.main.empty) {
// The statement and its neighbours need a parsed tree around the caret.
forceParsing(view, Math.min(view.state.doc.length, view.state.selection.main.head + 20_000), 100);
const plan = planStatementMove(view.state, up);
if (plan === "noop") return true;
if (plan !== "line") {
view.dispatch(plan.spec);
return true;
}
}
@@ -427,10 +739,17 @@ export const addCaretsToLineEnds: Command = (view) => {
return true;
};
/** ⌃⇧G: Unselect Occurrence — drops the most recently added selection. */
/** ⌃⇧G: Unselect Occurrence — drops the most recently added selection; a single one collapses to its caret. */
export const unselectOccurrence: Command = (view) => {
const sel = view.state.selection;
if (sel.ranges.length < 2) return false;
const { state } = view;
const sel = state.selection;
const run = state.field(occurrenceSearch, false) ?? null;
const effects = setOccurrenceSearch.of({ wholeWords: !!run?.wholeWords, notFound: false });
if (sel.ranges.length < 2) {
if (sel.main.empty) return false;
view.dispatch({ selection: EditorSelection.cursor(sel.main.head), effects, userEvent: "select" });
return true;
}
const ranges = sel.ranges.filter((_, i) => i !== sel.mainIndex);
// The previous occurrence becomes main: the one closest before the removed one, else the last.
const removed = sel.main.from;
@@ -440,6 +759,6 @@ export const unselectOccurrence: Command = (view) => {
main = i;
break;
}
view.dispatch({ selection: EditorSelection.create(ranges, main), scrollIntoView: true, userEvent: "select" });
view.dispatch({ selection: EditorSelection.create(ranges, main), effects, scrollIntoView: true, userEvent: "select" });
return true;
};
+99 -7
Переглянути файл
@@ -155,18 +155,23 @@ export function unfoldAtCaret(view: EditorView): boolean {
}
/**
* IDEA "Fold Selection / Remove Region" (⌘.): folds each selection; with no
* selection, expands the fold at the caret.
* IDEA "Fold Selection / Remove Region" (⌘.): folds each selection (without
* a trailing line break); with no selection, toggles the region at the caret.
*/
export function foldSelectionEffects(state: EditorState): StateEffect<unknown>[] {
const effects: StateEffect<unknown>[] = [];
for (const range of state.selection.ranges) {
if (!range.empty) {
effects.push(foldEffect.of({ from: range.from, to: range.to }));
const to = state.sliceDoc(range.to - 1, range.to) === "\n" ? range.to - 1 : range.to;
if (to > range.from) effects.push(foldEffect.of({ from: range.from, to }));
continue;
}
const r = foldToExpand(state, range.head);
if (r) effects.push(unfoldEffect.of(r));
const folded = foldToExpand(state, range.head);
if (folded) effects.push(unfoldEffect.of(folded));
else {
const r = enclosingFoldRange(state, range.head);
if (r) effects.push(foldEffect.of(r));
}
}
return effects;
}
@@ -175,11 +180,98 @@ export function foldSelection(view: EditorView): boolean {
const effects = foldSelectionEffects(view.state);
if (!effects.length) return false;
const sel = view.state.selection;
// The caret lands after the fold, like IDEA.
view.dispatch({ effects, selection: sel.ranges.some((r) => !r.empty) ? { anchor: sel.main.to } : undefined });
const folded = effects.filter((e) => e.is(foldEffect)).map((e) => e.value as { from: number; to: number });
// The caret lands after the new fold, like IDEA.
const after = sel.ranges.some((r) => !r.empty) && folded.length ? { anchor: folded[folded.length - 1]!.to } : undefined;
view.dispatch({ effects, selection: after });
return true;
}
function isFolded(state: EditorState, r: { from: number; to: number }): boolean {
let hit = false;
foldedRanges(state).between(r.from, r.from, (a, b) => {
if (a === r.from && b === r.to) hit = true;
});
return hit;
}
/** Every foldable region (nested ones included) of the lines in `[from, to]`, kept within `[from, to]`. */
export function foldableRegions(state: EditorState, from = 0, to = state.doc.length): Array<{ from: number; to: number }> {
const out: Array<{ from: number; to: number }> = [];
for (let pos = state.doc.lineAt(from).from; pos <= to; ) {
const line = state.doc.lineAt(pos);
const r = foldable(state, line.from, line.to);
if (r && r.from >= from && r.to <= to) out.push(r);
if (line.to >= state.doc.length) break;
pos = line.to + 1;
}
return out;
}
/** The selection, when it holds whole regions to work on; else the whole document. */
function regionScope(state: EditorState): { from: number; to: number } | null {
const main = state.selection.main;
return main.empty ? null : { from: main.from, to: main.to };
}
/**
* IDEA "Collapse All" (⌘⇧-): every region, nested ones included; with a
* selection, the regions inside it (all of them when it holds none).
*/
export function collapseAllEffects(state: EditorState): StateEffect<unknown>[] {
const scope = regionScope(state);
let regions = scope ? foldableRegions(state, scope.from, scope.to) : [];
if (!regions.length) regions = foldableRegions(state);
return regions.filter((r) => !isFolded(state, r)).map((r) => foldEffect.of(r));
}
/** IDEA "Expand All" (⌘⇧=): every folded region, or those inside the selection. */
export function expandAllEffects(state: EditorState): StateEffect<unknown>[] {
const all: Array<{ from: number; to: number }> = [];
foldedRanges(state).between(0, state.doc.length, (from, to) => void all.push({ from, to }));
const scope = regionScope(state);
const inside = scope ? all.filter((r) => r.from >= scope.from && r.to <= scope.to) : [];
return (inside.length ? inside : all).map((r) => unfoldEffect.of(r));
}
/**
* IDEA "Collapse Recursively" (⌘⌥-): the region at the caret line (else the
* innermost one around the caret) and every region inside it.
*/
export function collapseRecursivelyEffects(state: EditorState, head: number): StateEffect<unknown>[] {
const line = state.doc.lineAt(head);
const own = foldable(state, line.from, line.to);
const root = own && !isFolded(state, own) ? own : enclosingFoldRange(state, head);
if (!root) return [];
const inner = foldableRegions(state, root.from, root.to).filter((r) => !isFolded(state, r));
if (!isFolded(state, root) && !inner.some((r) => r.from === root.from && r.to === root.to)) inner.push(root);
return inner.map((r) => foldEffect.of(r));
}
/** IDEA "Expand Recursively" (⌘⌥=): the region at the caret and every folded region inside it. */
export function expandRecursivelyEffects(state: EditorState, head: number): StateEffect<unknown>[] {
const line = state.doc.lineAt(head);
const root = foldToExpand(state, head) ?? foldable(state, line.from, line.to);
if (!root) return [];
const out: StateEffect<unknown>[] = [];
foldedRanges(state).between(root.from, root.to, (from, to) => {
if (from >= root.from && to <= root.to) out.push(unfoldEffect.of({ from, to }));
});
return out;
}
function dispatchEffects(view: EditorView, effects: StateEffect<unknown>[]): boolean {
if (effects.length) view.dispatch({ effects });
return effects.length > 0;
}
export const collapseAll = (view: EditorView) => dispatchEffects(view, collapseAllEffects(view.state));
export const expandAll = (view: EditorView) => dispatchEffects(view, expandAllEffects(view.state));
export const collapseRecursively = (view: EditorView) =>
dispatchEffects(view, view.state.selection.ranges.flatMap((r) => collapseRecursivelyEffects(view.state, r.head)));
export const expandRecursively = (view: EditorView) =>
dispatchEffects(view, view.state.selection.ranges.flatMap((r) => expandRecursivelyEffects(view.state, r.head)));
function chevron(open: boolean): HTMLElement {
const span = document.createElement("span");
span.className = open ? "cm-openidea-foldOpen" : "cm-openidea-foldClosed";
+72
Переглянути файл
@@ -0,0 +1,72 @@
/**
* Dragging over the line numbers (or the empty space of the other gutters)
* selects whole lines, as in IDEA. A press without a drag is left to the
* gutter's own click handling (e.g. toggling a breakpoint).
*/
import { EditorSelection, type EditorState } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
/** The lines from `start` to `end` (1-based, either order), the head at the far line boundary. */
export function lineRangeSelection(state: EditorState, start: number, end: number): EditorSelection {
const doc = state.doc;
const after = (n: number) => (n < doc.lines ? doc.line(n + 1).from : doc.length);
return end >= start
? EditorSelection.single(doc.line(start).from, after(end))
: EditorSelection.single(after(start), doc.line(end).from);
}
/** Line-number column or empty gutter space; not the fold chevrons, VCS annotations or a gutter's own markers. */
export function inLineSelectArea(target: EventTarget | null): boolean {
const t = target as HTMLElement | null;
if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter, .cm-vcs-blame")) return false;
if (t.closest(".cm-lineNumbers")) return true;
if (t.classList.contains("cm-gutter")) return true;
return t.classList.contains("cm-gutterElement") && t.childElementCount === 0;
}
export const gutterLineDrag = ViewPlugin.fromClass(
class {
private stop: (() => void) | null = null;
constructor(readonly view: EditorView) {
view.dom.addEventListener("mousedown", this.onMouseDown);
}
private lineAt(clientY: number): number {
const block = this.view.lineBlockAtHeight(clientY - this.view.documentTop);
return this.view.state.doc.lineAt(block.from).number;
}
onMouseDown = (e: MouseEvent) => {
if (e.button !== 0 || e.altKey || e.metaKey || e.ctrlKey || e.shiftKey || !inLineSelectArea(e.target)) return;
this.stop?.();
const start = this.lineAt(e.clientY);
let dragging = false;
const move = (m: MouseEvent) => {
if (!(m.buttons & 1)) return finish();
const line = this.lineAt(m.clientY);
if (!dragging && line === start) return;
dragging = true;
this.view.dispatch({
selection: lineRangeSelection(this.view.state, start, line),
scrollIntoView: true,
userEvent: "select.pointer",
});
};
const finish = () => {
window.removeEventListener("mousemove", move, true);
window.removeEventListener("mouseup", finish, true);
this.stop = null;
if (dragging) this.view.focus();
};
window.addEventListener("mousemove", move, true);
window.addEventListener("mouseup", finish, true);
this.stop = finish;
};
destroy() {
this.stop?.();
this.view.dom.removeEventListener("mousedown", this.onMouseDown);
}
},
);
+331
Переглянути файл
@@ -0,0 +1,331 @@
import { afterEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { codeFolding, foldEffect, foldService, foldedRanges, indentService, indentUnit } from "@codemirror/language";
import { languages } from "@codemirror/language-data";
import {
deleteLine,
deleteWordBackward,
duplicateLineOrSelection,
moveStatementDown,
moveStatementUp,
nextOccurrence,
occurrenceSearch,
selectAllOccurrences,
unselectOccurrence,
} from "./editing";
import { pasteReindentFilter } from "./prefs";
import { closesStringAt, skipClosingQuote } from "./smartKeys";
import {
collapseAllEffects,
collapseRecursivelyEffects,
expandRecursivelyEffects,
foldSelectionEffects,
} from "./folding";
import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode";
import { nearestMark, unfoldAround } from "./lanes";
import { lineRangeSelection } from "./gutterDrag";
import { matchBraceTarget } from "./caretCommands";
import { overwriteField, toggleOverwrite } from "./overwrite";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
/** `|` marks carets, `[`/`]` a selection. */
function make(text: string, extensions: Extension = []) {
const ranges: ReturnType<typeof EditorSelection.cursor>[] = [];
let doc = "";
let anchor = -1;
for (const ch of text) {
if (ch === "|") ranges.push(EditorSelection.cursor(doc.length));
else if (ch === "[") anchor = doc.length;
else if (ch === "]") ranges.push(EditorSelection.range(anchor, doc.length));
else doc += ch;
}
const view = new EditorView({
state: EditorState.create({
doc,
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
extensions: [EditorState.allowMultipleSelections.of(true), indentUnit.of(" "), occurrenceSearch, extensions],
}),
parent: document.body,
});
views.push(view);
return view;
}
function show(view: EditorView): string {
const { doc, selection } = view.state;
let out = doc.toString();
for (const r of [...selection.ranges].sort((a, b) => b.from - a.from))
out = r.empty
? out.slice(0, r.head) + "|" + out.slice(r.head)
: out.slice(0, r.from) + "[" + out.slice(r.from, r.to) + "]" + out.slice(r.to);
return out;
}
const javascript = await languages.find((l) => l.name === "JavaScript")!.load();
describe("Move Statement", () => {
it("moves a multi-line statement as a whole, never one of its lines", () => {
const v = make("function f() {\n a();\n |foo(\n 1,\n 2);\n}", javascript);
moveStatementUp(v);
expect(v.state.doc.toString()).toBe("function f() {\n foo(\n 1,\n 2);\n a();\n}");
moveStatementDown(v);
expect(v.state.doc.toString()).toBe("function f() {\n a();\n foo(\n 1,\n 2);\n}");
});
it("moves into a block past its header and out of it past its closing brace, re-indented", () => {
const v = make("function f() {\n |a();\n if (x) {\n b();\n }\n}", javascript);
moveStatementDown(v);
expect(show(v)).toBe("function f() {\n if (x) {\n |a();\n b();\n }\n}");
moveStatementDown(v);
moveStatementDown(v);
expect(show(v)).toBe("function f() {\n if (x) {\n b();\n }\n |a();\n}");
});
it("never carries a statement out of its function", () => {
const v = make("function f() {\n |a();\n}\nb();", javascript);
moveStatementUp(v);
expect(v.state.doc.toString()).toBe("function f() {\n a();\n}\nb();");
moveStatementDown(v);
expect(v.state.doc.toString()).toBe("function f() {\n a();\n}\nb();");
});
it("passes a neighbouring function as a whole", () => {
const v = make("|a();\nfunction f() {\n b();\n}", javascript);
moveStatementDown(v);
expect(v.state.doc.toString()).toBe("function f() {\n b();\n}\na();");
});
});
describe("occurrences", () => {
it("⌃G from a caret selects the word, then whole-word matches after the last added one", () => {
const v = make("fo|o foobar foo foo");
expect(nextOccurrence(v)).toBe("selected");
expect(show(v)).toBe("[foo] foobar foo foo");
expect(nextOccurrence(v)).toBe("added");
expect(show(v)).toBe("[foo] foobar [foo] foo");
});
it("matches inside words for a selection made by hand", () => {
const v = make("[foo] foobar foo");
nextOccurrence(v);
expect(show(v)).toBe("[foo] [foo]bar foo");
});
it("reports nothing found once, then searches from the top", () => {
const v = make("foo x [foo]");
expect(nextOccurrence(v)).toBe("notFound");
expect(nextOccurrence(v)).toBe("added");
expect(show(v)).toBe("[foo] x [foo]");
});
it("⌃⌘G from a caret selects whole-word occurrences only", () => {
const v = make("a|b abc ab");
selectAllOccurrences(v);
expect(show(v)).toBe("[ab] abc [ab]");
});
it("⌃⇧G keeps the whole-word run going", () => {
const v = make("|ab abc ab ab");
nextOccurrence(v);
nextOccurrence(v);
nextOccurrence(v);
unselectOccurrence(v);
nextOccurrence(v);
expect(show(v)).toBe("[ab] abc [ab] [ab]");
});
});
describe("folded lines", () => {
const braces = foldService.of((state, from, to) => {
const line = state.doc.lineAt(from);
if (!line.text.endsWith("{")) return null;
const close = state.doc.toString().indexOf("}", to);
return close > to ? { from: to, to: close } : null;
});
const folded = () => {
const v = make("x\nf() {\n a;\n}\ny", [braces, codeFolding()]);
v.dispatch({ effects: foldEffect.of({ from: 7, to: 13 }), selection: { anchor: 3 } });
return v;
};
it("⌘D on a folded region's line duplicates the whole folded block", () => {
const v = folded();
duplicateLineOrSelection(v);
expect(v.state.doc.toString()).toBe("x\nf() {\n a;\n}\nf() {\n a;\n}\ny");
expect(v.state.selection.main.head).toBe(16);
});
it("⌘⌫ on a folded region's line deletes the whole folded block", () => {
const v = folded();
deleteLine(v);
expect(v.state.doc.toString()).toBe("x\ny");
});
});
describe("Delete to Word Start", () => {
it("deletes the whitespace before the caret and the word before it", () => {
const v = make("foo bar |");
deleteWordBackward(v);
expect(show(v)).toBe("foo |");
const w = make("a.b(|");
deleteWordBackward(w);
expect(show(w)).toBe("a.b|");
});
});
describe("paste reformatting", () => {
/** One unit deeper after a line ending in `{`. */
const braceIndent = indentService.of((cx, pos) => {
const line = cx.state.doc.lineAt(pos);
if (line.number === 1) return 0;
const prev = cx.state.doc.line(line.number - 1);
const base = cx.lineIndent(prev.from);
return prev.text.trimEnd().endsWith("{") ? base + 2 : base;
});
const paste = (v: EditorView, text: string) =>
v.dispatch({
changes: { from: v.state.selection.main.from, to: v.state.selection.main.to, insert: text },
userEvent: "input.paste",
});
it("indents each pasted line (IDEA's default)", () => {
const v = make("if {\n|\n}", [braceIndent, pasteReindentFilter("indentEachLine")]);
paste(v, "a();\n b();\n c();");
expect(v.state.doc.toString()).toBe("if {\n a();\n b();\n c();\n}");
});
it("starts a block pasted at a line's indentation at the proper indent", () => {
const v = make("if {\n|\n}", [braceIndent, pasteReindentFilter("indentBlock")]);
paste(v, "a();\n b();\n c();");
expect(v.state.doc.toString()).toBe("if {\n a();\n b();\n c();\n}");
});
});
describe("closing quotes", () => {
it("steps over the closing quote of an existing string literal", () => {
const v = make('f("ab|");', [javascript, skipClosingQuote]);
expect(closesStringAt(v.state, v.state.selection.main.head, '"')).toBe(true);
expect(closesStringAt(v.state, 2, '"')).toBe(false);
const handled = v.state.facet(EditorView.inputHandler).some((h) => h(v, 5, 5, '"', () => v.state.update({})));
expect(handled).toBe(true);
expect(show(v)).toBe('f("ab"|);');
});
});
describe("folding actions", () => {
const braces = foldService.of((state, from, to) => {
const line = state.doc.lineAt(from);
if (!line.text.trimEnd().endsWith("{")) return null;
let depth = 0;
for (let pos = to - 1; pos < state.doc.length; pos++) {
const ch = state.doc.sliceString(pos, pos + 1);
if (ch === "{") depth++;
else if (ch === "}" && --depth === 0) return { from: to, to: pos };
}
return null;
});
const text = "a {\n b {\n c;\n }\n}\nd {\n}\n";
const state = (sel?: { anchor: number; head?: number }) =>
EditorState.create({ doc: text, selection: sel, extensions: [braces, codeFolding()] });
const foldedOf = (s: EditorState) => {
const out: number[] = [];
foldedRanges(s).between(0, s.doc.length, (a) => void out.push(s.doc.lineAt(a).number));
return out.sort((a, b) => a - b);
};
it("Collapse All folds nested regions too; with a selection only those inside it", () => {
let s = state();
s = s.update({ effects: collapseAllEffects(s) }).state;
expect(foldedOf(s)).toEqual([1, 2, 6]);
s = state({ anchor: 4, head: 21 });
s = s.update({ effects: collapseAllEffects(s) }).state;
expect(foldedOf(s)).toEqual([2]);
});
it("collapses and expands recursively", () => {
let s = state({ anchor: 1 });
s = s.update({ effects: collapseRecursivelyEffects(s, 1) }).state;
expect(foldedOf(s)).toEqual([1, 2]);
s = s.update({ effects: expandRecursivelyEffects(s, 1) }).state;
expect(foldedOf(s)).toEqual([]);
});
it("⌘. folds a selection without its trailing line break and toggles the region at a caret", () => {
let s = state({ anchor: 4, head: 23 });
const fold = foldSelectionEffects(s)[0]!;
expect(fold.is(foldEffect) && fold.value).toEqual({ from: 4, to: 22 });
s = state({ anchor: 12 });
s = s.update({ effects: foldSelectionEffects(s) }).state;
expect(foldedOf(s)).toEqual([2]);
s = s.update({ effects: foldSelectionEffects(s) }).state;
expect(foldedOf(s)).toEqual([]);
});
});
describe("column selection", () => {
it("keeps the block in visual columns across tabs", () => {
const v = make("\tab\n cd", [columnModeExtension, EditorState.tabSize.of(4)]);
v.dispatch({ selection: { anchor: 1 } });
toggleColumnMode(v);
extendBlock(v, 1, 0);
extendBlock(v, 0, 1);
expect(v.state.selection.ranges.map((r) => [r.anchor, r.head])).toEqual([
[1, 2],
[8, 9],
]);
});
});
describe("error stripe", () => {
it("picks the nearest mark within the tolerance", () => {
const marks = [
{ top: 10, bottom: 12, pos: 1 },
{ top: 30, bottom: 32, pos: 2 },
];
expect(nearestMark(marks, 15)?.pos).toBe(1);
expect(nearestMark(marks, 27)?.pos).toBe(2);
expect(nearestMark(marks, 21)).toBeNull();
});
it("unfolds what hides the target line", () => {
const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({
effects: foldEffect.of({ from: 3, to: 8 }),
}).state;
expect(unfoldAround(s, 0)).toHaveLength(1);
});
});
describe("gutter drag", () => {
it("selects whole lines in either direction", () => {
const s = EditorState.create({ doc: "a\nb\nc\nd" });
expect(lineRangeSelection(s, 2, 3).main).toMatchObject({ anchor: 2, head: 6 });
expect(lineRangeSelection(s, 3, 1).main).toMatchObject({ anchor: 6, head: 0 });
expect(lineRangeSelection(s, 4, 4).main).toMatchObject({ anchor: 6, head: 7 });
});
});
describe("Move Caret to Matching Brace", () => {
it("jumps between braces, else to the unclosed opening brace", () => {
const s = EditorState.create({ doc: "f(a, [b], c)" });
expect(matchBraceTarget(s, 1)).toBe(12);
expect(matchBraceTarget(s, 12)).toBe(1);
expect(matchBraceTarget(s, 3)).toBe(1);
});
});
describe("overwrite mode", () => {
it("replaces the characters after the caret, never the line break", () => {
const v = make("ab|cd\nx", overwriteField);
toggleOverwrite(v);
expect(v.dom.classList.contains("cm-openidea-blockCaret")).toBe(true);
const type = (text: string) =>
v.state
.facet(EditorView.inputHandler)
.some((h) => h(v, v.state.selection.main.from, v.state.selection.main.to, text, () => v.state.update({})));
type("XYZ");
expect(show(v)).toBe("abXYZ|\nx");
});
});
+36 -6
Переглянути файл
@@ -2,7 +2,8 @@
* The 3px change lane next to the line numbers (`gutterLaneMarkers`) and the
* right-hand overview lane over the scrollbar (`scrollbarMarks`).
*/
import { type Extension } from "@codemirror/state";
import { EditorSelection, type EditorState, type Extension, type StateEffect } from "@codemirror/state";
import { foldedRanges, unfoldEffect } from "@codemirror/language";
import { EditorView, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import { gutterLaneMarkers, scrollbarMarks, type ScrollbarMark } from "@/editor/api";
@@ -13,6 +14,29 @@ export const changeLane: Extension = gutter({
const MAX_RENDERED = 4000;
/** How far (px) a click may miss a mark on the overview lane. */
const MARK_TOLERANCE = 6;
/** The mark nearest to `y` (by its top) among those within the tolerance. */
export function nearestMark<M extends { top: number; bottom: number }>(marks: readonly M[], y: number): M | null {
let best: M | null = null;
for (const m of marks) {
if (y < m.top - MARK_TOLERANCE || y > m.bottom + MARK_TOLERANCE) continue;
if (!best || Math.abs(m.top - y) < Math.abs(best.top - y)) best = m;
}
return best;
}
/** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */
export function unfoldAround(state: EditorState, pos: number): StateEffect<unknown>[] {
const lineEnd = state.doc.lineAt(pos).to;
const out: StateEffect<unknown>[] = [];
foldedRanges(state).between(pos, lineEnd, (from, to) => {
if (from <= lineEnd && to > pos) out.push(unfoldEffect.of({ from, to }));
});
return out;
}
class OverviewLane {
dom: HTMLElement;
private scheduled = false;
@@ -101,13 +125,19 @@ class OverviewLane {
}
private onMouseDown = (e: MouseEvent) => {
const el = (e.target as HTMLElement).closest<HTMLElement>(".cm-openidea-overviewMark");
if (!el?.dataset.pos) return;
if (e.button !== 0) return;
const marks = [...this.dom.querySelectorAll<HTMLElement>(".cm-openidea-overviewMark")].map((el) => {
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, pos: Number(el.dataset.pos) };
});
const hit = nearestMark(marks, e.clientY);
if (!hit) return;
e.preventDefault();
const pos = Math.min(Number(el.dataset.pos), this.view.state.doc.length);
const state = this.view.state;
const pos = Math.min(hit.pos, state.doc.length);
this.view.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, { y: "center" }),
selection: EditorSelection.cursor(pos),
effects: [...unfoldAround(state, pos), EditorView.scrollIntoView(pos, { y: "center" })],
userEvent: "select.overview",
});
this.view.focus();
+15
Переглянути файл
@@ -35,6 +35,21 @@ export function _resetLanguages() {
infosPromise = null;
}
/**
* `uiMode` of the backend language for a file name (longest extension first,
* like the backend's detection); `undefined` when the languages are not loaded
* or none matches.
*/
export function uiModeForPath(path: string): string | null | undefined {
if (!infos) return undefined;
let rest = path.slice(path.lastIndexOf("/") + 1).toLowerCase();
for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) {
rest = rest.slice(dot + 1);
for (const info of infos.values()) if (info.extensions.includes(rest)) return info.uiMode;
}
return undefined;
}
const supports = new Map<string, Promise<LanguageSupport | null>>();
export function findLanguageDescription(uiMode: string | null, path: string): LanguageDescription | null {
+43
Переглянути файл
@@ -0,0 +1,43 @@
/**
* Overwrite mode (Insert key, as in IDEA): typed characters replace the ones
* after the caret (never the line break) and the caret is drawn as a block.
*/
import { EditorSelection, Prec, StateEffect, StateField, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
export const setOverwrite = StateEffect.define<boolean>();
export function isOverwrite(state: EditorState): boolean {
return state.field(overwriteField, false) ?? false;
}
export function toggleOverwrite(view: EditorView): boolean {
view.dispatch({ effects: setOverwrite.of(!isOverwrite(view.state)) });
return true;
}
const overwriteInput = EditorView.inputHandler.of((view, _from, _to, text) => {
const { state } = view;
if (!isOverwrite(state) || state.readOnly || /[\r\n]/.test(text)) return false;
const tr = state.changeByRange((r) => {
let to = r.to;
if (r.empty) {
const line = state.doc.lineAt(r.head);
to = Math.min(line.to, r.head + text.length);
}
return { changes: { from: r.from, to, insert: text }, range: EditorSelection.cursor(r.from + text.length) };
});
view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "input.type" }));
return true;
});
export const overwriteField = StateField.define<boolean>({
create: () => false,
update: (on, tr) => tr.effects.reduce((acc, e) => (e.is(setOverwrite) ? e.value : acc), on),
provide: (f) => [
Prec.highest(overwriteInput),
EditorView.editorAttributes.compute([f], (s): Record<string, string> =>
s.field(f) ? { class: "cm-openidea-blockCaret" } : {},
),
],
});
+70 -25
Переглянути файл
@@ -3,7 +3,7 @@
* Keys, Code Editing, Code Style). Everything here is rebuilt into the
* `prefs` compartment when settings change.
*/
import { EditorSelection, EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Extension, type Transaction } from "@codemirror/state";
import { EditorSelection, EditorState, Prec, RangeSetBuilder, StateEffect, StateField, type Extension, type Transaction, type TransactionSpec } from "@codemirror/state";
import {
Decoration,
EditorView,
@@ -16,7 +16,7 @@ import {
type ViewUpdate,
} from "@codemirror/view";
import { highlightTree } from "@lezer/highlight";
import { getIndentUnit, indentUnit, syntaxTree } from "@codemirror/language";
import { getIndentUnit, getIndentation, indentRange, indentString, indentUnit, syntaxTree } from "@codemirror/language";
import {
cursorLineStart,
deleteCharBackwardStrict,
@@ -25,9 +25,17 @@ import {
} from "@codemirror/commands";
import type { EditorExtras } from "@/platform/editorSettings";
import { openideaHighlightStyle } from "./highlight";
import { cursorHumpLeft, cursorHumpRight, deleteHumpBackward, deleteHumpForward, selectHumpLeft, selectHumpRight } from "./editing";
import {
cursorHumpLeft,
cursorHumpRight,
deleteHumpBackward,
deleteHumpForward,
deleteWordBackward,
selectHumpLeft,
selectHumpRight,
} from "./editing";
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
import { smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
// --- soft wraps ---------------------------------------------------------------
@@ -207,25 +215,60 @@ export function indentPastedText(text: string, targetCol: number, unit: string,
.join("\n");
}
const pasteFilter = EditorState.transactionFilter.of((tr: Transaction) => {
if (!tr.isUserEvent("input.paste") || !tr.docChanged) return tr;
const start = tr.startState;
const changes: Array<{ from: number; to: number; text: string }> = [];
tr.changes.iterChanges((from, to, _fb, _tb, inserted) => changes.push({ from, to, text: inserted.toString() }));
if (changes.length !== 1) return tr;
const { from, to, text } = changes[0]!;
const line = start.doc.lineAt(from);
const before = start.doc.sliceString(line.from, from);
const targetCol = leadingColumns(/^\s*$/.test(before) ? before : line.text, start.tabSize);
const out = indentPastedText(text, targetCol, start.facet(indentUnit)[0] === "\t" ? "\t" : " ", start.tabSize, getIndentUnit(start) || start.tabSize);
if (out === text) return tr;
return {
changes: { from, to, insert: out },
selection: EditorSelection.cursor(from + out.length),
userEvent: "input.paste",
scrollIntoView: true,
};
});
/**
* Reformat on paste. `indentBlock` shifts the pasted block to the paste
* column; `indentEachLine` (IDEA's default) then gives each pasted line the
* language's indentation, where the language can compute one. A paste
* after nothing but whitespace starts at the line's proper indentation.
*/
export function pasteReindentFilter(mode: "indentBlock" | "indentEachLine") {
return EditorState.transactionFilter.of((tr: Transaction) => {
if (!tr.isUserEvent("input.paste") || !tr.docChanged) return tr;
const start = tr.startState;
const changes: Array<{ from: number; to: number; text: string }> = [];
tr.changes.iterChanges((from, to, _fb, _tb, inserted) => changes.push({ from, to, text: inserted.toString() }));
if (changes.length !== 1) return tr;
let { from } = changes[0]!;
const { to, text } = changes[0]!;
if (!text.trim()) return tr;
const line = start.doc.lineAt(from);
const before = start.doc.sliceString(line.from, from);
const atIndent = /^\s*$/.test(before);
const tabSize = start.tabSize;
const unitCols = getIndentUnit(start) || tabSize;
const useTabs = start.facet(indentUnit)[0] === "\t";
let targetCol = leadingColumns(atIndent ? before : line.text, tabSize);
let prefix = "";
if (atIndent) {
const proper = getIndentation(start, line.from);
if (proper !== null && proper !== targetCol) {
targetCol = proper;
prefix = indentString(start, proper);
from = line.from;
}
}
const block = indentPastedText(text, targetCol, useTabs ? "\t" : " ", tabSize, unitCols);
const out = prefix + (prefix ? block.trimStart() : block);
const first: TransactionSpec = {
changes: { from, to, insert: out },
selection: EditorSelection.cursor(from + out.length),
userEvent: "input.paste",
scrollIntoView: true,
};
if (mode !== "indentEachLine") return out === text && from === changes[0]!.from ? tr : first;
// Lines whose start lies in the pasted text, plus the paste line when it was pasted at its indentation.
const pasted = start.update({ ...first, filter: false }).state;
const end = from + out.length;
const firstLine = pasted.doc.lineAt(from);
const lineStart = atIndent ? firstLine.from : firstLine.to + 1;
const lastLine = pasted.doc.lineAt(end);
const lineEnd = lastLine.from === end && end > from ? end - 1 : end;
if (lineStart > lineEnd) return first;
const reindent = indentRange(pasted, lineStart, lineEnd);
if (reindent.empty) return first;
return [first, { changes: reindent, selection: EditorSelection.cursor(reindent.mapPos(end, 1)), sequential: true }];
});
}
// --- rich-text copy -----------------------------------------------------------
@@ -397,12 +440,13 @@ export function prefsExtension(o: PrefsOptions): Extension {
if (!g.dragAndDrop) out.push(noTextDrag);
if (g.caretScroll === "center") out.push(centerCaret);
if (g.richTextCopy) out.push(richCopy);
if (!o.readOnly && k.reformatOnPaste === "indentBlock") out.push(pasteFilter);
if (!o.readOnly && (k.reformatOnPaste === "indentBlock" || k.reformatOnPaste === "indentEachLine")) out.push(pasteReindentFilter(k.reformatOnPaste));
// Pairing: closeBrackets() reads the brackets list from language data; ours comes first.
const brackets = [...(k.pairBrackets ? ["(", "[", "{"] : []), ...(k.pairQuotes ? ["'", '"'] : [])];
out.push(Prec.highest(EditorState.languageData.of(() => [{ closeBrackets: { brackets } }])));
if (!k.surroundSelection) out.push(Prec.highest(noSurround));
if (k.pairQuotes) out.push(Prec.highest(skipClosingQuote));
if (k.tabOutOfPair) out.push(tabOut);
if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle));
@@ -411,9 +455,10 @@ export function prefsExtension(o: PrefsOptions): Extension {
keys.push(
{ key: "Alt-ArrowLeft", run: cursorHumpLeft, shift: selectHumpLeft, preventDefault: true },
{ key: "Alt-ArrowRight", run: cursorHumpRight, shift: selectHumpRight, preventDefault: true },
{ key: "Alt-Backspace", run: deleteHumpBackward },
{ key: "Alt-Delete", run: deleteHumpForward },
);
// Delete to Word Start: the whitespace before the caret and the word before it.
keys.push({ key: "Mod-Backspace", mac: "Alt-Backspace", run: k.camelHumps ? deleteHumpBackward : deleteWordBackward });
if (!k.smartHome)
keys.push(
{ key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
+31
Переглянути файл
@@ -0,0 +1,31 @@
/**
* Read-only code previews outside editor tabs (Find in Files, bookmarks): the
* editor's font, colours, line numbers and syntax highlighting, without
* editing, folding or contributions.
*/
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, highlightSpecialChars, lineNumbers } from "@codemirror/view";
import { syntaxHighlighting } from "@codemirror/language";
import { editorsFor } from "@/editor/api";
import type { EditorSettings } from "@/platform/ipc";
import { openideaHighlightStyle } from "./highlight";
import { openideaTheme } from "./theme";
import { findLanguageDescription, loadSupport, loadedSupport, uiModeForPath } from "./languages";
import { fontExtension } from "./setup";
export async function previewExtensions(path: string, settings: EditorSettings | undefined): Promise<Extension> {
const open = editorsFor(path)[0]?.ctx;
const desc = findLanguageDescription(open ? open.uiMode : (uiModeForPath(path) ?? null), path);
const support = desc ? (loadedSupport(desc) ?? (await loadSupport(desc))) : null;
return [
fontExtension(settings),
openideaTheme,
lineNumbers(),
highlightSpecialChars(),
syntaxHighlighting(openideaHighlightStyle),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.contentAttributes.of({ "aria-label": `Preview of ${path}` }),
support ?? [],
];
}
+6
Переглянути файл
@@ -44,6 +44,9 @@ import { multiCaretMouse } from "./multiCaret";
import { columnModeExtension } from "./columnMode";
import { tabKeymap } from "./smartKeys";
import { editorBaseKeymap } from "./baseKeymap";
import { occurrenceSearch } from "./editing";
import { gutterLineDrag } from "./gutterDrag";
import { overwriteField } from "./overwrite";
import { clipboardHistoryExtension } from "../clipboardHistory";
import type { LanguageInfo } from "../backend";
@@ -290,6 +293,9 @@ export function baseExtensions(o: SetupOptions): Extension {
EditorState.allowMultipleSelections.of(true),
multiCaretMouse,
columnModeExtension,
occurrenceSearch,
overwriteField,
gutterLineDrag,
clipboardHistoryExtension,
indentOnInput(),
syntaxHighlighting(openideaHighlightStyle),
+31 -2
Переглянути файл
@@ -3,8 +3,8 @@
* quotes.
*/
import { EditorSelection, StateEffect, countColumn, StateField, type EditorState, type Extension, type Transaction } from "@codemirror/state";
import { keymap, type Command } from "@codemirror/view";
import { getIndentUnit, getIndentation, indentString, indentUnit } from "@codemirror/language";
import { EditorView, keymap, type Command } from "@codemirror/view";
import { getIndentUnit, getIndentation, indentString, indentUnit, syntaxTree } from "@codemirror/language";
import { indentMore } from "@codemirror/commands";
// --- Tab -------------------------------------------------------------------------------
@@ -142,3 +142,32 @@ export const tabOutOfScope: Command = (view) => {
/** Settings › Smart Keys › "Jump outside closing bracket/quote with Tab" (Tab itself is `insertTab`). */
export const tabOut: Extension = tabOutScopes;
// --- closing quote ---------------------------------------------------------------------------
/** `quote` at `pos` closes the string literal the caret is in (typed or not, the pair's end). */
export function closesStringAt(state: EditorState, pos: number, quote: string): boolean {
if (state.sliceDoc(pos, pos + 1) !== quote) return false;
for (let n: ReturnType<ReturnType<typeof syntaxTree>["resolveInner"]> | null = syntaxTree(state).resolveInner(pos, 1); n; n = n.parent) {
if (n.to > pos + 1) break;
if (n.to === pos + 1 && n.from < pos && /String|Char|Template|Rune/.test(n.type.name)) return true;
}
return false;
}
/** Typing a quote right before the closing quote of a string literal steps over it, as in IDEA. */
export const skipClosingQuote = EditorView.inputHandler.of((view, _from, _to, text) => {
if (!/^["'`]$/.test(text) || view.state.readOnly) return false;
const { state } = view;
const sel = state.selection;
if (!sel.ranges.every((r) => r.empty && closesStringAt(state, r.head, text))) return false;
view.dispatch({
selection: EditorSelection.create(
sel.ranges.map((r) => EditorSelection.cursor(r.head + 1)),
sel.mainIndex,
),
scrollIntoView: true,
userEvent: "input.type",
});
return true;
});
+30 -6
Переглянути файл
@@ -2,9 +2,9 @@
* Sticky scroll: the header lines of the scopes enclosing the first visible
* line stay pinned at the top (IDEA 2023+ / VS Code). Scopes come from the
* backend folding ranges, or from the syntax tree when there are none.
* Clicking a sticky line scrolls to it.
* Clicking a sticky line scrolls to it, the caret where it was clicked.
*/
import { Compartment, Facet, type EditorState, type Extension } from "@codemirror/state";
import { Compartment, EditorSelection, Facet, type EditorState, type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { foldNodeProp, syntaxTree } from "@codemirror/language";
import { highlightTree } from "@lezer/highlight";
@@ -80,6 +80,23 @@ function renderLineContent(state: EditorState, lineNo: number, target: HTMLEleme
push(end, "");
}
/** Character offset in a rendered sticky line closest to `clientX`. */
export function offsetAtX(inner: HTMLElement, clientX: number): number {
const range = document.createRange();
let offset = 0;
const walker = document.createTreeWalker(inner, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
for (let i = 0; i < node.length; i++) {
range.setStart(node, i);
range.setEnd(node, i + 1);
const r = range.getBoundingClientRect();
if (clientX < r.left + r.width / 2) return offset + i;
}
offset += node.length;
}
return offset;
}
class StickyScroll {
dom: HTMLElement;
private lines: number[] = [];
@@ -203,14 +220,21 @@ class StickyScroll {
}
private onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
const row = (e.target as HTMLElement).closest<HTMLElement>(".cm-openidea-stickyLine");
if (!row?.dataset.line) return;
e.preventDefault();
const line = this.view.state.doc.line(Math.min(Number(row.dataset.line), this.view.state.doc.lines));
const indent = /^\s*/.exec(line.text)![0].length;
// The line number of a sticky line is not a link (IDEA).
const inner = (e.target as HTMLElement).closest(".cm-openidea-stickyText")?.querySelector<HTMLElement>(".cm-openidea-stickyInner");
if (!inner) return;
const state = this.view.state;
const line = state.doc.line(Math.min(Number(row.dataset.line), state.doc.lines));
const offset = offsetAtX(inner, e.clientX);
const rowIndex = [...this.dom.children].indexOf(row);
// The line takes the place of its sticky row, so the text stays under the pointer.
this.view.dispatch({
selection: { anchor: line.from + indent },
effects: EditorView.scrollIntoView(line.from, { y: "start" }),
selection: EditorSelection.cursor(line.from + Math.min(offset, line.length)),
effects: EditorView.scrollIntoView(line.from, { y: "start", yMargin: Math.max(0, rowIndex) * this.view.defaultLineHeight }),
userEvent: "select.sticky",
});
this.view.focus();
+12
Переглянути файл
@@ -65,6 +65,18 @@ describe("EditorController", () => {
expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts", "/p/b.ts"]);
});
it("closes a tab by its input key", async () => {
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
await ctrl.openFile("/p/a.ts", { focus: false });
await ctrl.openFile("/p/b.ts", { focus: false });
const key = Object.values(useTabs.getState().panes)
.flatMap((p) => p.tabs)
.find((t) => t.input.path === "/p/b.ts")!.input.key;
ctrl.closeInput(key);
expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]);
});
it("builds IDEA's editor tab menu", async () => {
useProject.setState({ project: { name: "p", root: "/p", isGit: true, settings: {} } as never, settings: {} as never });
const ids = ["navigate.selectIn", "project.revealInFinder", "vcs.localHistory", "vcs.commit", "project.copyAbsolutePath", "project.rename", "file.settings"];
+4
Переглянути файл
@@ -1003,6 +1003,10 @@ export class EditorController implements SlotHost {
});
}
closeInput(key: string) {
this.apply((s) => closeKey(s, key));
}
activePath(): string | null {
return this.activeSlot?.model.path ?? this.activeTabOf()?.input.path ?? null;
}
+5 -1
Переглянути файл
@@ -9,9 +9,10 @@ import {
registerOverlay,
registerStatusItem,
} from "@/workbench/contributions";
import { _provideDocumentAttacher } from "@/editor/api";
import { _provideCodePreview, _provideDocumentAttacher } from "@/editor/api";
import { editorController } from "./controller";
import { attachDocument } from "./model/attachment";
import { previewExtensions } from "./cm/preview";
import { registerEditorActions } from "./actions";
import { installColorScheme } from "./colorSchemeRuntime";
import { EditorAreaView } from "./view/EditorArea";
@@ -39,8 +40,10 @@ export function activate(): void | (() => void) {
focus: () => ctrl.focus(),
openPaths: () => ctrl.openPaths(),
closePath: (path) => ctrl.closePath(path),
closeInput: (key) => ctrl.closeInput(key),
});
_provideDocumentAttacher((path) => attachDocument(ctrl.manager, path));
_provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor));
const disposers = [
installColorScheme(),
provideEditorArea(EditorAreaView),
@@ -94,6 +97,7 @@ export function activate(): void | (() => void) {
return () => {
disposers.reverse().forEach((d) => d());
_provideDocumentAttacher(null);
_provideCodePreview(null);
ctrl.stop();
};
}
+13
Переглянути файл
@@ -53,3 +53,16 @@ export function humpLeft(text: string, pos: number): number {
while (i > 0 && !isHumpBoundary(text, i)) i--;
return i;
}
/**
* Word start to the left of `pos` within a line without CamelHumps: the
* whitespace before `pos`, then a run of word characters or of punctuation.
*/
export function wordStartLeft(text: string, pos: number): number {
let i = pos;
while (i > 0 && kindOf(text[i - 1]) === "space") i--;
if (i <= 0) return 0;
const punct = kindOf(text[i - 1]) === "punct";
while (i > 0 && kindOf(text[i - 1]) !== "space" && (kindOf(text[i - 1]) === "punct") === punct) i--;
return i;
}
+120 -2
Переглянути файл
@@ -28,6 +28,60 @@
font-family: var(--font-mono);
color: var(--accent);
}
.withPreview {
flex-direction: row;
width: auto;
max-width: min(1160px, 94vw);
}
.listMain {
display: flex;
flex-direction: column;
min-width: 0;
flex: none;
}
.mnemonic {
width: 10px;
flex: none;
font-family: var(--font-keys);
font-size: var(--fs-small);
color: var(--fg-subtle);
text-align: right;
}
.previewPanel {
width: 420px;
flex: none;
min-height: 60px;
max-height: 340px;
overflow: auto;
border-left: 1px solid var(--border-popup);
background: var(--bg-popup-inline, var(--bg-elev));
}
.previewText {
padding: 8px 10px;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.diff {
margin: 0;
padding: 6px 0;
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-small);
color: var(--fg-editor);
}
.diffLine {
padding: 0 10px;
white-space: pre;
min-height: 1.4em;
}
.diff-added {
background: color-mix(in srgb, var(--vcs-added) 20%, transparent);
}
.diff-removed {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.diff-gap {
color: var(--fg-faint);
}
.listBody {
display: flex;
flex-direction: column;
@@ -54,11 +108,15 @@
color: var(--fg-faint);
}
.label {
flex: none;
max-width: 60%;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.label:has(+ .preview) {
flex: none;
max-width: 60%;
}
.preview {
min-width: 80px;
flex: 0 1 auto;
@@ -163,6 +221,34 @@
.docScope {
display: contents;
}
.docFrame {
outline: none;
}
.docWindowFrame {
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
outline: none;
}
.docWindowFrame .docBody {
flex: 1;
}
.docWindowEmpty {
padding: 16px;
font-size: var(--fs-ui);
color: var(--fg-faint);
}
.docToolbar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
gap: 2px;
padding: 2px 6px;
border-top: 1px solid var(--border-popup);
background: var(--bg-popup);
}
.docBody {
padding: 10px 14px;
}
@@ -195,6 +281,17 @@
white-space: nowrap;
color: var(--fg-subtle);
}
.defFrame {
display: flex;
flex-direction: column;
min-height: 0;
outline: none;
}
.defCount {
flex: none;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.defBody {
overflow: auto;
padding: 4px 12px;
@@ -283,3 +380,24 @@
overflow: hidden;
text-overflow: ellipsis;
}
.reformatGroup {
margin: 0 0 10px;
padding: 0;
border: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.reformatLegend {
margin-bottom: 6px;
padding: 0;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.reformatOption {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-ui);
}
+187
Переглянути файл
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { Location } from "@/bindings";
const openFile = vi.fn(async () => undefined);
const show = vi.fn();
vi.mock("@/workbench/contributions", async (orig) => {
const actual = await orig<typeof import("@/workbench/contributions")>();
return {
...actual,
editorService: () => ({ openFile }),
maybeEditorService: () => ({ openFile }),
toolWindowService: () => ({ show, hide: vi.fn(), toggle: vi.fn(), isVisible: () => false }),
};
});
const { LspPopups, DocumentationWindow } = await import("./Popups");
const { closePopup, openPopup, useLspPopup } = await import("./popupStore");
const { useDocWindow, pushHistory, stepHistory, hoverCandidates } = await import("./docs");
const loc = (path: string, line: number): Location => ({
path,
range: { start: { line, col: 4 }, end: { line, col: 7 } },
});
afterEach(() => {
act(() => closePopup());
vi.clearAllMocks();
});
describe("list popup", () => {
it("numbers rows 1-9 and runs a row by its number", () => {
const run = vi.fn();
openPopup({
kind: "list",
anchor: { x: 0, y: 0 },
title: "Refactor This",
numbered: true,
items: [
{ id: "a", label: "Rename…", run: vi.fn() },
{ id: "b", label: "Inline…", run },
],
});
render(<LspPopups />);
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["1Rename…", "2Inline…"]);
fireEvent.keyDown(screen.getByRole("listbox"), { key: "2" });
expect(run).toHaveBeenCalledOnce();
expect(useLspPopup.getState().popup).toBeNull();
});
it("reports the selected row and cleans up on close", () => {
const onActive = [vi.fn(), vi.fn()];
const onClose = vi.fn();
openPopup({
kind: "list",
anchor: { x: 0, y: 0 },
title: "Expressions",
items: onActive.map((fn, i) => ({ id: `${i}`, label: `e${i}`, run: vi.fn(), onActive: fn })),
onClose,
});
render(<LspPopups />);
expect(onActive[0]).toHaveBeenCalledOnce();
fireEvent.keyDown(screen.getByRole("listbox"), { key: "ArrowDown" });
expect(onActive[1]).toHaveBeenCalledOnce();
act(() => closePopup());
expect(onClose).toHaveBeenCalledOnce();
});
it("pins the results elsewhere from the header button", () => {
const pin = vi.fn();
openPopup({
kind: "list",
anchor: { x: 0, y: 0 },
title: "Choose Declaration of foo (2 found)",
items: [{ id: "a", label: "foo", run: vi.fn() }],
pin: { label: "Open in Find Tool Window", run: pin },
});
render(<LspPopups />);
fireEvent.click(screen.getByRole("button", { name: "Open in Find Tool Window" }));
expect(pin).toHaveBeenCalledOnce();
expect(useLspPopup.getState().popup).toBeNull();
});
it("shows the selected row's preview, fetched lazily", async () => {
vi.useFakeTimers();
try {
const preview = vi.fn(async () => ({ kind: "diff" as const, lines: [{ kind: "added" as const, text: "let x = 1;" }] }));
openPopup({
kind: "list",
anchor: { x: 0, y: 0 },
title: "Intentions",
items: [{ id: "a", label: "Introduce local variable", run: vi.fn() }],
preview,
});
render(<LspPopups />);
expect(preview).not.toHaveBeenCalled();
await act(() => vi.advanceTimersByTimeAsync(200));
expect(preview).toHaveBeenCalledOnce();
expect(screen.getByLabelText("Preview").textContent).toBe("let x = 1;");
} finally {
vi.useRealTimers();
}
});
});
describe("documentation", () => {
it("history helpers drop forward entries and clamp steps", () => {
const e = (v: string) => ({ markup: { kind: "plaintext" as const, value: v }, language: null });
const h = pushHistory({ entries: [e("a"), e("b"), e("c")], index: 0 }, e("d"));
expect(h.entries.map((x) => x.markup.value)).toEqual(["a", "d"]);
expect(h.index).toBe(1);
expect(stepHistory(h, 1)).toBe(1);
expect(stepHistory(h, -1)).toBe(0);
});
it("asks about the declared name when a link points at the start of a declaration", () => {
expect(hoverCandidates("export class Foo extends Bar {", 0)).toEqual([0, 13, 25]);
});
it("walks the popup history with ← / → and opens the source with F4", async () => {
const source = vi.fn(async () => loc("/p/b.ts", 3));
openPopup({
kind: "doc",
anchor: { x: 0, y: 0 },
entries: [
{ markup: { kind: "plaintext", value: "first" }, language: null },
{ markup: { kind: "plaintext", value: "second" }, language: null, source },
],
index: 1,
themeClasses: "",
});
const { container } = render(<LspPopups />);
const frame = () => document.querySelector<HTMLElement>("[data-lsp-doc]")!;
expect(frame().textContent).toContain("second");
fireEvent.keyDown(frame(), { key: "ArrowLeft" });
expect(frame().textContent).toContain("first");
expect(screen.queryByRole("button", { name: "Edit Source" })).toBeNull();
fireEvent.keyDown(frame(), { key: "ArrowRight" });
expect(frame().textContent).toContain("second");
fireEvent.keyDown(frame(), { key: "F4" });
await vi.waitFor(() => expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 3, col: 4 }, focus: true }));
expect(container).toBeTruthy();
});
it("moves the popup to the Documentation tool window", () => {
openPopup({
kind: "doc",
anchor: { x: 0, y: 0 },
entries: [{ markup: { kind: "plaintext", value: "docs" }, language: null }],
index: 0,
themeClasses: "",
});
render(
<>
<LspPopups />
<DocumentationWindow />
</>,
);
fireEvent.click(screen.getByRole("button", { name: "Show in Documentation Tool Window" }));
expect(useLspPopup.getState().popup).toBeNull();
expect(show).toHaveBeenCalledWith("documentation", true);
expect(useDocWindow.getState().entries).toHaveLength(1);
});
});
describe("quick definition popup", () => {
it("switches between several definitions with ← / → and edits the source with F4", () => {
openPopup({
kind: "definition",
anchor: { x: 0, y: 0 },
targets: [
{ location: loc("/p/a.ts", 1), name: "run", firstLine: 0, text: "function run() {}", language: null },
{ location: loc("/p/b.ts", 5), name: "run", firstLine: 4, text: "run(): void;", language: null },
],
index: 0,
themeClasses: "",
});
render(<LspPopups />);
const frame = () => screen.getByRole("dialog", { name: "Definition" }).firstElementChild as HTMLElement;
expect(screen.getByText("1/2")).toBeTruthy();
fireEvent.keyDown(frame(), { key: "ArrowRight" });
expect(screen.getByText("2/2")).toBeTruthy();
expect(frame().textContent).toContain("run(): void;");
fireEvent.keyDown(frame(), { key: "F4" });
expect(openFile).toHaveBeenCalledWith("/p/b.ts", { selection: { line: 5, col: 4 }, focus: true });
});
});
+332 -56
Переглянути файл
@@ -2,13 +2,15 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import {
Button,
Checkbox,
Dialog,
HighlightedText,
Icon,
IconButton,
Input,
List,
Popup,
Select,
SymbolBadge,
fileIconName,
fuzzyMatch,
@@ -16,7 +18,24 @@ import {
} from "@/ui";
import { basename, relativePath, useProject } from "@/platform/project";
import { editorService } from "@/workbench/contributions";
import { closePopup, useLspPopup, type ListPopupItem, type LspPopup } from "./popupStore";
import {
closePopup,
updatePopup,
useLspPopup,
type ListPopupItem,
type ListPreview,
type LspPopup,
} from "./popupStore";
import { shortcutLabel } from "@/platform/keymap";
import {
docLinkHandler,
docStep,
editDocSource,
moveDocPopupToWindow,
useDocWindow,
type DocHistory,
type DocHost,
} from "./docs";
import { renderCode, renderMarkup } from "./markdown";
import { describeSummary } from "./workspaceEdit";
import { useUsagesStatus } from "./navigation";
@@ -33,6 +52,27 @@ function ItemIcon({ item }: { item: ListPopupItem }) {
return <span className={styles.iconSpacer} />;
}
function ListPreviewPanel({ preview }: { preview: ListPreview | null | "loading" }) {
if (preview === "loading") return <div className={styles.previewPanel} aria-busy="true" />;
if (!preview) return null;
return (
<div className={styles.previewPanel} aria-label="Preview">
{preview.kind === "text" ? (
<div className={styles.previewText}>{preview.text}</div>
) : (
<pre className={styles.diff}>
{preview.lines.map((l, i) => (
<div key={i} className={clsx(styles.diffLine, styles[`diff-${l.kind}`])}>
{l.kind === "gap" ? "⋯" : l.text || " "}
</div>
))}
{preview.note && <div className={styles.previewText}>{preview.note}</div>}
</pre>
)}
</div>
);
}
function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
@@ -45,6 +85,30 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
);
const [active, setActive] = useState(firstEnabled);
const activeIndex = Math.min(active, Math.max(0, filtered.length - 1));
const activeItem = filtered[activeIndex]?.item;
// Choosers highlight what the selected row would act on.
useEffect(() => {
activeItem?.onActive?.();
}, [activeItem]);
const [preview, setPreview] = useState<{ id: string; data: ListPreview | null } | null>(null);
const fetchPreview = popup.preview;
useEffect(() => {
if (!fetchPreview || !activeItem || activeItem.disabled) return;
const ctrl = new AbortController();
// Moving quickly through the list must not queue a resolve per row.
const timer = setTimeout(() => {
fetchPreview(activeItem, ctrl.signal).then(
(data) => !ctrl.signal.aborted && setPreview({ id: activeItem.id, data }),
() => !ctrl.signal.aborted && setPreview({ id: activeItem.id, data: null }),
);
}, 120);
return () => {
clearTimeout(timer);
ctrl.abort();
};
}, [activeItem, fetchPreview]);
const activate = (item: ListPopupItem) => {
if (item.disabled) return;
@@ -54,7 +118,11 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === "Backspace" && query) {
if (popup.numbered && !query && /^[1-9]$/.test(e.key)) {
const item = popup.items[Number(e.key) - 1];
e.preventDefault();
if (item) activate(item);
} else if (e.key === "Backspace" && query) {
setQuery(query.slice(0, -1));
setActive(0);
e.preventDefault();
@@ -69,55 +137,82 @@ function ListPopup({ popup }: { popup: Extract<LspPopup, { kind: "list" }> }) {
// Rows are virtualized (absolutely positioned), so size the popup from the content.
const width = useMemo(() => {
const chars = Math.max(
popup.title.length,
popup.title.length + (popup.pin ? 4 : 0),
...popup.items.map(
(i) => i.label.length + (i.preview?.length ?? 0) + (i.detail?.length ?? 0) + (i.shortcut?.length ?? 0),
),
);
return Math.min(720, Math.max(320, chars * 7 + 90));
return Math.min(720, Math.max(320, chars * 7 + 90 + (popup.numbered ? 16 : 0)));
}, [popup]);
const shownPreview = popup.preview
? preview && preview.id === activeItem?.id
? preview.data
: activeItem && !activeItem.disabled
? ("loading" as const)
: null
: null;
const indexOf = (item: ListPopupItem) => popup.items.indexOf(item);
return (
<Popup
open
anchor={popup.anchor}
onClose={closePopup}
aria-label={popup.title}
className={styles.listPopup}
style={{ width }}
className={clsx(styles.listPopup, popup.preview && styles.withPreview)}
style={popup.preview ? undefined : { width }}
>
<div className={styles.listHeader}>
<span className={styles.listTitle}>{popup.title}</span>
{query && <span className={styles.query}>{query}</span>}
</div>
<div onKeyDown={onKeyDown} className={styles.listBody} style={{ height }}>
<List
items={filtered}
getKey={(r) => r.item.id}
activeIndex={activeIndex}
onActiveChange={setActive}
hoverSelects
aria-label={popup.title}
onActivate={(r) => activate(r.item)}
empty={
<span className={styles.empty}>
<T>Nothing matches</T>
</span>
}
renderItem={({ item, match }) => (
<div
className={clsx(styles.item, item.disabled && styles.disabled, item.emphasis && styles.emphasis)}
title={item.disabled ?? undefined}
>
<ItemIcon item={item} />
<HighlightedText className={styles.label} text={item.label} indices={match} tone="accent" />
{item.preview && <span className={styles.preview}>{item.preview}</span>}
<span className={styles.spacer} />
{item.detail && <span className={styles.detail}>{item.detail}</span>}
{item.shortcut && <span className={styles.shortcut}>{item.shortcut}</span>}
</div>
<div className={styles.listMain} style={popup.preview ? { width } : undefined}>
<div className={styles.listHeader}>
<span className={styles.listTitle}>{popup.title}</span>
{query && <span className={styles.query}>{query}</span>}
{popup.pin && (
<IconButton
icon="pin"
size="small"
title={popup.pin.label}
onClick={() => {
const run = popup.pin!.run;
closePopup();
run();
}}
/>
)}
/>
</div>
<div onKeyDown={onKeyDown} className={styles.listBody} style={{ height }}>
<List
items={filtered}
getKey={(r) => r.item.id}
activeIndex={activeIndex}
onActiveChange={setActive}
hoverSelects
aria-label={popup.title}
onActivate={(r) => activate(r.item)}
empty={
<span className={styles.empty}>
<T>Nothing matches</T>
</span>
}
renderItem={({ item, match }) => {
const n = popup.numbered && !query ? indexOf(item) + 1 : 0;
return (
<div
className={clsx(styles.item, item.disabled && styles.disabled, item.emphasis && styles.emphasis)}
title={item.disabled ?? undefined}
>
{popup.numbered && <span className={styles.mnemonic}>{n > 0 && n <= 9 ? n : ""}</span>}
<ItemIcon item={item} />
<HighlightedText className={styles.label} text={item.label} indices={match} tone="accent" />
{item.preview && <span className={styles.preview}>{item.preview}</span>}
<span className={styles.spacer} />
{item.detail && <span className={styles.detail}>{item.detail}</span>}
{item.shortcut && <span className={styles.shortcut}>{item.shortcut}</span>}
</div>
);
}}
/>
</div>
</div>
{popup.preview && <ListPreviewPanel preview={shownPreview} />}
</Popup>
);
}
@@ -209,25 +304,112 @@ function DomContent({ build, className }: { build: () => HTMLElement; className?
return <div ref={ref} className={className} />;
}
/** Documentation with its history toolbar; shared by the popup and the Documentation tool window. */
function DocView({ host, history, themeClasses }: { host: DocHost; history: DocHistory; themeClasses: string }) {
const entry = history.entries[history.index];
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
docStep(host, e.key === "ArrowLeft" ? -1 : 1);
} else if (e.key === "F4" && entry?.source) {
e.preventDefault();
void editDocSource(host);
}
};
const ref = useRef<HTMLDivElement>(null);
return (
<div
ref={ref}
tabIndex={-1}
data-lsp-doc={host}
className={clsx(themeClasses, host === "popup" ? styles.docFrame : styles.docWindowFrame)}
onKeyDown={onKeyDown}
>
{entry && (
<DomContent
key={`${history.index}:${history.entries.length}`}
className={styles.docBody}
build={() => renderMarkup(entry.markup, entry.language, docLinkHandler(host))}
/>
)}
<div className={styles.docToolbar}>
<IconButton
icon="arrow-left"
size="small"
title="Back"
shortcut="←"
disabled={history.index === 0}
onClick={() => docStep(host, -1)}
/>
<IconButton
icon="arrow-right"
size="small"
title="Forward"
shortcut="→"
disabled={history.index >= history.entries.length - 1}
onClick={() => docStep(host, 1)}
/>
<span className={styles.spacer} />
{entry?.source && (
<IconButton icon="edit" size="small" title="Edit Source" shortcut="F4" onClick={() => void editDocSource(host)} />
)}
{host === "popup" && (
<IconButton
icon="open-in-tool-window"
size="small"
title="Show in Documentation Tool Window"
shortcut={shortcutLabel("code.quickDocumentation") || undefined}
onClick={() => moveDocPopupToWindow()}
/>
)}
</div>
</div>
);
}
function DocPopup({ popup }: { popup: Extract<LspPopup, { kind: "doc" }> }) {
const focusSeq = useLspPopup((s) => s.focusSeq);
const scope = useRef<HTMLDivElement>(null);
const opened = useRef(focusSeq);
useEffect(() => {
if (focusSeq !== opened.current) scope.current?.focus({ preventScroll: true });
if (focusSeq !== opened.current) scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
}, [focusSeq]);
return (
<Popup open anchor={popup.anchor} onClose={closePopup} aria-label="Documentation" className={styles.docPopup}>
<div ref={scope} tabIndex={-1} className={clsx(popup.themeClasses, styles.docScope)}>
<DomContent className={styles.docBody} build={() => renderMarkup(popup.markup, popup.language)} />
<Popup
open
anchor={popup.anchor}
onClose={closePopup}
aria-label="Documentation"
className={styles.docPopup}
// As in IDEA the popup leaves the keyboard to the editor until F1 focuses it.
autoFocus={false}
>
<div ref={scope} className={styles.docScope}>
<DocView host="popup" history={popup} themeClasses={popup.themeClasses} />
</div>
</Popup>
);
}
/** The Documentation tool window (F1 on a focused documentation popup). */
export function DocumentationWindow() {
const history = useDocWindow();
if (!history.entries.length) {
return (
<div className={styles.docWindowEmpty}>
<T>No documentation</T>
</div>
);
}
return <DocView host="window" history={history} themeClasses={history.themeClasses} />;
}
function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definition" }> }) {
const root = useProject((s) => s.project?.root ?? "");
const { location } = popup;
const target = popup.targets[popup.index]!;
const { location } = target;
const many = popup.targets.length > 1;
const open = () => {
closePopup();
void editorService().openFile(location.path, {
@@ -235,21 +417,56 @@ function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definiti
focus: true,
});
};
const select = (index: number) =>
updatePopup("definition", { index: (index + popup.targets.length) % popup.targets.length });
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (many && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
e.preventDefault();
select(popup.index + (e.key === "ArrowLeft" ? -1 : 1));
} else if (e.key === "F4") {
e.preventDefault();
open();
}
};
return (
<Popup open anchor={popup.anchor} onClose={closePopup} aria-label="Definition" className={styles.defPopup}>
<div className={styles.defHeader}>
<Icon name={fileIconName(basename(location.path))} size={14} />
<span className={styles.defFile}>{basename(location.path)}</span>
<span className={styles.defPath}>
{relativePath(root, location.path)}:{location.range.start.line + 1}
</span>
<span className={styles.spacer} />
<Button size="small" variant="ghost" onClick={open}>
<T>Open</T>
</Button>
</div>
<div className={clsx(popup.themeClasses, styles.docScope)}>
<DomContent className={styles.defBody} build={() => renderCode(popup.text, popup.language, "cm-lsp-code")} />
<div className={styles.defFrame} tabIndex={-1} data-autofocus onKeyDown={onKeyDown}>
<div className={styles.defHeader}>
<Icon name={fileIconName(basename(location.path))} size={14} />
{many ? (
<Select
variant="ghost"
aria-label="Definition"
value={String(popup.index)}
options={popup.targets.map((t, i) => ({
value: String(i),
label: t.name,
detail: `${basename(t.location.path)}:${t.location.range.start.line + 1}`,
}))}
onChange={(v) => select(Number(v))}
/>
) : (
<span className={styles.defFile}>{basename(location.path)}</span>
)}
<span className={styles.defPath}>
{relativePath(root, location.path)}:{location.range.start.line + 1}
</span>
<span className={styles.spacer} />
{many && (
<span className={styles.defCount}>
{popup.index + 1}/{popup.targets.length}
</span>
)}
<IconButton icon="edit" size="small" title="Edit Source" shortcut="F4" onClick={open} />
</div>
<div className={clsx(popup.themeClasses, styles.docScope)}>
<DomContent
key={popup.index}
className={styles.defBody}
build={() => renderCode(target.text, target.language, "cm-lsp-code")}
/>
</div>
</div>
</Popup>
);
@@ -326,6 +543,63 @@ function PreviewDialog({ popup }: { popup: Extract<LspPopup, { kind: "preview" }
);
}
/** Reformat File… (⌥⇧⌘L): scope and options, then Reformat Code. */
function ReformatDialog({ popup }: { popup: Extract<LspPopup, { kind: "reformat" }> }) {
const [selectionOnly, setSelectionOnly] = useState(popup.hasSelection && popup.initial.selectionOnly);
const [optimizeImports, setOptimizeImports] = useState(popup.canOptimizeImports && popup.initial.optimizeImports);
return (
<Dialog
open
onClose={closePopup}
title={popup.title}
width={380}
buttons={[
{
label: "Run",
role: "default",
autoFocus: true,
onClick: () => {
closePopup();
popup.onRun({ selectionOnly, optimizeImports });
},
},
{ label: "Cancel", role: "cancel" },
]}
>
<fieldset className={styles.reformatGroup}>
<legend className={styles.reformatLegend}>
<T>Scope</T>
</legend>
<label className={styles.reformatOption}>
<input type="radio" name="reformat-scope" checked={!selectionOnly} onChange={() => setSelectionOnly(false)} />
<T>Whole file</T>
</label>
<label className={clsx(styles.reformatOption, !popup.hasSelection && styles.disabled)}>
<input
type="radio"
name="reformat-scope"
checked={selectionOnly}
disabled={!popup.hasSelection}
onChange={() => setSelectionOnly(true)}
/>
<T>Selected text</T>
</label>
</fieldset>
<fieldset className={styles.reformatGroup}>
<legend className={styles.reformatLegend}>
<T>Options</T>
</legend>
<Checkbox
checked={optimizeImports}
disabled={!popup.canOptimizeImports}
onChange={setOptimizeImports}
label="Optimize imports"
/>
</fieldset>
</Dialog>
);
}
/** Status bar text while ⌘⇧F7 usages are highlighted ("3 usages of 'x' found…"). */
export function UsagesStatus() {
const text = useUsagesStatus((s) => s.text);
@@ -353,5 +627,7 @@ export function LspPopups() {
return <DefinitionPopup key={seq} popup={popup} />;
case "preview":
return <PreviewDialog key={seq} popup={popup} />;
case "reformat":
return <ReformatDialog key={seq} popup={popup} />;
}
}
+4 -1
Переглянути файл
@@ -13,7 +13,7 @@ import { gotoDiagnostic } from "./diagnostics";
import { applyKindSilently, runKindAction, showIntentions } from "./intentions";
import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSettings";
import { startRename } from "./rename";
import { formatDocument } from "./format";
import { formatDocument, reformatDialog } from "./format";
import { showHint } from "./hint";
import { refactorThis, runRefactoring, safeDelete } from "@/editor/refactor";
@@ -114,6 +114,9 @@ export function registerLspActions(): () => void {
editorAction("code.reformat", "Reformat Code", "Code", reformat, {
keywords: ["format", "prettier"],
}),
editorAction("code.reformatDialog", "Reformat File…", "Code", (v) =>
reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")),
),
editorAction("code.optimizeImports", "Optimize Imports", "Code", optimizeImports, { keywords: ["organize imports"] }),
// Project view / editor tab variants: `{ path }` of a file, opened first.
{ id: "code.reformatFile", title: "Reformat File", category: "Code", hidden: true, when: "projectOpen", run: inFile(reformat) },
+20
Переглянути файл
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import type { OutlineItem } from "@/platform/ipc";
import { lensTargets } from "./codeLens";
const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => {
const r = { start: { line, col: 0 }, end: { line, col: 1 } };
return { name, kind, detail: null, range: r, selection_range: r, children };
};
describe("usages lens targets", () => {
it("skips callbacks and anonymous functions", () => {
const items = [
item("Cart", "class", 0, [
item("count", "method", 1, [item("reduce() callback", "function", 2)]),
item("<function>", "function", 3),
]),
];
expect(lensTargets(items).map((t) => t.name)).toEqual(["Cart", "count"]);
});
});
+4 -1
Переглянути файл
@@ -40,12 +40,15 @@ export interface LensTarget {
col: number;
}
/** Servers name callbacks and anonymous functions (`<function>`, `map() callback`); they have no usages. */
const anonymous = (name: string) => /^<.*>$/.test(name) || / callback$/.test(name);
/** Declarations worth a usages lens, in document order. Exported for tests. */
export function lensTargets(items: readonly OutlineItem[]): LensTarget[] {
const out: LensTarget[] = [];
const walk = (list: readonly OutlineItem[]) => {
for (const it of list) {
if (LENS_KINDS.has(it.kind)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col });
if (LENS_KINDS.has(it.kind) && !anonymous(it.name)) out.push({ name: it.name, line: it.selection_range.start.line, col: it.selection_range.start.col });
if (it.children.length) walk(it.children);
}
};
+19 -2
Переглянути файл
@@ -351,10 +351,27 @@ export function matchesCase(label: string, typed: string, mode: string): boolean
return i >= typed.length;
}
/**
* A source another feature restricts the lookup to, via the
* `openideaExclusiveCompletion` language-data key (`(state) => source | null`;
* ⌘J's live templates).
*/
function exclusiveSource(ctx: CompletionContext): CompletionSource | null {
type Provider = (state: CompletionContext["state"]) => CompletionSource | null;
for (const f of ctx.state.languageDataAt<Provider>("openideaExclusiveCompletion", ctx.pos)) {
const source = f(ctx.state);
if (source) return source;
}
return null;
}
/** Show suggestions as you type: sources only answer explicit requests when it is off. */
function gated(source: CompletionSource): CompletionSource {
return (ctx) =>
!ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx);
return (ctx) => {
const only = exclusiveSource(ctx);
if (only) return source === contributedSource ? only(ctx) : null;
return !ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx);
};
}
// ---------------------------------------------------------------------------
+163
Переглянути файл
@@ -0,0 +1,163 @@
/**
* Quick Documentation navigation, as in IDEA: links to other symbols open
* their documentation in place (⌘-click opens the source instead), ← / →
* walk the history, Edit Source (F4) jumps to the declaration, and F1 on a
* focused popup moves it to the Documentation tool window, which then shows
* F1's documentation instead of a popup while it is open.
*/
import { create } from "zustand";
import { commands, nextRequestId, type Location } from "@/platform/ipc";
import { hasPrimaryModifier } from "@/platform/keymap";
import { editorService, maybeEditorService, toolWindowService } from "@/workbench/contributions";
import { closePopup, updatePopup, useLspPopup, type DocEntry } from "./popupStore";
import { isEmptyMarkup, languageForPath, sourceLink, type DocLinkHandler } from "./markdown";
import { lspRequest } from "./lsp";
import { readFileLines, withDocument } from "./files";
export const DOCUMENTATION_WINDOW = "documentation";
export interface DocHistory {
entries: DocEntry[];
index: number;
}
/** History after following a link from the shown entry: forward entries are dropped. */
export function pushHistory(h: DocHistory, entry: DocEntry): DocHistory {
return { entries: [...h.entries.slice(0, h.index + 1), entry], index: h.index + 1 };
}
/** History index after ← (-1) / → (+1); unchanged at the ends. */
export function stepHistory(h: DocHistory, delta: -1 | 1): number {
return Math.max(0, Math.min(h.entries.length - 1, h.index + delta));
}
// ---------------------------------------------------------------------------
// Documentation tool window
interface DocWindowState extends DocHistory {
themeClasses: string;
}
export const useDocWindow = create<DocWindowState>(() => ({ entries: [], index: 0, themeClasses: "" }));
export function docWindowVisible(): boolean {
try {
return toolWindowService().isVisible(DOCUMENTATION_WINDOW);
} catch {
return false;
}
}
/** Shows `entry` in the Documentation tool window (a new history step). */
export function showInDocWindow(entry: DocEntry, themeClasses: string) {
useDocWindow.setState((s) => ({ ...pushHistory(s, entry), themeClasses: themeClasses || s.themeClasses }));
toolWindowService().show(DOCUMENTATION_WINDOW, false);
}
/** F1 on a focused documentation popup: the popup's history moves to the tool window. */
export function moveDocPopupToWindow(): boolean {
const popup = useLspPopup.getState().popup;
if (popup?.kind !== "doc") return false;
useDocWindow.setState({ entries: popup.entries, index: popup.index, themeClasses: popup.themeClasses });
closePopup();
toolWindowService().show(DOCUMENTATION_WINDOW, true);
return true;
}
// ---------------------------------------------------------------------------
// Shared by the popup and the tool window
/** Where a doc view keeps its history: the popup or the tool window. */
export type DocHost = "popup" | "window";
function history(host: DocHost): DocHistory | null {
if (host === "window") return useDocWindow.getState();
const p = useLspPopup.getState().popup;
return p?.kind === "doc" ? p : null;
}
function setHistory(host: DocHost, h: DocHistory) {
if (host === "window") useDocWindow.setState(h);
else updatePopup("doc", h);
}
export function docStep(host: DocHost, delta: -1 | 1) {
const h = history(host);
if (h) setHistory(host, { entries: h.entries, index: stepHistory(h, delta) });
}
/** F4 / Edit Source: the documented element's declaration. */
export async function editDocSource(host: DocHost): Promise<boolean> {
const h = history(host);
const entry = h?.entries[h.index];
const loc = (await entry?.source?.().catch(() => null)) ?? null;
if (!loc) return false;
if (host === "popup") closePopup();
await editorService().openFile(loc.path, { selection: { line: loc.range.start.line, col: loc.range.start.col }, focus: true });
return true;
}
const DECLARATION_KEYWORDS =
/^(export|default|declare|abstract|public|private|protected|internal|static|final|async|const|let|var|val|function|fun|fn|func|def|class|interface|struct|enum|trait|type|impl|pub|mod|object|record|override|readonly|open|data|sealed|extends|implements)$/;
/**
* Positions on the linked line worth asking the server about: the link's own
* column, then the identifiers after declaration keywords (links often point
* at the start of `export class Foo`).
*/
export function hoverCandidates(lineText: string, col: number): number[] {
const out = [col];
const re = /[A-Za-z_$][\w$]*/g;
for (let m = re.exec(lineText); m && out.length < 5; m = re.exec(lineText)) {
if (m.index < col || DECLARATION_KEYWORDS.test(m[0])) continue;
if (!out.includes(m.index)) out.push(m.index);
}
return out;
}
async function docAt(target: { path: string; line: number; col: number }): Promise<DocEntry | null> {
const line = (await readFileLines(target.path))?.[target.line] ?? "";
return withDocument(target.path, async (doc) => {
for (const col of hoverCandidates(line, target.col)) {
const hover = await lspRequest(() => commands.lspHover(nextRequestId(), doc, { line: target.line, col })).catch(
() => null,
);
if (hover && !isEmptyMarkup(hover.contents)) {
const location: Location = {
path: target.path,
range: { start: { line: target.line, col }, end: { line: target.line, col } },
};
return { markup: hover.contents, language: languageForPath(target.path), source: async () => location };
}
}
return null;
});
}
/**
* Link clicks in a documentation view: a source link shows that symbol's
* documentation in the same view (⌘-click opens the source instead).
*/
export function docLinkHandler(host: DocHost): DocLinkHandler {
return (href, e) => {
const target = sourceLink(href);
if (!target || hasPrimaryModifier(e)) {
if (host === "popup" && target) closePopup();
return false;
}
void (async () => {
const entry = await docAt(target).catch(() => null);
const h = history(host);
if (!entry || !h) {
if (host === "popup") closePopup();
void maybeEditorService()?.openFile(target.path, {
selection: { line: target.line, col: target.col },
focus: true,
});
return;
}
setHistory(host, pushHistory(h, entry));
})();
return true;
};
}
+15
Переглянути файл
@@ -7,6 +7,7 @@ import { splitSignature } from "./signature";
import { dedupeLocations, usagesStatusText } from "./navigation";
import { declarationSummary } from "./markdown";
import { linkedSpans } from "./rename";
import { definitionSpan } from "./hover";
describe("semanticRanges", () => {
const doc = Text.of(["let mut count = Foo::new();", "const MAX: u32 = 1;"]);
@@ -120,3 +121,17 @@ describe("linkedSpans", () => {
]);
});
});
describe("definitionSpan", () => {
const loc = { path: "/a.ts", range: { start: { line: 40, col: 9 }, end: { line: 40, col: 12 } } };
const range = (a: number, b: number) => ({ start: { line: a, col: 0 }, end: { line: b, col: 1 } });
it("shows the whole enclosing declaration when the syntax tree knows it", () => {
expect(definitionSpan(loc, 500, range(38, 120))).toEqual({ first: 38, last: 120 });
});
it("falls back to a window around the name otherwise", () => {
expect(definitionSpan(loc, 500, null)).toEqual({ first: 39, last: 52 });
expect(definitionSpan(loc, 500, range(0, 10))).toEqual({ first: 39, last: 52 });
});
});
+28 -1
Переглянути файл
@@ -1,4 +1,4 @@
/** Reading other files' text for previews (definitions, choosers). */
/** Reading other files' text for previews (definitions, choosers), and backend documents for them. */
import { editorsFor } from "@/editor/api";
import { call, commands, isTauri } from "@/platform/ipc";
import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs";
@@ -34,3 +34,30 @@ export async function readManyFileLines(paths: readonly string[], concurrency =
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
return out;
}
/**
* Runs `fn` with a backend document id for `path` (an open editor's when there
* is one; otherwise opened for the call only, the backend ref-counts it).
*/
export async function withDocument<T>(path: string, fn: (doc: number) => Promise<T>): Promise<T> {
const open = editorsFor(path)[0];
if (open) return fn(open.ctx.doc);
const info = await call(isVirtualUri(path) ? commands.docOpenVirtual(path) : commands.docOpen(path));
try {
return await fn(info.id);
} finally {
void commands.docClose(info.id);
}
}
/** The identifier at (or starting right after) `col` of `line`; null when there is none. */
export function identifierAt(line: string, col: number): string | null {
const re = /[\p{L}\p{N}_$]+/gu;
for (let m = re.exec(line); m; m = re.exec(line)) {
const end = m.index + m[0].length;
if (end < col) continue;
if (m.index > col + 1) return null;
return /^\p{N}/u.test(m[0]) ? null : m[0];
}
return null;
}
+28
Переглянути файл
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import { formattedLines } from "./format";
describe("formattedLines", () => {
const doc = Text.of(["fn main(){", "let x=1;", "}", "", "fn other() {}"]);
const lineStart = (n: number) => doc.line(n).from;
it("counts the lines of the result the changes touched", () => {
expect(
formattedLines(doc, [
{ from: lineStart(1) + 2, to: lineStart(1) + 4, insert: " " },
{ from: lineStart(1) + 10, to: lineStart(1) + 10, insert: " " },
{ from: lineStart(2), to: lineStart(2), insert: " " },
]),
).toBe(2);
});
it("numbers lines as in the new text when a change adds lines", () => {
// `{}` split over two lines, then a change on (old) line 5 = new line 6.
expect(
formattedLines(doc, [
{ from: lineStart(5) + 11, to: lineStart(5) + 12, insert: "{\n" },
{ from: lineStart(1), to: lineStart(1) + 1, insert: "f" },
]),
).toBe(3);
});
});
+97 -12
Переглянути файл
@@ -1,28 +1,79 @@
/**
* Reformat Code (⌥⌘L): `lsp_format` for the selection (or the whole file),
* applied locally as one CodeMirror transaction and one undo step; editor/core
* syncs it to the backend like any user edit.
* syncs it to the backend like any user edit. As in IDEA an explicit reformat
* reports "Formatted N lines" with a link to the Reformat File dialog
* (⌥⇧⌘L: scope and Optimize Imports).
*/
import { indentUnit } from "@codemirror/language";
import { isolateHistory } from "@codemirror/commands";
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { executeAction } from "@/platform/actions";
import { CONTRIBUTED_FORMAT, contributedAt, whenSynced, type ContributedFormatter } from "@/editor/api";
import { docOf, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, textEditsToChanges } from "./positions";
import { showHint } from "./hint";
import { openPopup, type ReformatOptions } from "./popupStore";
export interface FormatResult {
applied: boolean;
/** Number of changed regions. */
changes: number;
/** Lines of the result that the changes touched. */
lines: number;
}
export async function formatDocument(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true }): Promise<FormatResult> {
const NOTHING: FormatResult = { applied: false, changes: 0, lines: 0 };
/** Lines of the new document touched by `changes` (given against `doc`), as IDEA counts "Formatted N lines". */
export function formattedLines(doc: Text, changes: readonly { from: number; to: number; insert: string }[]): number {
const touched = new Set<number>();
// Line shift of the changes so far, to number lines as in the new text.
let shift = 0;
for (const c of [...changes].sort((a, b) => a.from - b.from)) {
const startLine = doc.lineAt(c.from).number + shift;
const insertedLines = c.insert.split("\n").length - 1;
for (let n = startLine; n <= startLine + insertedLines; n++) touched.add(n);
shift += insertedLines - (doc.lineAt(c.to).number - doc.lineAt(c.from).number);
}
return touched.size;
}
function formattedHint(view: EditorView, lines: number) {
const keys = shortcutLabel("code.reformatDialog");
showHint(view, () => {
const box = document.createElement("div");
box.textContent = `Formatted ${lines} ${lines === 1 ? "line" : "lines"}`;
const more = document.createElement("div");
const link = document.createElement("button");
link.type = "button";
link.className = "cm-lsp-link-button";
link.textContent = "Show";
link.addEventListener("mousedown", (e) => e.preventDefault());
link.addEventListener("click", () => void executeAction("code.reformatDialog"));
more.append(link, ` the Reformat dialog${keys ? `: ${keys}` : ""}`);
box.appendChild(more);
return box;
});
}
export async function formatDocument(
view: EditorView,
opts: { explicit: boolean; selectionOnly?: boolean } = { explicit: true },
): Promise<FormatResult> {
const result = await format(view, opts);
if (opts.explicit && result.applied) formattedHint(view, result.lines);
return result;
}
async function format(view: EditorView, opts: { explicit: boolean; selectionOnly?: boolean }): Promise<FormatResult> {
const own = contributedAt<ContributedFormatter>(view.state, CONTRIBUTED_FORMAT, 0);
if (own) return applyContributedFormat(view, own, opts);
const doc = docOf(view);
if (!doc || doc.readOnly) return { applied: false, changes: 0 };
if (!doc || doc.readOnly) return NOTHING;
// The server formats the backend's copy: let pending keystrokes reach it first.
await whenSynced(view);
const state = view.state;
@@ -35,16 +86,16 @@ export async function formatDocument(view: EditorView, opts: { explicit: boolean
edits = await lspRequest(() => commands.lspFormat(doc.doc, range, options));
} catch (e) {
reportLspError(view, e, opts.explicit);
return { applied: false, changes: 0 };
return NOTHING;
}
if (view.state.doc !== state.doc) {
if (opts.explicit) showHint(view, "The file changed while formatting; try again");
return { applied: false, changes: 0 };
return NOTHING;
}
const changes = textEditsToChanges(state.doc, edits);
if (!changes.length) {
if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted");
return { applied: false, changes: 0 };
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
return NOTHING;
}
view.dispatch({
changes,
@@ -52,7 +103,7 @@ export async function formatDocument(view: EditorView, opts: { explicit: boolean
annotations: isolateHistory.of("full"),
scrollIntoView: false,
});
return { applied: true, changes: changes.length };
return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) };
}
function applyContributedFormat(
@@ -61,16 +112,50 @@ function applyContributedFormat(
opts: { explicit: boolean; selectionOnly?: boolean },
): FormatResult {
const state = view.state;
if (state.readOnly) return { applied: false, changes: 0 };
if (state.readOnly) return NOTHING;
const sel = state.selection.main;
const useSelection = (opts.selectionOnly ?? true) && !sel.empty;
const changes = format(state, useSelection ? { from: sel.from, to: sel.to } : null).filter(
(c) => state.sliceDoc(c.from, c.to) !== c.insert,
);
if (!changes.length) {
if (opts.explicit) showHint(view, useSelection ? "The selection is already formatted" : "The code is already formatted");
return { applied: false, changes: 0 };
if (opts.explicit) showHint(view, "No lines changed: content is already properly formatted");
return NOTHING;
}
view.dispatch({ changes, userEvent: "format", annotations: isolateHistory.of("full"), scrollIntoView: false });
return { applied: true, changes: changes.length };
return { applied: true, changes: changes.length, lines: formattedLines(state.doc, changes) };
}
/** The dialog's last choices, as IDEA remembers them. */
let lastOptions: ReformatOptions = { selectionOnly: true, optimizeImports: false };
/**
* Reformat File… (⌥⇧⌘L): asks for the scope and options, then optimizes
* imports (when chosen) and reformats.
*/
export async function reformatDialog(
view: EditorView,
optimizeImports: (view: EditorView) => Promise<unknown> = async () => {},
): Promise<void> {
const ctx = docOf(view);
if (!ctx || ctx.readOnly) {
if (ctx?.readOnly) showHint(view, "The file is read-only");
return;
}
const sel = view.state.selection.main;
const name = ctx.path.slice(ctx.path.lastIndexOf("/") + 1);
openPopup({
kind: "reformat",
title: `Reformat File: ${name}`,
hasSelection: !sel.empty,
canOptimizeImports: !contributedAt(view.state, CONTRIBUTED_FORMAT, 0),
initial: lastOptions,
onRun: (options) =>
void (async () => {
lastOptions = { ...lastOptions, ...options, selectionOnly: sel.empty ? lastOptions.selectionOnly : options.selectionOnly };
view.focus();
if (options.optimizeImports) await optimizeImports(view);
await formatDocument(view, { explicit: true, selectionOnly: options.selectionOnly });
})(),
});
}
+102 -41
Переглянути файл
@@ -1,11 +1,11 @@
/**
* Hover tooltip (500ms): diagnostics under the pointer + quick documentation,
* plus the explicit actions code.quickDocumentation (F1, sticky popup) and
* code.quickDefinition (⌥Space, definition source popup).
* plus the explicit actions code.quickDocumentation (F1, sticky popup with
* history, `docs.ts`) and code.quickDefinition (⌥Space, definition source popup).
*/
import { EditorSelection } from "@codemirror/state";
import { hoverTooltip, type EditorView } from "@codemirror/view";
import { commands, isTauri, nextRequestId, type Hover, type RequestId } from "@/platform/ipc";
import { commands, isTauri, nextRequestId, type Hover, type Location, type Pos, type Range, type RequestId } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics";
@@ -13,9 +13,12 @@ import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown";
import { docOf, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
import { caretAnchor, focusPopup, openPopup, useLspPopup } from "./popupStore";
import { caretAnchor, focusPopup, openPopup, useLspPopup, type DefinitionTarget, type DocEntry } from "./popupStore";
import { selectedLookupDoc, showLookupDocs } from "./completion";
import { readFileLines } from "./files";
import { identifierAt, readFileLines, withDocument } from "./files";
import { docWindowVisible, moveDocPopupToWindow, showInDocWindow } from "./docs";
import { structure } from "@/editor/structure";
import { basename } from "@/platform/project";
import { iconNode } from "./iconMarkup";
import { editorPrefs } from "@/platform/editorSettings";
import { CONTRIBUTED_HOVER, contributedAt, type ContributedHover } from "@/editor/api";
@@ -118,13 +121,39 @@ export function hoverExtension() {
);
}
/** The documentation popup has the keyboard (F1 pressed twice, or clicked into). */
function docPopupFocused(): boolean {
return !!(document.activeElement as HTMLElement | null)?.closest?.("[data-lsp-doc]");
}
/** Lazily resolved declaration of the element at `pos` (Edit Source). */
function definitionOf(doc: number, pos: Pos): () => Promise<Location | null> {
let found: Promise<Location | null> | null = null;
return () =>
(found ??= lspRequest(() => commands.lspGoto(doc, pos, "definition")).then(
(locs) => locs[0] ?? null,
() => null,
));
}
function showDoc(view: EditorView, entry: DocEntry, at: number) {
if (docWindowVisible()) {
showInDocWindow(entry, view.themeClasses);
return;
}
openPopup({ kind: "doc", anchor: caretAnchor(view, at), entries: [entry], index: 0, themeClasses: view.themeClasses });
}
/**
* F1: documentation for the symbol at the caret in a sticky popup; in an open
* lookup, for its selected item. Again on an open popup: focuses it.
* F1: documentation for the symbol at the caret in a popup (or the
* Documentation tool window while it is open); in an open lookup, for its
* selected item. Again on an open popup: focuses it; once more: moves it to
* the tool window.
*/
export async function quickDocumentation(view: EditorView) {
if (useLspPopup.getState().popup?.kind === "doc") {
focusPopup();
if (docPopupFocused()) moveDocPopupToWindow();
else focusPopup();
return;
}
const pos = view.state.selection.main.head;
@@ -133,14 +162,7 @@ export async function quickDocumentation(view: EditorView) {
if (lookup === "showing") {
const doc = await selectedLookupDoc(view).catch(() => null);
if (!doc) showHint(view, "No documentation found");
else
openPopup({
kind: "doc",
anchor: caretAnchor(view, pos),
markup: doc.markup,
language: doc.language,
themeClasses: view.themeClasses,
});
else showDoc(view, { markup: doc.markup, language: doc.language }, pos);
return;
}
try {
@@ -149,13 +171,16 @@ export async function quickDocumentation(view: EditorView) {
showHint(view, "No documentation found");
return;
}
openPopup({
kind: "doc",
anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos),
markup: hover.contents,
language: docOf(view)?.uiMode ?? null,
themeClasses: view.themeClasses,
});
const ctx = docOf(view);
showDoc(
view,
{
markup: hover.contents,
language: ctx?.uiMode ?? null,
source: ctx ? definitionOf(ctx.doc, offsetToPos(view.state.doc, pos)) : undefined,
},
hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos,
);
} catch (e) {
reportLspError(view, e, true);
}
@@ -163,36 +188,72 @@ export async function quickDocumentation(view: EditorView) {
const DEFINITION_CONTEXT_BEFORE = 1;
const DEFINITION_MAX_LINES = 30;
/** A declaration taller than this is cut (IDEA scrolls; we keep the popup light). */
const DECLARATION_MAX_LINES = 400;
const MAX_TARGETS = 20;
/** ⌥Space: the definition's source in a popup, without leaving the editor. */
/** Line span to show for a definition: its whole declaration when the syntax tree knows it. */
export function definitionSpan(
loc: Location,
lineCount: number,
declaration: Range | null,
): { first: number; last: number } {
const start = loc.range.start.line;
if (declaration && declaration.start.line <= start && start <= declaration.end.line) {
const first = Math.max(0, declaration.start.line);
return { first, last: Math.min(lineCount - 1, declaration.end.line, first + DECLARATION_MAX_LINES - 1) };
}
const first = Math.max(0, start - DEFINITION_CONTEXT_BEFORE);
const spanEnd = Math.max(loc.range.end.line, start + 12);
return { first, last: Math.min(lineCount - 1, Math.min(spanEnd, first + DEFINITION_MAX_LINES - 1)) };
}
async function declarationRange(loc: Location): Promise<Range | null> {
try {
return await withDocument(loc.path, async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null);
} catch {
return null;
}
}
async function definitionTarget(loc: Location, fallbackLanguage: string | null): Promise<DefinitionTarget | null> {
const [lines, declaration] = await Promise.all([readFileLines(loc.path), declarationRange(loc)]);
if (!lines) return null;
const { first, last } = definitionSpan(loc, lines.length, declaration);
const name = identifierAt(lines[loc.range.start.line] ?? "", loc.range.start.col) ?? basename(loc.path);
return {
location: loc,
name,
firstLine: first,
text: lines.slice(first, last + 1).join("\n"),
language: languageForPath(loc.path) ?? fallbackLanguage,
};
}
/** ⌥Space: the definition's source in a popup, without leaving the editor; ← / → switch between several. */
export async function quickDefinition(view: EditorView) {
const doc = docOf(view);
if (!doc) return;
const pos = view.state.selection.main.head;
try {
const locations = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition"));
const loc = locations[0];
if (!loc) {
const found = await lspRequest(() => commands.lspGoto(doc.doc, offsetToPos(view.state.doc, pos), "definition"));
const seen = new Set<string>();
const locations = found.filter((l) => {
const key = `${l.path}:${l.range.start.line}:${l.range.start.col}`;
return !seen.has(key) && !!seen.add(key);
});
if (!locations.length) {
showHint(view, "No definition found");
return;
}
const lines = await readFileLines(loc.path);
if (!lines) {
const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter(
(t): t is DefinitionTarget => !!t,
);
if (!targets.length) {
showHint(view, "Cannot read the definition's file");
return;
}
const first = Math.max(0, loc.range.start.line - DEFINITION_CONTEXT_BEFORE);
const spanEnd = Math.max(loc.range.end.line, loc.range.start.line + 12);
const last = Math.min(lines.length - 1, Math.min(spanEnd, first + DEFINITION_MAX_LINES - 1));
openPopup({
kind: "definition",
anchor: caretAnchor(view, pos),
location: loc,
firstLine: first,
text: lines.slice(first, last + 1).join("\n"),
language: languageForPath(loc.path) ?? doc.uiMode,
themeClasses: view.themeClasses,
});
openPopup({ kind: "definition", anchor: caretAnchor(view, pos), targets, index: 0, themeClasses: view.themeClasses });
} catch (e) {
reportLspError(view, e, true);
}
+17 -3
Переглянути файл
@@ -7,14 +7,14 @@
*/
import type { Extension } from "@codemirror/state";
import { registerEditorContribution, type EditorDocContext } from "@/editor/api";
import { registerOverlay, registerStatusItem } from "@/workbench/contributions";
import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
import { lspDoc } from "./lsp";
import { hintExtension } from "./hint";
import { completionExtension } from "./completion";
import { inplaceRenameExtension } from "./inplaceRename";
import { diagnosticsExtension, installDiagnosticsEvents } from "./diagnostics";
import { hoverExtension } from "./hover";
import { signatureExtension } from "./signature";
import { parameterTabKeys, signatureExtension } from "./signature";
import { navigationExtension } from "./navigation";
import { intentionsExtension } from "./intentions";
import { inlayExtension } from "./inlay";
@@ -26,7 +26,8 @@ import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from
import { lspTheme } from "./theme";
import { inspectionWidgetExtension } from "./inspectionWidget";
import { unresolvedReferences } from "./fileReferences";
import { LspPopups, UsagesStatus } from "./Popups";
import { DocumentationWindow, LspPopups, UsagesStatus } from "./Popups";
import { DOCUMENTATION_WINDOW } from "./docs";
import { prefetchMarkdown } from "./markdown";
import { registerNavigationActions } from "@/editor/navigation";
@@ -56,8 +57,21 @@ export function activate(): () => void {
installDiagnosticsEvents(),
installAvailabilityEvents(),
registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }),
// After the templates (order 90): live template / postfix expansion keeps ⇥ first.
registerEditorContribution({ id: "lsp.parameterTab", order: 95, extensions: () => parameterTabKeys }),
registerOverlay({ id: "editor-lsp.popups", component: LspPopups }),
registerStatusItem({ id: "editor-lsp.usagesStatus", align: "left", priority: 10, component: UsagesStatus }),
// IDEA's Documentation tool window: right side, listed once F1 moved documentation there.
registerToolWindow({
id: DOCUMENTATION_WINDOW,
title: "Documentation",
icon: "info",
anchor: "right",
secondary: true,
stripe: "onUse",
order: 90,
component: DocumentationWindow,
}),
registerLspActions(),
registerNavigationActions(),
registerFormatOnSave(),
+69
Переглянути файл
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState, StateEffect } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { InlayHint } from "@/bindings";
const openFile = vi.fn(async () => undefined);
vi.mock("@/workbench/contributions", async (orig) => {
const actual = await orig<typeof import("@/workbench/contributions")>();
return { ...actual, maybeEditorService: () => ({ openFile }) };
});
const { inlayDecorations, inlayMenuItems } = await import("./inlay");
const { useContextMenuStore } = await import("@/ui");
const hint: InlayHint = {
position: { line: 0, col: 5 },
label: ": Foo",
kind: "type",
paddingLeft: false,
paddingRight: false,
tooltip: null,
parts: [
{ value: ": ", tooltip: null, location: null },
{
value: "Foo",
tooltip: "struct Foo",
location: { path: "/p/foo.rs", range: { start: { line: 4, col: 7 }, end: { line: 4, col: 10 } } },
},
],
};
const views: EditorView[] = [];
afterEach(() => {
views.splice(0).forEach((v) => v.destroy());
vi.clearAllMocks();
});
function editorWith(hints: InlayHint[]) {
const view = new EditorView({ state: EditorState.create({ doc: "let x = foo();" }), parent: document.body });
views.push(view);
view.dispatch({ effects: StateEffect.appendConfig.of(EditorView.decorations.of(inlayDecorations(view, hints))) });
return view;
}
describe("inlay hints", () => {
it("renders label parts; ⌘-click on a part with a location navigates there", () => {
const view = editorWith([hint]);
const part = view.dom.querySelector<HTMLElement>(".cm-lsp-inlay-part")!;
expect(part.textContent).toBe("Foo");
expect(part.title).toBe("struct Foo");
expect(view.dom.querySelector(".cm-lsp-inlay")!.textContent).toBe(": Foo");
part.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
expect(openFile).not.toHaveBeenCalled();
part.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, metaKey: true, ctrlKey: true }));
expect(openFile).toHaveBeenCalledWith("/p/foo.rs", { selection: { line: 4, col: 7 }, focus: true });
});
it("offers to disable hints of the clicked kind", () => {
const view = editorWith([hint]);
view.dom.querySelector(".cm-lsp-inlay")!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
const items = useContextMenuStore.getState().menu?.items ?? [];
expect(items.map((i) => ("label" in i ? i.label : i.kind))).toEqual([
"Disable Type hints",
"separator",
"Inlay Hints Settings…",
]);
expect((inlayMenuItems("parameter")[0] as { label: string }).label).toBe("Disable Parameter name hints");
});
});
+65 -4
Переглянути файл
@@ -1,16 +1,42 @@
/**
* Inlay hints (settings.editor.inlayHints) for the visible range, refreshed
* after edits / scrolling (debounced) and rendered as subtle inline widgets.
* Label parts that name a declaration navigate there on ⌘-click; right-click
* offers to disable hints of that kind.
*/
import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { commands, type InlayHint } from "@/platform/ipc";
import { call, commands, isTauri, type InlayHint } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { hasPrimaryModifier } from "@/platform/keymap";
import { maybeEditorService } from "@/workbench/contributions";
import { showContextMenu, type MenuItem } from "@/ui";
import { useProject } from "@/platform/project";
import { editorPrefs } from "@/platform/editorSettings";
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
import { offsetsToRange, posToOffset } from "./positions";
import { onLspRefresh, serverBackoff } from "./availability";
const KIND_LABEL: Record<InlayHint["kind"], string> = {
type: "Type hints",
parameter: "Parameter name hints",
other: "Other hints",
};
async function setEditorSetting(key: string, value: unknown) {
if (!isTauri()) return;
useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) });
}
/** Right-click on a hint: turn its kind off (IDEA's "Disable '…' hints"), or open the settings. */
export function inlayMenuItems(kind: InlayHint["kind"]): MenuItem[] {
return [
{ label: `Disable ${KIND_LABEL[kind]}`, run: () => void setEditorSetting(`inlay.${kind}`, false) },
{ kind: "separator" },
{ label: "Inlay Hints Settings…", run: () => void executeAction("file.settings", "editor.inlayHints") },
];
}
class InlayWidget extends WidgetType {
constructor(readonly hint: InlayHint) {
super();
@@ -18,16 +44,51 @@ class InlayWidget extends WidgetType {
eq(other: InlayWidget) {
const a = this.hint,
b = other.hint;
return a.label === b.label && a.kind === b.kind && a.paddingLeft === b.paddingLeft && a.paddingRight === b.paddingRight;
return (
a.label === b.label &&
a.kind === b.kind &&
a.paddingLeft === b.paddingLeft &&
a.paddingRight === b.paddingRight &&
(a.parts?.length ?? 0) === (b.parts?.length ?? 0) &&
(a.parts ?? []).every((p, i) => p.location?.path === b.parts[i]?.location?.path && p.value === b.parts[i]?.value)
);
}
toDOM() {
toDOM(view: EditorView) {
const el = document.createElement("span");
el.className = `cm-lsp-inlay cm-lsp-inlay-${this.hint.kind}`;
if (this.hint.paddingLeft) el.classList.add("cm-lsp-inlay-pl");
if (this.hint.paddingRight) el.classList.add("cm-lsp-inlay-pr");
el.textContent = this.hint.label;
const parts = this.hint.parts ?? [];
if (parts.some((p) => p.location || p.tooltip)) {
for (const part of parts) {
const span = document.createElement("span");
span.textContent = part.value;
if (part.tooltip) span.title = part.tooltip;
if (part.location) {
span.className = "cm-lsp-inlay-part";
const loc = part.location;
// ⌘-click (Ctrl elsewhere) navigates to the declaration the part names, as in IDEA.
span.addEventListener("mousedown", (e) => {
if (e.button !== 0 || !hasPrimaryModifier(e)) return;
e.preventDefault();
e.stopPropagation();
void maybeEditorService()?.openFile(loc.path, {
selection: { line: loc.range.start.line, col: loc.range.start.col },
focus: true,
});
});
}
el.appendChild(span);
}
} else el.textContent = this.hint.label;
if (this.hint.tooltip) el.title = this.hint.tooltip;
el.setAttribute("aria-hidden", "true");
el.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
view.focus();
showContextMenu(e, inlayMenuItems(this.hint.kind));
});
return el;
}
ignoreEvent() {
+38 -1
Переглянути файл
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { history, undoDepth } from "@codemirror/commands";
import { EditorView } from "@codemirror/view";
import { endInplaceRename, inplaceRenameActive, inplaceRenameExtension, startInplaceRename } from "./inplaceRename";
import {
cycleSuggestion,
endInplaceRename,
inplaceRenameActive,
inplaceRenameExtension,
startInplaceRename,
} from "./inplaceRename";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
@@ -84,4 +90,35 @@ describe("in-place rename edits elsewhere", () => {
await Promise.resolve();
expect(commit).toHaveBeenCalledWith("bar");
});
it("cycles name suggestions with ↑ / ↓, mirrored and committed like typing", () => {
const view = new EditorView({
state: EditorState.create({ doc: "let foo = 1; foo + foo;", extensions: [inplaceRenameExtension()] }),
});
views.push(view);
const commit = vi.fn();
startInplaceRename(view, {
primary: { from: 4, to: 7 },
others: [{ from: 13, to: 16 }],
hint: "",
suggestions: ["count", "total"],
commit,
dialog: vi.fn(),
});
expect(cycleSuggestion(view, 1)).toBe(true);
expect(view.state.doc.toString()).toBe("let count = 1; count + foo;");
expect(view.state.selection.main).toMatchObject({ from: 4, to: 9 });
cycleSuggestion(view, 1);
expect(view.state.doc.toString()).toBe("let total = 1; total + foo;");
cycleSuggestion(view, 1);
expect(view.state.doc.toString()).toBe("let count = 1; count + foo;");
endInplaceRename(view, "commit");
expect(commit).toHaveBeenCalledWith("count");
expect(view.state.doc.toString()).toBe("let foo = 1; foo + foo;");
});
it("leaves the arrows alone without suggestions", () => {
const { view } = session();
expect(cycleSuggestion(view, 1)).toBe(false);
});
});
+51 -2
Переглянути файл
@@ -11,6 +11,7 @@
*/
import { Annotation, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, type ChangeSet, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view";
import { completionStatus } from "@codemirror/autocomplete";
import { isExternalChange } from "@/editor/api";
export interface Span {
@@ -30,6 +31,8 @@ export interface InplaceSession {
commit: (newName: string) => void;
/** ⇧F6 again: the rename dialog, prefilled with the typed name. */
dialog: (typed: string) => void;
/** Name suggestions listed under the name; ↑ / ↓ put the previous / next one in (Introduce Variable). */
suggestions?: readonly string[];
}
const startSession = StateEffect.define<InplaceSession>();
@@ -63,11 +66,30 @@ const sessionField = StateField.define<InplaceSession | null>({
? {
pos: s.primary.from,
above: false,
create: () => {
create: (view) => {
const dom = document.createElement("div");
dom.className = "cm-lsp-hint cm-lsp-inplace-hint";
dom.setAttribute("role", "status");
dom.textContent = s.hint;
if (s.suggestions?.length) {
const current = view.state.sliceDoc(s.primary.from, s.primary.to);
const list = document.createElement("ul");
list.className = "cm-lsp-inplace-suggestions";
list.setAttribute("aria-label", "Suggested names");
for (const name of s.suggestions) {
const li = document.createElement("li");
li.textContent = name;
if (name === current) li.className = "cm-lsp-inplace-suggestion-active";
li.addEventListener("mousedown", (e) => {
e.preventDefault();
putSuggestion(view, name);
});
list.appendChild(li);
}
dom.appendChild(list);
}
const hint = document.createElement("div");
hint.textContent = s.hint;
dom.appendChild(hint);
return { dom };
},
}
@@ -117,6 +139,31 @@ export function startInplaceRename(view: EditorView, session: Omit<InplaceSessio
});
}
/** Replaces the name being edited (and so its occurrences) with `name`, selected. */
export function putSuggestion(view: EditorView, name: string): boolean {
const s = inplaceSession(view.state);
if (!s) return false;
const { from, to } = s.primary;
view.dispatch({
changes: { from, to, insert: name },
selection: EditorSelection.range(from, from + name.length),
annotations: Transaction.addToHistory.of(false),
});
return true;
}
/** ↑ / ↓ over the suggestions; false without any (the key then moves the caret, ending the session). */
export function cycleSuggestion(view: EditorView, delta: 1 | -1): boolean {
const s = inplaceSession(view.state);
const list = s?.suggestions;
// An open lookup keeps the arrows.
if (!s || !list?.length || completionStatus(view.state) === "active") return false;
const current = view.state.sliceDoc(s.primary.from, s.primary.to);
const i = list.indexOf(current);
const next = list[i < 0 ? (delta > 0 ? 0 : list.length - 1) : (i + delta + list.length) % list.length]!;
return putSuggestion(view, next);
}
/**
* Ends the session, putting the old name back everywhere. Returns the name
* that was typed (null without a session). `commit` then runs the rename.
@@ -167,6 +214,8 @@ const keys = Prec.highest(
{ key: "Escape", run: (v) => endInplaceRename(v, "cancel") !== null },
// Undo while renaming restores the old name instead of undoing earlier edits.
{ key: "Mod-z", run: (v) => endInplaceRename(v, "cancel") !== null },
{ key: "ArrowDown", run: (v) => cycleSuggestion(v, 1) },
{ key: "ArrowUp", run: (v) => cycleSuggestion(v, -1) },
]),
);
+64
Переглянути файл
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { CodeAction } from "@/bindings";
const resolved: CodeAction = {
title: "Introduce local variable",
kind: "refactor.extract",
isPreferred: false,
disabledReason: null,
edit: {
files: [
{
kind: "edit",
path: "/p/a.ts",
edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "const x = 1;\n" }],
},
{ kind: "create", path: "/p/b.ts", overwrite: false },
],
},
command: null,
data: null,
server: 1,
};
const lspResolveCodeAction = vi.fn(async () => ({ status: "ok" as const, data: resolved }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspResolveCodeAction } };
});
const { intentionPreview } = await import("./intentions");
const { lspDoc } = await import("./lsp");
const views: EditorView[] = [];
afterEach(() => {
views.splice(0).forEach((v) => v.destroy());
vi.clearAllMocks();
});
describe("intention preview", () => {
it("resolves the action once and shows its diff for this file", async () => {
const view = new EditorView({
state: EditorState.create({
doc: "f(1);",
extensions: [lspDoc.of({ doc: 1, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false })],
}),
});
views.push(view);
const action = { ...resolved, edit: null };
const cache = new Map<CodeAction, Promise<CodeAction>>();
const signal = new AbortController().signal;
const preview = await intentionPreview(view, action, cache, signal);
expect(preview).toEqual({
kind: "diff",
lines: [
{ kind: "added", text: "const x = 1;" },
{ kind: "context", text: "f(1);" },
],
note: "Also changes 1 other file",
});
await intentionPreview(view, action, cache, signal);
expect(lspResolveCodeAction).toHaveBeenCalledOnce();
});
});
+48 -4
Переглянути файл
@@ -12,7 +12,8 @@ import { shortcutLabel } from "@/platform/keymap";
import { debounce, docOf, isUnavailable, lspRequest, reportLspError } from "./lsp";
import { offsetsToRange } from "./positions";
import { showHint } from "./hint";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { caretAnchor, openPopup, type ListPopupItem, type ListPreview } from "./popupStore";
import { editPreviewLines } from "./workspaceEdit";
import { serverBackoff } from "./availability";
import { iconNode } from "./iconMarkup";
import { editorPrefs } from "@/platform/editorSettings";
@@ -121,17 +122,53 @@ export async function applyKindSilently(view: EditorView, kind: string): Promise
return true;
}
function actionItems(view: EditorView, actions: CodeAction[]): ListPopupItem[] {
function actionItems(view: EditorView, actions: CodeAction[], resolved?: Map<CodeAction, Promise<CodeAction>>): ListPopupItem[] {
return sortCodeActions(actions).map((a, i) => ({
id: `${i}`,
label: a.title,
icon: isQuickFix(a) ? "bulb-fix" : "bulb",
emphasis: a.isPreferred,
disabled: a.disabledReason,
run: () => applyCodeAction(view, a),
// The edit the preview already resolved is applied as is.
run: async () => applyCodeAction(view, (await resolved?.get(a)?.catch(() => a)) ?? a),
}));
}
/** The action with its edit (resolved once per popup; servers may compute edits lazily). */
function resolveOnce(doc: number, action: CodeAction, cache: Map<CodeAction, Promise<CodeAction>>): Promise<CodeAction> {
let p = cache.get(action);
if (!p) {
p = action.edit || action.command ? Promise.resolve(action) : lspRequest(() => commands.lspResolveCodeAction(doc, action));
cache.set(action, p);
p.catch(() => cache.delete(action));
}
return p;
}
/** Intention preview (IDEA's side panel): the diff the action would make to this file. */
export async function intentionPreview(
view: EditorView,
action: CodeAction,
cache: Map<CodeAction, Promise<CodeAction>>,
signal: AbortSignal,
): Promise<ListPreview | null> {
const ctx = docOf(view);
if (!ctx) return null;
const doc = view.state.doc;
const full = await resolveOnce(ctx.doc, action, cache);
if (signal.aborted || view.state.doc !== doc) return null;
const files = full.edit?.files ?? [];
const here = files.find((f) => f.kind === "edit" && f.path === ctx.path);
const others = new Set(files.map((f) => (f.kind === "rename" ? f.from : f.path)).filter((p) => p !== ctx.path)).size;
const note = others ? `Also changes ${others} other ${others === 1 ? "file" : "files"}` : undefined;
if (here?.kind === "edit") {
const lines = editPreviewLines(doc, here.edits);
if (lines.length) return { kind: "diff", lines, note };
}
if (note) return { kind: "text", text: note };
return { kind: "text", text: full.command && !full.edit ? "Preview isn't available for this action" : "No changes in this file" };
}
/** ⌥↵ */
export async function showIntentions(view: EditorView) {
try {
@@ -141,11 +178,18 @@ export async function showIntentions(view: EditorView) {
showHint(view, "No intentions available");
return;
}
const sorted = sortCodeActions(actions);
const resolved = new Map<CodeAction, Promise<CodeAction>>();
const items = actionItems(view, sorted, resolved);
openPopup({
kind: "list",
anchor: caretAnchor(view, view.state.selection.main.head),
title: "Intentions",
items: actionItems(view, actions),
items,
preview: (item, signal) => {
const action = sorted[items.indexOf(item)];
return action ? intentionPreview(view, action, resolved, signal) : Promise.resolve(null);
},
});
} catch (e) {
reportLspError(view, e, true);
+41 -13
Переглянути файл
@@ -158,29 +158,57 @@ function highlightBlocks(root: HTMLElement, fallback: string | null) {
});
}
function onLinkClick(e: MouseEvent) {
const a = (e.target as Element | null)?.closest?.("a");
if (!a) return;
e.preventDefault();
const href = a.getAttribute("href") ?? "";
/** A documentation link to a source position (`file://…#L12,5`, `jdt://…#L42`); null for web links. */
export function sourceLink(href: string): { path: string; line: number; col: number } | null {
if (href.startsWith("file://")) {
const url = new URL(href);
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
const m = /^#?L?(\d+)(?:[,:](\d+))?/.exec(url.hash.slice(1));
const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: m[2] ? Math.max(0, Number(m[2]) - 1) : 0 } : undefined;
void maybeEditorService()?.openFile(decodeURIComponent(url.pathname), { selection, focus: true });
} else if (isVirtualUri(href) && !/^https?:/i.test(href) && !/^mailto:/i.test(href)) {
return {
path: decodeURIComponent(url.pathname),
line: m ? Math.max(0, Number(m[1]) - 1) : 0,
col: m?.[2] ? Math.max(0, Number(m[2]) - 1) : 0,
};
}
if (isVirtualUri(href) && !/^https?:/i.test(href) && !/^mailto:/i.test(href)) {
// Library class links (jdtls hover: `jdt://contents/…#L42`).
const hash = href.indexOf("#");
const m = hash >= 0 ? /^L?(\d+)/.exec(href.slice(hash + 1)) : null;
const selection = m ? { line: Math.max(0, Number(m[1]) - 1), col: 0 } : undefined;
void maybeEditorService()?.openFile(hash >= 0 ? href.slice(0, hash) : href, { selection, focus: true });
return { path: hash >= 0 ? href.slice(0, hash) : href, line: m ? Math.max(0, Number(m[1]) - 1) : 0, col: 0 };
}
return null;
}
/** Opens a documentation link: source links in the editor, web links in the browser. */
export function openDocLink(href: string) {
const target = sourceLink(href);
if (target) {
void maybeEditorService()?.openFile(target.path, { selection: { line: target.line, col: target.col }, focus: true });
} else if (/^https?:\/\//.test(href) && isTauri()) {
void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(href)).catch(() => {});
}
}
/** Handles a link click itself (true) or leaves it to `openDocLink` (false). */
export type DocLinkHandler = (href: string, e: MouseEvent) => boolean;
function linkClickHandler(onLink?: DocLinkHandler) {
return (e: MouseEvent) => {
const a = (e.target as Element | null)?.closest?.("a");
if (!a) return;
e.preventDefault();
const href = a.getAttribute("href") ?? "";
if (onLink?.(href, e)) return;
openDocLink(href);
};
}
/** Renders markup into a `.cm-lsp-markdown` element. */
export function renderMarkup(m: Markup, fallbackLanguage: string | null = null): HTMLElement {
export function renderMarkup(m: Markup, fallbackLanguage: string | null = null, onLink?: DocLinkHandler): HTMLElement {
const dom = document.createElement("div");
dom.className = "cm-lsp-markdown";
dom.innerHTML = markupToHtml(m);
@@ -193,7 +221,7 @@ export function renderMarkup(m: Markup, fallbackLanguage: string | null = null):
})
.catch(() => {});
}
dom.addEventListener("click", onLinkClick);
dom.addEventListener("click", linkClickHandler(onLink));
return dom;
}
+23 -4
Переглянути файл
@@ -42,7 +42,7 @@ import { offsetToPos, rangeToOffsets } from "./positions";
import { SYNTAX_NOTE, declarationFallback, isSelfTarget, localHighlights, type DeclarationTarget } from "./syntaxFallback";
import { showHint } from "./hint";
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { readManyFileLines } from "./files";
import { identifierAt, readManyFileLines } from "./files";
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
import { serverBackoff } from "./availability";
import { requestHover } from "./hover";
@@ -100,10 +100,12 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
symbol: l.kind,
run: () => navigateTo(l),
};
// Server targets: the declared name (speed search matches it), file and line after it.
const name = identifierAt(files.get(l.path)?.[l.range.start.line] ?? "", l.range.start.col);
return {
id: `${i}`,
label: basename(l.path),
detail: where,
label: name ?? basename(l.path),
detail: name ? `${basename(l.path)} · ${where}` : where,
preview,
icon: fileIconName(basename(l.path)),
run: () => navigateTo(l),
@@ -111,6 +113,19 @@ export async function locationItems(locs: readonly DeclarationTarget[]): Promise
});
}
/** "Choose Implementation of foo (3 found)". */
export function chooserTitle(kind: GotoKind, name: string | null, count: number, fromSyntax = false): string {
return `${TITLES[kind]}${name ? ` of ${name}` : ""} (${count} found${fromSyntax ? ", based on syntax" : ""})`;
}
/** Pin button of the targets chooser: the targets as a Find tool window tab (code-insight). */
function pinToFind(symbol: string, locations: readonly Location[]) {
return {
label: "Open in Find Tool Window",
run: () => void executeAction("find.showUsageResults", { symbol, locations }),
};
}
export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number) {
const offset = at ?? view.state.selection.main.head;
if (kind === "definition" || kind === "declaration") {
@@ -169,11 +184,14 @@ export async function gotoTargets(view: EditorView, kind: GotoKind, at?: number)
}, 150);
return;
}
const word = view.state.wordAt(offset);
const name = word ? view.state.sliceDoc(word.from, word.to) : null;
openPopup({
kind: "list",
anchor: caretAnchor(view, offset),
title: `${TITLES[kind]} (${locs.length} found${fromSyntax ? ", based on syntax" : ""})`,
title: chooserTitle(kind, name, locs.length, fromSyntax),
items: await locationItems(locs),
pin: pinToFind(name ?? "", locs),
});
}
@@ -207,6 +225,7 @@ async function gotoWebReference(view: EditorView, doc: EditorDocContext, pos: Po
anchor: caretAnchor(view, offset),
title: `Choose Declaration of '${label}' (${targets.length} found)`,
items: await locationItems(targets),
pin: pinToFind(label, targets),
});
return true;
}
+82 -6
Переглянути файл
@@ -24,20 +24,81 @@ export interface ListPopupItem {
disabled?: string | null;
shortcut?: string;
run: () => void | Promise<void>;
/** Called when the row becomes the selected one (e.g. highlight what it would act on). */
onActive?: () => void;
}
/** Side panel content for the selected row of a list popup (intention preview). */
export type ListPreview =
| { kind: "diff"; lines: DiffLine[]; /** Other files the change touches. */ note?: string }
| { kind: "text"; text: string };
export interface DiffLine {
kind: "context" | "added" | "removed" | "gap";
text: string;
}
export interface DocEntry {
markup: Markup;
language: string | null;
/** Declaration of the documented element (Edit Source), resolved on demand. */
source?: () => Promise<Location | null>;
}
export interface DefinitionTarget {
location: Location;
/** Name shown in the switcher (symbol or file name). */
name: string;
firstLine: number;
text: string;
language: string | null;
}
export interface ReformatOptions {
selectionOnly: boolean;
optimizeImports: boolean;
}
export type LspPopup =
| { kind: "doc"; anchor: PopupPoint; markup: Markup; language: string | null; themeClasses: string }
| {
kind: "doc";
anchor: PopupPoint;
/** Navigation history (links followed inside the popup); `index` is shown. */
entries: DocEntry[];
index: number;
themeClasses: string;
}
| {
kind: "definition";
anchor: PopupPoint;
location: Location;
firstLine: number;
text: string;
language: string | null;
/** Every target (⌥Space on an overloaded / multiply defined symbol); `index` is shown. */
targets: DefinitionTarget[];
index: number;
themeClasses: string;
}
| { kind: "list"; anchor: PopupPoint; title: string; items: ListPopupItem[] }
| {
kind: "list";
anchor: PopupPoint;
title: string;
items: ListPopupItem[];
/** Rows 1-9 get a number; typing it runs the row (IDEA's numbered popups). */
numbered?: boolean;
/** Header pin button (e.g. "Open in Find Tool Window"). */
pin?: { label: string; run: () => void };
/** Side panel for the selected row, fetched lazily; `signal` aborts when the selection moves. */
preview?: (item: ListPopupItem, signal: AbortSignal) => Promise<ListPreview | null>;
/** Runs when the popup goes away (closed, replaced or a row ran). */
onClose?: () => void;
}
| {
kind: "reformat";
title: string;
hasSelection: boolean;
/** Whether the server offers Optimize Imports for the file. */
canOptimizeImports: boolean;
initial: ReformatOptions;
onRun: (options: ReformatOptions) => void;
}
| {
kind: "rename";
anchor: PopupPoint;
@@ -85,12 +146,27 @@ export function focusPopup() {
useLspPopup.setState((s) => ({ focusSeq: s.focusSeq + 1 }));
}
function disposed(popup: LspPopup | null) {
if (popup?.kind === "list") popup.onClose?.();
}
export function openPopup(popup: LspPopup) {
const previous = useLspPopup.getState().popup;
useLspPopup.setState((s) => ({ popup, seq: s.seq + 1 }));
if (previous !== popup) disposed(previous);
}
export function closePopup() {
const previous = useLspPopup.getState().popup;
useLspPopup.setState({ popup: null });
disposed(previous);
}
/** Replaces the open popup's state without remounting it (doc history, definition switching). */
export function updatePopup<K extends LspPopup["kind"]>(kind: K, patch: Partial<Extract<LspPopup, { kind: K }>>): void {
const current = useLspPopup.getState().popup;
if (current?.kind !== kind) return;
useLspPopup.setState({ popup: { ...current, ...patch } as LspPopup });
}
/** Screen point just below `pos` (falls back to the editor's top-left). */
+53 -1
Переглянути файл
@@ -11,7 +11,9 @@ vi.mock("@/platform/ipc", async (orig) => {
return { ...actual, isTauri: () => true, commands: { ...actual.commands, lspSignatureHelp } };
});
const { signatureExtension, acceptsParameter } = await import("./signature");
const { signatureExtension, acceptsParameter, argumentSpans, argumentStop, parameterTabKeys, triggerSignatureHelp } = await import(
"./signature"
);
const { lspDoc } = await import("./lsp");
// jsdom has no layout; CodeMirror measures text when the tooltip is positioned.
@@ -62,3 +64,53 @@ describe("acceptsParameter", () => {
expect(acceptsParameter({ label: "format(String f, Object... a)", parameters: [[7, 15], [17, 28]] }, 4)).toBe(true);
});
});
describe("argument navigation", () => {
const at = (src: string) => ({ text: src.replace("|", ""), pos: src.indexOf("|") });
const stop = (src: string, dir: 1 | -1) => {
const { text, pos } = at(src);
const to = argumentStop(text, pos, dir);
return to === null ? null : `${text.slice(0, to)}|${text.slice(to)}`;
};
it("splits arguments at top-level commas, outside strings and nested brackets", () => {
const text = 'f(a, g(b, c), "x, y", [1, 2])';
expect(argumentSpans(text, 1).map((s) => text.slice(s.from, s.to))).toEqual(["a", "g(b, c)", '"x, y"', "[1, 2]"]);
expect(argumentSpans("f()", 1)).toEqual([]);
});
it("moves to the start of the next / previous argument, not past the ends", () => {
expect(stop("f(a|b, c)", 1)).toBe("f(ab, |c)");
expect(stop("f(ab, c|)", -1)).toBe("f(|ab, c)");
expect(stop("f(ab,| c)", 1)).toBeNull();
expect(stop("f(ab, c|)", 1)).toBeNull();
expect(stop("f(a, |)", -1)).toBe("f(|a, )");
expect(stop("f(a, g(|x, y), z)", 1)).toBe("f(a, g(x, |y), z)");
expect(stop('f("(", |b)', -1)).toBe('f(|"(", b)');
});
it("⇥ moves while parameter info is shown and is left alone otherwise", async () => {
const text = "f(a, b)";
const view = new EditorView({
state: EditorState.create({
doc: text,
selection: { anchor: 3 },
extensions: [
lspDoc.of({ doc: 1, path: "/a.ts", language: "typescript", uiMode: null, readOnly: false }),
signatureExtension(),
parameterTabKeys,
],
}),
parent: document.body,
});
views.push(view);
const tab = () =>
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", keyCode: 9, bubbles: true, cancelable: true }));
expect(tab()).toBe(true); // not handled: no parameter info
expect(view.state.selection.main.head).toBe(3);
triggerSignatureHelp(view, true);
await vi.waitFor(() => expect(view.dom.querySelector(".cm-lsp-signature")).not.toBeNull());
expect(tab()).toBe(false);
expect(view.state.selection.main.head).toBe(5);
});
});
+111 -2
Переглянути файл
@@ -1,9 +1,11 @@
/**
* Parameter info (⌘P, and automatically after `(` / `,`): a tooltip above the
* caret listing the signatures with the active parameter in bold. While open
* it follows the caret and closes when the server reports no signature.
* it follows the caret and closes when the server reports no signature;
* ⇥ / ⇧⇥ move to the next / previous argument.
*/
import { Prec, StateEffect, StateField, type Extension } from "@codemirror/state";
import { EditorSelection, Prec, StateEffect, StateField, type Extension } from "@codemirror/state";
import { completionStatus, hasNextSnippetField, hasPrevSnippetField } from "@codemirror/autocomplete";
import { EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view";
import { commands, type SignatureHelp } from "@/platform/ipc";
import { docOf, lspRequest, reportLspError, debounce } from "./lsp";
@@ -11,6 +13,7 @@ import { offsetToPos } from "./positions";
import { showHint } from "./hint";
import { renderMarkup, isEmptyMarkup } from "./markdown";
import { editorPrefs } from "@/platform/editorSettings";
import { inplaceRenameActive } from "./inplaceRename";
interface SigState {
pos: number;
@@ -140,6 +143,112 @@ const signaturePlugin = ViewPlugin.fromClass(
},
);
// ---------------------------------------------------------------------------
// ⇥ / ⇧⇥: next / previous argument while parameter info is shown
const OPEN = "([{";
const CLOSE = ")]}";
/** Skips a string literal starting at `i` (its quote); returns the index after it. */
function skipString(text: string, i: number): number {
const q = text[i]!;
let j = i + 1;
while (j < text.length && text[j] !== q) {
if (text[j] === "\\") j++;
else if (text[j] === "\n" && q !== "`") break;
j++;
}
return j + 1;
}
const isQuote = (c: string) => c === '"' || c === "'" || c === "`";
/** The `(` of the innermost call around `pos`, scanning brackets outside strings and line comments. */
export function enclosingParen(text: string, pos: number, window = 4000): number | null {
const start = Math.max(0, pos - window);
const stack: number[] = [];
for (let i = start; i < pos; i++) {
const c = text[i]!;
if (isQuote(c) && !(c === "'" && /\w/.test(text[i - 1] ?? ""))) {
i = skipString(text, i) - 1;
} else if (c === "/" && text[i + 1] === "/") {
while (i < pos && text[i] !== "\n") i++;
} else if (OPEN.includes(c)) stack.push(i);
else if (CLOSE.includes(c)) stack.pop();
}
for (let k = stack.length - 1; k >= 0; k--) if (text[stack[k]!] === "(") return stack[k]!;
return null;
}
/** Argument spans (trimmed; empty ones collapse to where they would start) of the call opened at `open`. */
export function argumentSpans(text: string, open: number): { from: number; to: number }[] {
const out: { from: number; to: number }[] = [];
let depth = 0;
let argStart = open + 1;
const push = (end: number) => {
let from = argStart;
let to = end;
while (from < to && /\s/.test(text[from]!)) from++;
while (to > from && /\s/.test(text[to - 1]!)) to--;
out.push({ from, to });
};
for (let i = open + 1; i < text.length; i++) {
const c = text[i]!;
if (isQuote(c) && !(c === "'" && /\w/.test(text[i - 1] ?? ""))) i = skipString(text, i) - 1;
else if (OPEN.includes(c)) depth++;
else if (CLOSE.includes(c)) {
if (depth === 0) {
push(i);
return out.length === 1 && out[0]!.from === out[0]!.to ? [] : out;
}
depth--;
} else if (c === "," && depth === 0) {
push(i);
argStart = i + 1;
}
}
return out;
}
/** Caret target of ⇥ (`dir` 1) / ⇧⇥ (-1) in the call around `pos`; null at the ends (the key then does its usual thing). */
export function argumentStop(text: string, pos: number, dir: 1 | -1): number | null {
const open = enclosingParen(text, pos);
if (open === null) return null;
const args = argumentSpans(text, open);
if (!args.length) return null;
// The argument the caret is in: past an argument's separating comma it is that argument.
let current = 0;
for (let i = 1; i < args.length; i++) if (pos > text.lastIndexOf(",", args[i]!.from)) current = i;
const target = args[current + dir];
return target ? target.from : null;
}
function moveToArgument(view: EditorView, dir: 1 | -1): boolean {
const state = view.state;
if (!state.field(signatureField, false) || state.readOnly) return false;
if (state.selection.ranges.length > 1 || completionStatus(state) === "active") return false;
// Snippet / template fields and the in-place rename own ⇥.
if (dir > 0 ? hasNextSnippetField(state) : hasPrevSnippetField(state)) return false;
if (inplaceRenameActive(state)) return false;
const head = state.selection.main.head;
const to = argumentStop(state.doc.toString(), head, dir);
if (to === null || to === head) return false;
view.dispatch({ selection: EditorSelection.cursor(to), scrollIntoView: true, userEvent: "select" });
return true;
}
/**
* Next / Previous Parameter keys. Registered after the template contributions
* (see `index.ts`) so live template / postfix expansion keeps ⇥ first; the
* lookup, snippet fields and the in-place rename are checked explicitly.
*/
export const parameterTabKeys: Extension = Prec.high(
keymap.of([
{ key: "Tab", run: (v) => moveToArgument(v, 1) },
{ key: "Shift-Tab", run: (v) => moveToArgument(v, -1) },
]),
);
export function signatureExtension(): Extension {
return [
signatureField,
+2 -1
Переглянути файл
@@ -274,7 +274,8 @@ describe("navigation wiring", () => {
const popup = useLspPopup.getState().popup;
expect(popup?.kind).toBe("list");
if (popup?.kind !== "list") return;
expect(popup.title).toBe("Choose Declaration (2 found, based on syntax)");
expect(popup.title).toBe("Choose Declaration of run (2 found, based on syntax)");
expect(popup.pin?.label).toBe("Open in Find Tool Window");
expect(popup.items.map((i) => [i.label, i.symbol, i.detail?.split(" · ")[0]])).toEqual([
["run", "method", "A"],
["run", "method", "B"],
+58
Переглянути файл
@@ -0,0 +1,58 @@
/**
* Temporary range highlight (styled in `theme.ts`) while a chooser moves its selection (the
* expression / occurrences an Introduce refactoring would take, a postfix
* template's target…), as in IDEA. Not part of the document state: any
* edit clears it, and callers clear it when their popup closes.
*/
import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { Decoration, EditorView } from "@codemirror/view";
export interface HighlightRange {
from: number;
to: number;
}
const setTemporary = StateEffect.define<readonly HighlightRange[]>();
const mark = Decoration.mark({ class: "cm-lsp-chooser-highlight" });
const temporaryField = StateField.define<readonly HighlightRange[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setTemporary)) return e.value;
return tr.docChanged && value.length ? [] : value;
},
provide: (f) =>
EditorView.decorations.from(f, (ranges) =>
Decoration.set(
ranges.filter((r) => r.from < r.to).map((r) => mark.range(r.from, r.to)),
true,
),
),
});
/** Highlights `ranges` until the next call, `clearTemporaryHighlight` or an edit; scrolls the first into view. */
export function highlightRanges(view: EditorView, ranges: readonly HighlightRange[]): void {
const len = view.state.doc.length;
const clamped = ranges
.map((r) => ({ from: Math.max(0, Math.min(r.from, len)), to: Math.max(0, Math.min(r.to, len)) }))
.sort((a, b) => a.from - b.from);
const effects: StateEffect<unknown>[] = [setTemporary.of(clamped)];
if (!view.state.field(temporaryField, false)) effects.push(StateEffect.appendConfig.of(temporaryHighlightExtension()));
const first = clamped[0];
if (first) effects.push(EditorView.scrollIntoView(first.from, { y: "nearest" }));
view.dispatch({ effects });
}
export function clearTemporaryHighlight(view: EditorView): void {
const current = view.state.field(temporaryField, false);
if (current?.length) view.dispatch({ effects: setTemporary.of([]) });
}
export function temporaryHighlights(view: EditorView): readonly HighlightRange[] {
return view.state.field(temporaryField, false) ?? [];
}
export function temporaryHighlightExtension(): Extension {
return [temporaryField];
}
+15
Переглянути файл
@@ -256,6 +256,8 @@ export const lspTheme = EditorView.theme({
backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
},
".cm-lsp-nav-info": { padding: "4px 8px" },
// Chooser highlight (tempHighlight.ts): the expression / occurrences a choice would take.
".cm-lsp-chooser-highlight": { backgroundColor: "var(--bg-text-search-result)" },
// --- in-place rename (inplaceRename.ts) ------------------------------------
".cm-lsp-inplace": {
@@ -267,6 +269,17 @@ export const lspTheme = EditorView.theme({
borderRadius: "2px",
},
".cm-lsp-inplace-hint": { color: "var(--fg-secondary)", fontSize: "var(--fs-small)", padding: "3px 8px" },
".cm-lsp-inplace-suggestions": {
listStyle: "none",
margin: "0 -8px 4px",
padding: "0",
fontFamily: "var(--openidea-editor-font, var(--font-mono))",
fontSize: "var(--fs-ui)",
color: "var(--fg)",
},
".cm-lsp-inplace-suggestions li": { padding: "1px 8px", cursor: "pointer" },
".cm-lsp-inplace-suggestions li:hover": { backgroundColor: "var(--bg-hover)" },
".cm-lsp-inplace-suggestion-active": { backgroundColor: "var(--bg-selected)" },
// --- floating lightbulb (intentions.ts) -----------------------------------
".cm-lsp-bulb": {
@@ -295,6 +308,8 @@ export const lspTheme = EditorView.theme({
WebkitUserSelect: "none",
userSelect: "none",
},
// A part naming a declaration (⌘-click navigates there).
".cm-lsp-inlay-part:hover": { textDecoration: "underline", textUnderlineOffset: "2px" },
".cm-lsp-inlay-pl": { marginLeft: "4px" },
".cm-lsp-inlay-pr": { marginRight: "4px" },
+4 -2
Переглянути файл
@@ -67,8 +67,10 @@ export async function expressionTypeInfo(view: EditorView) {
openPopup({
kind: "doc",
anchor: caretAnchor(view, hover.range ? rangeToOffsets(view.state.doc, hover.range).from : pos),
markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` },
language: docOf(view)?.uiMode ?? null,
entries: [
{ markup: { kind: "markdown", value: `\`\`\`${language}\n${info.text}\n\`\`\`` }, language: docOf(view)?.uiMode ?? null },
],
index: 0,
themeClasses: view.themeClasses,
});
} catch (e) {
+30 -1
Переглянути файл
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { describeSummary, needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit";
import { Text } from "@codemirror/state";
import { describeSummary, editPreviewLines, needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit";
const edit = (line: number) => ({ range: { start: { line, col: 0 }, end: { line, col: 3 } }, new_text: "new" });
@@ -47,3 +48,31 @@ describe("summarizeWorkspaceEdit", () => {
expect(s).toMatchObject({ files: 0, occurrences: 0 });
});
});
describe("editPreviewLines", () => {
const doc = Text.of(["function f() {", " return price * 2;", "}", "", "const a = 1;", "const b = 2;", "const c = 3;"]);
const at = (line: number, col: number) => ({ line, col });
it("shows changed lines with one line of context, hunks separated", () => {
const lines = editPreviewLines(doc, [
{ range: { start: at(1, 2), end: at(1, 2) }, new_text: "const x = price * 2;\n " },
{ range: { start: at(1, 9), end: at(1, 18) }, new_text: "x" },
{ range: { start: at(6, 10), end: at(6, 11) }, new_text: "4" },
]);
expect(lines).toEqual([
{ kind: "context", text: "function f() {" },
{ kind: "removed", text: " return price * 2;" },
{ kind: "added", text: " const x = price * 2;" },
{ kind: "added", text: " return x;" },
{ kind: "context", text: "}" },
{ kind: "gap", text: "" },
{ kind: "context", text: "const b = 2;" },
{ kind: "removed", text: "const c = 3;" },
{ kind: "added", text: "const c = 4;" },
]);
});
it("is empty when nothing changes", () => {
expect(editPreviewLines(doc, [{ range: { start: at(0, 0), end: at(0, 8) }, new_text: "function" }])).toEqual([]);
});
});
+70 -2
Переглянути файл
@@ -1,5 +1,8 @@
/** Summaries of workspace edits for rename / refactoring previews. */
import type { WorkspaceEdit } from "@/bindings";
/** Summaries of workspace edits for rename / refactoring previews, and the intention preview diff. */
import { ChangeSet, type Text } from "@codemirror/state";
import type { TextEdit, WorkspaceEdit } from "@/bindings";
import type { DiffLine } from "./popupStore";
import { textEditsToChanges } from "./positions";
export interface WorkspaceEditFileSummary {
path: string;
@@ -70,3 +73,68 @@ export function describeSummary(s: WorkspaceEditSummary): string {
export function needsPreview(s: WorkspaceEditSummary): boolean {
return s.files > 1 || s.hasFileOperations;
}
/** Line diff of `a` → `b` (LCS; both short: they are the changed lines of one hunk). */
function lineDiff(a: readonly string[], b: readonly string[]): DiffLine[] {
const n = a.length;
const m = b.length;
if (n * m > 250_000) return [...a.map((text) => ({ kind: "removed" as const, text })), ...b.map((text) => ({ kind: "added" as const, text }))];
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--) lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!);
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < n || j < m) {
if (i < n && j < m && a[i] === b[j]) {
out.push({ kind: "context", text: a[i]! });
i++;
j++;
} else if (i < n && (j >= m || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) out.push({ kind: "removed", text: a[i++]! });
else out.push({ kind: "added", text: b[j++]! });
}
return out;
}
const lines = (doc: Text, first: number, last: number) => {
const out: string[] = [];
for (let n = first; n <= last; n++) out.push(doc.line(n).text);
return out;
};
/**
* The intention preview of `edits` to `doc`: changed lines with `context`
* unchanged lines around each hunk, hunks separated by a gap line, at most
* `maxLines` lines.
*/
export function editPreviewLines(doc: Text, edits: readonly TextEdit[], context = 1, maxLines = 60): DiffLine[] {
const changes = textEditsToChanges(doc, edits);
if (!changes.length) return [];
const set = ChangeSet.of(changes, doc.length);
const after = set.apply(doc);
// Hunks: changed old-line ranges, merged when their contexts touch.
const hunks: { a1: number; a2: number }[] = [];
for (const c of changes) {
const a1 = doc.lineAt(c.from).number;
const a2 = doc.lineAt(c.to).number;
const last = hunks[hunks.length - 1];
if (last && a1 - last.a2 <= 2 * context + 1) last.a2 = Math.max(last.a2, a2);
else hunks.push({ a1, a2 });
}
const out: DiffLine[] = [];
for (const h of hunks) {
if (out.length) out.push({ kind: "gap", text: "" });
const from = doc.line(h.a1).from;
const to = doc.line(h.a2).to;
const b1 = after.lineAt(set.mapPos(from, -1)).number;
const b2 = after.lineAt(set.mapPos(to, 1)).number;
const c1 = Math.max(1, h.a1 - context);
const c2 = Math.min(doc.lines, h.a2 + context);
for (const text of lines(doc, c1, h.a1 - 1)) out.push({ kind: "context", text });
out.push(...lineDiff(lines(doc, h.a1, h.a2), lines(after, b1, b2)));
for (const text of lines(doc, h.a2 + 1, c2)) out.push({ kind: "context", text });
if (out.length >= maxLines) break;
}
if (out.length <= maxLines) return out;
return [...out.slice(0, maxLines), { kind: "gap", text: "" }];
}
+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,
});
}