feat(editor): Add folding, navigation, editing and window shortcuts

Editing and folding
- Move Caret to Code Block Start / End with Selection, Auto-Indent Lines,
  Move Element Left / Right (swaps call arguments, parameters and list
  entries).
- Fold Code Block folds the {...} block around the caret; Expand to Level
  and Expand All to Level 1-5.
- Custom folding regions (//region, #region, <editor-fold>) fold to their
  description; Navigate > Custom Folding lists them.
- Error Description shows the problems at the caret; Next / Previous
  Highlighted Usage steps through usage highlights.
- Editor tooltips stay below the editor's top edge, so hints on the first
  lines no longer hide under the tab bar.

Navigation, project and run
- Navigate > File Path lists the file and its folders to reveal in the file
  manager.
- Copy... (F5) opens the Copy dialog for the selection or the open file; the
  project view's Refactor submenu gets Copy File.
- Debug... chooses a configuration to debug; Quick Evaluate Expression shows
  the selection's value in an expandable hint.
- Inspect Current File runs the inspection without the scope dialog.
- Surround with Live Template lists only $SELECTION$ templates; Java gets
  Callable, read / write lock and iterate templates.

Windows
- Stretch the active tool window left / right / up / down, Restore Default
  Layout, Go to Next / Previous Splitter, Close Active Tab on a second key,
  Stop Background Processes.

All actions have default shortcuts in both keymaps, VS Code preset entries,
main menu items and Chinese translations.
Цей коміт міститься в:
l0ng-ai committed 2026-10-28 23:49:14 +08:00
1 джерело 2d7726cf53
коміт a404804ee7
42 files changed
+1379 -64

No files matched your search

+59 -1
Переглянути файл
@@ -409,8 +409,10 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("navigate.gotoTest", "Test"),
SEP,
act("navigate.fileStructure", "File Structure"),
act("navigate.showFilePath", "File Path"),
act("navigate.callHierarchy", "Call Hierarchy"),
act("navigate.typeHierarchy", "Type Hierarchy"),
act("navigate.customRegion", "Custom Folding…"),
SEP,
act("navigate.nextMethod", "Next Method"),
act("navigate.previousMethod", "Previous Method"),
@@ -439,6 +441,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("code.parameterInfo", "Parameter Info"),
act("code.quickDocumentation", "Quick Documentation"),
act("code.expressionTypeInfo", "Expression Type Info"),
act("code.showErrorDescription", "Error Description"),
act("code.showIntentions", "Show Context Actions"),
act("code.generate", "Generate…"),
act("code.overrideMethods", "Override Methods…"),
@@ -446,9 +449,11 @@ pub fn spec(os: Os) -> Vec<Entry> {
SEP,
act("code.insertLiveTemplate", "Insert Live Template…"),
act("code.surroundWith", "Surround With…"),
act("code.surroundWithLiveTemplate", "Surround with Live Template…"),
act("code.unwrap", "Unwrap/Remove…"),
SEP,
act("code.reformat", "Reformat Code"),
act("code.autoIndentLines", "Auto-Indent Lines"),
act("code.optimizeImports", "Optimize Imports"),
act("code.cleanup", "Code Cleanup…"),
SEP,
@@ -459,6 +464,10 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("code.shrinkSelection", "Shrink Selection"),
act("code.duplicateLine", "Duplicate Line or Selection"),
act("code.deleteLine", "Delete Line"),
act("code.moveStatementUp", "Move Statement Up"),
act("code.moveStatementDown", "Move Statement Down"),
act("code.moveElementLeft", "Move Element Left"),
act("code.moveElementRight", "Move Element Right"),
act("code.moveLineUp", "Move Line Up"),
act("code.moveLineDown", "Move Line Down"),
SEP,
@@ -469,13 +478,43 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("code.foldToggle", "Expand/Collapse"),
act("code.foldAll", "Collapse All"),
act("code.unfoldAll", "Expand All"),
sub(
"expandToLevel",
"Expand to Level",
vec![
act("code.expandToLevel1", "1"),
act("code.expandToLevel2", "2"),
act("code.expandToLevel3", "3"),
act("code.expandToLevel4", "4"),
act("code.expandToLevel5", "5"),
],
),
sub(
"expandAllToLevel",
"Expand All to Level",
vec![
act("code.expandAllToLevel1", "1"),
act("code.expandAllToLevel2", "2"),
act("code.expandAllToLevel3", "3"),
act("code.expandAllToLevel4", "4"),
act("code.expandAllToLevel5", "5"),
],
),
SEP,
act("code.collapseBlock", "Fold Code Block"),
act("code.foldSelection", "Fold Selection / Remove Region"),
],
),
SEP,
act("analyze.inspectCode", "Inspect Code…"),
sub("analyzeCode", "Analyze Code", vec![act("analyze.locateDuplicates", "Locate Duplicates…")]),
sub(
"analyzeCode",
"Analyze Code",
vec![
act("analyze.inspectFile", "Inspect Current File"),
act("analyze.locateDuplicates", "Locate Duplicates…"),
],
),
act("analyze.stackTrace", "Analyze Stack Trace or Thread Dump…"),
],
));
@@ -497,6 +536,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("refactor.inline", "Inline…"),
SEP,
act("refactor.move", "Move…"),
act("project.copyElement", "Copy…"),
act("refactor.safeDelete", "Safe Delete…"),
],
));
@@ -526,6 +566,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("run.stop", "Stop"),
SEP,
act("run.chooseConfig", "Run…"),
act("run.chooseDebugConfig", "Debug…"),
act("runAnything.show", "Run Anything…"),
act("run.editConfigs", "Edit Configurations…"),
act("debug.attachToProcess", "Attach to Process…"),
@@ -543,6 +584,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("debug.showExecutionPoint", "Show Execution Point"),
act("debug.resetFrame", "Reset Frame"),
act("debug.evaluate", "Evaluate Expression…"),
act("debug.quickEvaluate", "Quick Evaluate Expression"),
sub(
"debuggingActions",
"Debugging Actions",
@@ -706,6 +748,16 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("toolwindow.hideActive", "Hide Active Tool Window"),
act("toolwindow.maximize", "Maximize Tool Window"),
act("toolwindow.closeContent", "Close Active Tab"),
sub(
"resizeToolWindow",
"Resize",
vec![
act("toolwindow.resizeLeft", "Stretch to Left"),
act("toolwindow.resizeRight", "Stretch to Right"),
act("toolwindow.resizeUp", "Stretch to Top"),
act("toolwindow.resizeDown", "Stretch to Bottom"),
],
),
SEP,
act("toolwindow.jumpToLast", "Jump to Last Tool Window"),
act("toolwindow.focusEditor", "Focus Editor"),
@@ -728,6 +780,8 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("editor.splitDown", "Split Down"),
act("editor.unsplit", "Unsplit"),
act("editor.moveToOppositeGroup", "Move to Opposite Group"),
act("editor.nextSplitter", "Go to Next Splitter"),
act("editor.prevSplitter", "Go to Previous Splitter"),
],
),
SEP,
@@ -1427,6 +1481,10 @@ mod tests {
"toolwindow.closeContent",
"toolwindow.jumpToLast",
"toolwindow.focusEditor",
"toolwindow.resizeLeft",
"toolwindow.resizeRight",
"toolwindow.resizeUp",
"toolwindow.resizeDown",
]
.contains(id)
}));
+121 -3
Переглянути файл
@@ -1,8 +1,15 @@
/** Editor actions (ids from keymaps/intellij-macos.ts). */
import { EditorSelection, type EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { addCursorAbove, addCursorBelow, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
import { syntaxTree, toggleFold } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import {
addCursorAbove,
addCursorBelow,
indentSelection,
moveLineDown,
moveLineUp,
toggleBlockComment,
} from "@codemirror/commands";
import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
import { executeAction, registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
@@ -37,16 +44,23 @@ import {
collapseRecursively,
expandAll,
expandRecursively,
collapseBlock,
expandToLevelEffects,
foldAtCaret,
foldSelection,
unfoldAtCaret,
} from "./cm/folding";
import { reverseLines, sortLines } from "./cm/lines";
import { customRegions } from "./cm/regions";
import { moveElementLeft, moveElementRight } from "./cm/moveElement";
import { caretAnchor, openPopup } from "@/editor/lsp/popupStore";
import {
cursorCodeBlockEnd,
cursorCodeBlockStart,
cursorMatchBrace,
scrollToCenter,
selectCodeBlockEnd,
selectCodeBlockStart,
splitLine,
} from "./cm/caretCommands";
import { isOverwrite, toggleOverwrite } from "./cm/overwrite";
@@ -314,6 +328,56 @@ function completeCurrentStatement(view: EditorView, slot: EditorSlot) {
});
}
/** Navigate › Custom Folding (⌥⌘.): the file's custom regions; picking one moves the caret to it. */
function showCustomRegions(view: EditorView) {
const regions = customRegions(view.state.doc);
if (!regions.length) return void executeAction("editor.showHint", { text: "No custom folding regions in this file" });
const head = view.state.selection.main.head;
openPopup({
kind: "list",
anchor: caretAnchor(view, head),
title: "Custom Folding Regions",
items: regions.map((r) => ({
id: String(r.startLine),
label: r.name || "(no description)",
detail: `:${r.startLine}`,
icon: "folder",
run: () => {
const line = view.state.doc.line(Math.min(r.startLine, view.state.doc.lines));
const at = line.from + /^\s*/.exec(line.text)![0].length;
view.dispatch({
selection: { anchor: at },
effects: EditorView.scrollIntoView(at, { y: "start", yMargin: 40 }),
userEvent: "select.navigate",
});
view.focus();
},
})),
});
}
/** Folding › Expand to Level / Expand All to Level (titles kept literal for the text catalog). */
const FOLD_LEVELS: Array<[number, string, string]> = [
[1, "Expand to Level 1", "Expand All to Level 1"],
[2, "Expand to Level 2", "Expand All to Level 2"],
[3, "Expand to Level 3", "Expand All to Level 3"],
[4, "Expand to Level 4", "Expand All to Level 4"],
[5, "Expand to Level 5", "Expand All to Level 5"],
];
function foldLevel(view: EditorView, level: number, all: boolean) {
const effects = expandToLevelEffects(view.state, level, all);
if (!effects.length) return;
// A caret inside a new fold would unfold it again: it moves to the fold's start.
const head = view.state.selection.main.head;
const hiding = effects
.filter((e) => e.is(foldEffect))
.map((e) => e.value as { from: number; to: number })
.filter((r) => r.from < head && head < r.to)
.sort((a, b) => a.from - b.from)[0];
view.dispatch({ effects, selection: hiding ? { anchor: hiding.from } : undefined });
}
// --- cyclic expand word / copy reference ------------------------------------------------
/** Text of the other open documents for Cyclic Expand Word (bounded). */
@@ -425,6 +489,12 @@ export function registerEditorActions(): () => void {
text("code.deleteLine", "Delete Line", (v) => deleteLine(v)),
text("code.moveLineUp", "Move Line Up", (v) => moveLineUp(v)),
text("code.moveLineDown", "Move Line Down", (v) => moveLineDown(v)),
text("code.moveElementLeft", "Move Element Left", (v) => moveElementLeft(v), {
keywords: ["swap argument", "move parameter"],
}),
text("code.moveElementRight", "Move Element Right", (v) => moveElementRight(v), {
keywords: ["swap argument", "move parameter"],
}),
text("code.moveStatementUp", "Move Statement Up", (v, slot) => moveStatement(v, slot, true)),
text("code.moveStatementDown", "Move Statement Down", (v, slot) => moveStatement(v, slot, false)),
text("code.extendSelection", "Extend Selection", (_v, slot) => void queueSelection(slot, extend), {
@@ -490,6 +560,38 @@ export function registerEditorActions(): () => void {
text("editor.codeBlockEnd", "Move Caret to Code Block End", (v) => cursorCodeBlockEnd(v), {
category: "Editor Actions",
}),
text(
"editor.codeBlockStartWithSelection",
"Move Caret to Code Block Start with Selection",
(v) => selectCodeBlockStart(v),
{ category: "Editor Actions" },
),
text(
"editor.codeBlockEndWithSelection",
"Move Caret to Code Block End with Selection",
(v) => selectCodeBlockEnd(v),
{
category: "Editor Actions",
},
),
text("navigate.customRegion", "Custom Folding…", (v) => showCustomRegions(v), {
category: "Navigate",
keywords: ["region", "editor-fold", "go to region"],
}),
...FOLD_LEVELS.flatMap(([level, one, all]) => [
text(`code.expandToLevel${level}`, one, (v) => foldLevel(v, level, false), {
category: "Folding",
keywords: ["unfold", "fold level"],
}),
text(`code.expandAllToLevel${level}`, all, (v) => foldLevel(v, level, true), {
category: "Folding",
keywords: ["unfold", "fold level"],
}),
]),
text("code.collapseBlock", "Fold Code Block", (v) => collapseBlock(v), { category: "Folding", keywords: ["fold"] }),
text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), {
keywords: ["reindent", "fix indentation"],
}),
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), {
@@ -750,6 +852,22 @@ export function registerEditorActions(): () => void {
when: "editorTabsOpen",
run: () => ctrl.split(useTabs.getState().activePane, "column"),
},
{
id: "editor.nextSplitter",
title: "Go to Next Splitter",
category: "Window",
keywords: ["split", "editor group", "pane"],
when: "editorSplit",
run: () => ctrl.focusSplitter(1),
},
{
id: "editor.prevSplitter",
title: "Go to Previous Splitter",
category: "Window",
keywords: ["split", "editor group", "pane"],
when: "editorSplit",
run: () => ctrl.focusSplitter(-1),
},
{
id: "editor.moveToOppositeGroup",
title: "Move to Opposite Group",
+23 -3
Переглянути файл
@@ -78,18 +78,38 @@ export function codeBlockEnd(state: EditorState, pos: number): number | null {
return null;
}
function moveToBlock(find: (state: EditorState, pos: number) => number | null): Command {
function moveToBlock(find: (state: EditorState, pos: number) => number | null, extend = false): Command {
return (view) => {
const { state } = view;
const target = find(state, state.selection.main.head);
const main = state.selection.main;
const target = find(state, main.head);
if (target == null) return false;
view.dispatch({ selection: EditorSelection.cursor(target), scrollIntoView: true, userEvent: "select.navigate" });
view.dispatch({
selection: extend ? EditorSelection.range(main.anchor, target) : EditorSelection.cursor(target),
scrollIntoView: true,
userEvent: extend ? "select" : "select.navigate",
});
return true;
};
}
export const cursorCodeBlockStart = moveToBlock(codeBlockStart);
export const cursorCodeBlockEnd = moveToBlock(codeBlockEnd);
export const selectCodeBlockStart = moveToBlock(codeBlockStart, true);
export const selectCodeBlockEnd = moveToBlock(codeBlockEnd, true);
/**
* The inside of the `{…}` block around `pos` (between the braces) for Collapse Block;
* after a `}` the block it closes. `null` outside any block.
*/
export function enclosingCodeBlock(state: EditorState, pos: number): { from: number; to: number } | null {
const start = codeBlockStart(state, pos);
if (start == null) return null;
const open = braceAt(state, start) === "{" ? start : start - 1;
const end = codeBlockEnd(state, open);
if (end == null) return null;
return { from: open + 1, to: end - 1 };
}
/**
* Where Go to Matching Brace (⌃M) goes from `pos`: from a brace next to the
+62
Переглянути файл
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { codeFolding, foldEffect, foldedRanges } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { enclosingCodeBlock, selectCodeBlockEnd, selectCodeBlockStart } from "./caretCommands";
import { collapseBlock, collapseBlockEffects } from "./folding";
const text = "fn a() {\n if x { y(); }\n z();\n}\n";
const at = (needle: string) => text.indexOf(needle);
const state = (pos: number) =>
EditorState.create({ doc: text, selection: EditorSelection.cursor(pos), extensions: [codeFolding()] });
describe("enclosingCodeBlock", () => {
it("is the inside of the innermost block", () => {
expect(enclosingCodeBlock(state(0), at("y()"))).toEqual({ from: at("{ y") + 1, to: at("}\n z") });
});
it("after a closing brace is the block it closes", () => {
expect(enclosingCodeBlock(state(0), at("}\n z") + 1)).toEqual({ from: at("{ y") + 1, to: at("}\n z") });
});
it("is null outside any block", () => {
expect(enclosingCodeBlock(state(0), 0)).toBeNull();
});
});
describe("collapseBlockEffects", () => {
it("folds the block around the caret, then the enclosing one", () => {
const s = state(at("y()"));
const first = collapseBlockEffects(s);
expect(first.map((e) => e.value)).toEqual([{ from: at("{ y") + 1, to: at("}\n z") }]);
const folded = s.update({ effects: first }).state;
expect(collapseBlockEffects(folded).map((e) => e.value)).toEqual([
{ from: at("{\n") + 1, to: text.lastIndexOf("}") },
]);
expect(first.every((e) => e.is(foldEffect))).toBe(true);
});
});
describe("code block with selection", () => {
it("extends the selection to the block start and end", () => {
const view = new EditorView({ state: state(at("z()")) });
selectCodeBlockEnd(view);
expect(view.state.selection.main).toMatchObject({ anchor: at("z()"), head: text.lastIndexOf("}") });
view.dispatch({ selection: EditorSelection.cursor(at("z()")) });
selectCodeBlockStart(view);
expect(view.state.selection.main).toMatchObject({ anchor: at("z()"), head: at("{\n") + 1 });
view.destroy();
});
});
describe("collapseBlock", () => {
it("keeps the fold by moving a caret inside it onto the fold", () => {
const view = new EditorView({ state: state(at("y()")) });
collapseBlock(view);
// A later selection change would unfold a fold around the caret.
view.dispatch({ selection: view.state.selection });
expect(foldedRanges(view.state).size).toBe(1);
expect(view.state.selection.main.head).toBe(at("{ y") + 1);
view.destroy();
});
});
+36 -1
Переглянути файл
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import { EditorState } from "@codemirror/state";
import { codeFolding, foldEffect, foldService, foldedRanges } from "@codemirror/language";
import { enclosingFoldRange, foldSelectionEffects, foldToExpand, foldsFromRanges } from "./folding";
import {
enclosingFoldRange,
expandToLevelEffects,
foldSelectionEffects,
foldToExpand,
foldsFromRanges,
} from "./folding";
/** Folds `{ … }` blocks whose header line ends with `{`. */
const braces = foldService.of((state, from, to) => {
@@ -71,6 +77,12 @@ describe("foldsFromRanges", () => {
]);
});
it("folds a custom region from its start marker through its end marker", () => {
const s = EditorState.create({ doc: "class A {\n //region Fields\n int a;\n //endregion\n}\n" });
const [f] = foldsFromRanges(s, [fold(1, 3)]);
expect(f).toMatchObject({ from: s.doc.line(2).from + 2, to: s.doc.line(4).to, custom: true });
});
it("keeps a brace line that follows a statement or comment", () => {
const s = EditorState.create({ doc: "x();\n{\n y();\n}\n// c\n{\n}\n" });
expect(foldsFromRanges(s, [fold(1, 3), fold(5, 6)]).map((f) => s.doc.lineAt(f.from).number)).toEqual([2, 6]);
@@ -88,3 +100,26 @@ describe("foldSelectionEffects (⌘.)", () => {
expect(foldedRanges(s).size).toBe(0);
});
});
describe("expandToLevelEffects", () => {
const folded = (s: EditorState) => {
const out: number[] = [];
foldedRanges(s).between(0, s.doc.length, (a) => void out.push(s.doc.lineAt(a).number));
return out;
};
it("Expand All to Level 1 keeps top-level regions open and folds nested ones", () => {
let s = make();
s = s.update({ effects: expandToLevelEffects(s, 1, true) }).state;
expect(folded(s)).toEqual([2]);
s = s.update({ effects: expandToLevelEffects(s, 2, true) }).state;
expect(folded(s)).toEqual([]);
});
it("Expand to Level counts from the region around the caret", () => {
let s = make().update({ selection: { anchor: text.indexOf("y()") } }).state;
s = s.update({ effects: expandToLevelEffects(s, 1, true) }).state;
s = s.update({ selection: { anchor: text.indexOf("z()") } }).state;
expect(folded(s.update({ effects: expandToLevelEffects(s, 2) }).state)).toEqual([]);
});
});
+86 -6
Переглянути файл
@@ -3,7 +3,15 @@
* with CodeMirror's syntax folding (`foldNodeProp`) as fallback, IDEA-style
* chevrons in the gutter (open chevrons only on gutter hover).
*/
import { Prec, StateEffect, StateField, type EditorState, type Extension, type Line } from "@codemirror/state";
import {
EditorSelection,
Prec,
StateEffect,
StateField,
type EditorState,
type Extension,
type Line,
} from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import {
codeFolding,
@@ -15,6 +23,8 @@ import {
unfoldEffect,
} from "@codemirror/language";
import type { FoldRange } from "@/platform/ipc";
import { enclosingCodeBlock } from "./caretCommands";
import { regionMarker, regionNameAt } from "./regions";
export interface BackendFold {
/** Offset where the fold starts (end of the header line). */
@@ -22,6 +32,8 @@ export interface BackendFold {
to: number;
/** 0-based header line at the time the folds were computed (for sticky scroll). */
kind: FoldRange["kind"];
/** A custom region (`//region` … `//endregion`): folds from its start marker, which the placeholder names. */
custom?: boolean;
}
export const setBackendFolds = StateEffect.define<FoldRange[]>();
@@ -44,8 +56,14 @@ export function foldsFromRanges(state: EditorState, ranges: readonly FoldRange[]
const lines = state.doc.lines;
for (const r of ranges) {
if (r.start_line + 1 > lines || r.end_line + 1 > lines || r.end_line <= r.start_line) continue;
const start = allmanHeader(state, state.doc.line(r.start_line + 1));
const first = state.doc.line(r.start_line + 1);
const end = state.doc.line(r.end_line + 1);
if (regionMarker(first.text)?.start) {
const from = first.from + /^\s*/.exec(first.text)![0].length;
if (end.to > from) out.push({ from, to: end.to, kind: r.kind, custom: true });
continue;
}
const start = allmanHeader(state, first);
const trimmed = end.text.trimStart();
// `}` / `)` / `]` on the last line stays visible after the placeholder, like IDEA.
const to = /^[}\])]/.test(trimmed) ? end.from + (end.text.length - trimmed.length) : end.to;
@@ -81,8 +99,8 @@ function foldAtLine(folds: readonly BackendFold[], lineStart: number, lineEnd: n
let best: BackendFold | null = null;
for (let i = lo; i < folds.length && folds[i]!.from <= lineEnd; i++) {
const f = folds[i]!;
// Only folds whose header ends on this line; keep the widest.
if (f.from === lineEnd && (!best || f.to > best.to)) best = f;
// Only folds whose header ends on this line (custom regions: on this line); keep the widest.
if ((f.from === lineEnd || f.custom) && (!best || f.to > best.to)) best = f;
}
return best;
}
@@ -162,6 +180,38 @@ export function unfoldAtCaret(view: EditorView): boolean {
return effects.length > 0;
}
/**
* IDEA "Collapse Block" (⇧⌘.): folds the inside of the `{…}` block around the
* caret, whether or not the language offers a fold there; an already folded
* block passes on to the one enclosing it.
*/
export function collapseBlockEffects(state: EditorState): StateEffect<unknown>[] {
const effects: StateEffect<unknown>[] = [];
for (const range of state.selection.ranges) {
let block = enclosingCodeBlock(state, range.head);
while (block && isFolded(state, block)) block = enclosingCodeBlock(state, block.from - 1);
if (block && block.to > block.from) effects.push(foldEffect.of(block));
}
return effects;
}
export function collapseBlock(view: EditorView): boolean {
const effects = collapseBlockEffects(view.state);
if (!effects.length) return false;
// A caret inside a fold would unfold it again: it moves to the fold, as in IDEA.
const folds = effects.map((e) => e.value as { from: number; to: number });
const sel = view.state.selection;
const selection = EditorSelection.create(
sel.ranges.map((r) => {
const f = folds.find((x) => x.from < r.head && r.head < x.to);
return f ? EditorSelection.cursor(f.from) : r;
}),
sel.mainIndex,
);
view.dispatch({ effects, selection });
return true;
}
/**
* IDEA "Fold Selection / Remove Region" (⌘.): folds each selection (without
* a trailing line break); with no selection, toggles the region at the caret.
@@ -239,6 +289,34 @@ export function collapseAllEffects(state: EditorState): StateEffect<unknown>[] {
}
/** IDEA "Expand All" (⌘⇧=): every folded region, or those inside the selection. */
/**
* IDEA "Expand to Level N" (⌘* N) / "Expand All to Level N" (⌘⇧* N): the
* regions of the region around the caret (of the file with `all`) nested at most
* `level` deep are expanded, deeper ones collapsed.
*/
export function expandToLevelEffects(state: EditorState, level: number, all = false): StateEffect<unknown>[] {
const regions = foldableRegions(state);
let scope = regions;
if (!all) {
const head = state.selection.main.head;
const around = regions.filter((r) => r.from <= head && head <= r.to);
const root = around.reduce<(typeof regions)[number] | null>(
(best, r) => (!best || r.to - r.from < best.to - best.from ? r : best),
null,
);
if (!root) return [];
scope = regions.filter((r) => r.from >= root.from && r.to <= root.to);
}
const effects: StateEffect<unknown>[] = [];
for (const r of scope) {
const depth = scope.filter((o) => o !== r && o.from <= r.from && r.to <= o.to).length + 1;
const folded = isFolded(state, r);
if (depth <= level && folded) effects.push(unfoldEffect.of(r));
else if (depth > level && !folded) effects.push(foldEffect.of(r));
}
return effects;
}
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 }));
@@ -319,10 +397,12 @@ export function foldingExtension(showOutline = true): Extension {
backendFolds,
backendFoldService,
codeFolding({
placeholderDOM: (_view, onclick) => {
// A folded custom region shows its name, as in IDEA.
preparePlaceholder: (state, range) => regionNameAt(state, range.from),
placeholderDOM: (_view, onclick, name: string | null) => {
const el = document.createElement("span");
el.className = "cm-foldPlaceholder";
el.textContent = "…";
el.textContent = name ?? "…";
el.title = "Expand";
el.setAttribute("aria-label", "folded code");
el.onclick = onclick;
+40
Переглянути файл
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { moveElementLeft, moveElementRight } from "./moveElement";
/** `|` marks the caret; returns the text (with caret) after running `cmd`. */
function run(text: string, cmd: typeof moveElementLeft): string {
const pos = text.indexOf("|");
const view = new EditorView({
state: EditorState.create({ doc: text.replace("|", ""), selection: EditorSelection.cursor(pos) }),
});
cmd(view);
const head = view.state.selection.main.head;
const doc = view.state.doc.toString();
view.destroy();
return doc.slice(0, head) + "|" + doc.slice(head);
}
describe("Move Element Left / Right", () => {
it("swaps call arguments, keeping the caret in the moved one", () => {
expect(run("f(a|lpha, b)", moveElementRight)).toBe("f(b, a|lpha)");
expect(run("f(alpha, b|eta)", moveElementLeft)).toBe("f(b|eta, alpha)");
});
it("moves a nested call within the enclosing list", () => {
expect(run("f(g(|x), y)", moveElementRight)).toBe("f(y, g(|x))");
});
it("ignores commas in strings and nested brackets", () => {
expect(run('f("a, b", [1, 2], |c)', moveElementLeft)).toBe('f("a, b", |c, [1, 2])');
});
it("does nothing at the end of the list", () => {
expect(run("f(a, |b)", moveElementRight)).toBe("f(a, |b)");
});
it("works across lines", () => {
expect(run("[\n one,\n |two,\n]", moveElementLeft)).toBe("[\n |two,\n one,\n]");
});
});
+141
Переглянути файл
@@ -0,0 +1,141 @@
/**
* Move Element Left / Right (⌥⇧⌘← / →): swaps the element at the caret with its
* neighbour in the enclosing comma-separated list — call arguments, parameters,
* array and object literal entries.
*/
import { EditorSelection, type EditorState } from "@codemirror/state";
import type { Command } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
const OPEN = "([{";
const CLOSE = ")]}";
const NOT_CODE = /String|Comment|Regexp|Char|Template/;
/** Lists longer than this are not scanned. */
const MAX_SCAN = 20_000;
function inCode(state: EditorState, pos: number): boolean {
const tree = syntaxTree(state);
return !tree.length || !NOT_CODE.test(tree.resolveInner(pos, 1).name);
}
/** `[open, close]` offsets of the innermost bracket pair around `pos` that holds a comma list. */
function enclosingList(state: EditorState, pos: number): [number, number] | null {
const doc = state.doc;
const min = Math.max(0, pos - MAX_SCAN);
const stack: string[] = [];
for (let p = pos - 1; p >= min; p--) {
const ch = doc.sliceString(p, p + 1);
const close = CLOSE.indexOf(ch);
const open = OPEN.indexOf(ch);
if (close < 0 && open < 0) continue;
if (!inCode(state, p)) continue;
if (close >= 0) stack.push(OPEN[close]!);
else if (stack.length) {
if (stack.pop() !== ch) return null;
} else {
const end = matchingClose(state, p, ch);
if (end != null && end >= pos) return [p, end];
}
}
return null;
}
function matchingClose(state: EditorState, open: number, ch: string): number | null {
const doc = state.doc;
const want = CLOSE[OPEN.indexOf(ch)]!;
const max = Math.min(doc.length, open + MAX_SCAN);
let depth = 0;
for (let p = open + 1; p < max; p++) {
const c = doc.sliceString(p, p + 1);
if (c === ch && inCode(state, p)) depth++;
else if (c === want && inCode(state, p)) {
if (depth === 0) return p;
depth--;
}
}
return null;
}
/** The trimmed top-level elements between `from` and `to`. */
export function listElements(state: EditorState, from: number, to: number): { from: number; to: number }[] {
const text = state.sliceDoc(from, to);
const out: { from: number; to: number }[] = [];
let depth = 0;
let quote: string | null = null;
let start = 0;
const push = (end: number) => {
const raw = text.slice(start, end);
const lead = raw.length - raw.trimStart().length;
const trimmed = raw.trim();
if (trimmed) out.push({ from: from + start + lead, to: from + start + lead + trimmed.length });
};
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") quote = c;
else if ("([{".includes(c)) depth++;
else if (")]}".includes(c)) depth--;
else if (c === "," && depth === 0) {
push(i);
start = i + 1;
}
}
push(text.length);
return out;
}
/** The swap Move Element would make at `pos`, or null when there is no neighbour. */
export function elementSwap(
state: EditorState,
pos: number,
dir: -1 | 1,
): { a: { from: number; to: number }; b: { from: number; to: number }; caret: number } | null {
// The innermost list with more than one element: in `f(g(x), y)` at `x`, `g(x)` moves.
let items: { from: number; to: number }[] = [];
for (let at = pos, depth = 0; items.length < 2; depth++) {
const list = depth < 16 ? enclosingList(state, at) : null;
if (!list) return null;
items = listElements(state, list[0] + 1, list[1]);
at = list[0];
}
// The last element starting at or before the caret (the one before a comma it follows).
let i = 0;
items.forEach((it, k) => {
if (it.from <= pos) i = k;
});
const j = i + dir;
if (j < 0 || j >= items.length) return null;
const cur = items[i]!;
const other = items[j]!;
const offset = Math.max(0, Math.min(pos - cur.from, cur.to - cur.from));
// Where the moved element starts once both are swapped.
const start = dir === 1 ? other.to - (cur.to - cur.from) : other.from;
return { a: cur, b: other, caret: start + offset };
}
function moveElement(dir: -1 | 1): Command {
return (view) => {
const { state } = view;
if (state.readOnly || state.selection.ranges.length > 1) return false;
const swap = elementSwap(state, state.selection.main.head, dir);
if (!swap) return false;
const { a, b } = swap;
view.dispatch({
changes: [
{ from: a.from, to: a.to, insert: state.sliceDoc(b.from, b.to) },
{ from: b.from, to: b.to, insert: state.sliceDoc(a.from, a.to) },
],
selection: EditorSelection.cursor(swap.caret),
scrollIntoView: true,
userEvent: "move.element",
});
return true;
};
}
export const moveElementLeft = moveElement(-1);
export const moveElementRight = moveElement(1);
+48
Переглянути файл
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { EditorState, Text } from "@codemirror/state";
import { customRegions, regionMarker, regionNameAt } from "./regions";
describe("regionMarker", () => {
it.each([
["//region Accessors", "Accessors"],
[" // region Accessors", "Accessors"],
["#region Setup", "Setup"],
["# region Setup", "Setup"],
["//#region Setup", "Setup"],
['// <editor-fold desc="Getters">', "Getters"],
['<!-- <editor-fold desc="Head"> -->', "Head"],
["/* region Block */", "Block"],
["//region", ""],
])("start %s", (line, name) => {
expect(regionMarker(line)).toEqual({ start: true, name });
});
it.each(["//endregion", "# endregion", "#endregion", "// </editor-fold>", "<!-- </editor-fold> -->"])(
"end %s",
(line) => {
expect(regionMarker(line)).toEqual({ start: false });
},
);
it.each(["const region = 1;", "// the region below", "regions.push(x)"])("not a marker: %s", (line) => {
expect(regionMarker(line)).toBeNull();
});
});
describe("customRegions", () => {
it("matches nested markers and drops unclosed ones", () => {
const doc = Text.of(["//region A", "//region B", "x", "//endregion", "//endregion", "//region C"]);
expect(customRegions(doc)).toEqual([
{ name: "A", startLine: 1, endLine: 5 },
{ name: "B", startLine: 2, endLine: 4 },
]);
});
});
describe("regionNameAt", () => {
it("names a fold starting at a start marker", () => {
const state = EditorState.create({ doc: " //region Fields\nint a;\n//endregion" });
expect(regionNameAt(state, 2)).toBe("Fields");
expect(regionNameAt(state, 5)).toBeNull();
});
});
+64
Переглянути файл
@@ -0,0 +1,64 @@
/**
* Custom folding regions: `//region Name` … `//endregion`, `#region` (C#, Python…),
* `<editor-fold desc="Name">` … `</editor-fold>`, in any line comment style.
* The backend folds them; this names their placeholders and lists them for
* Navigate › Custom Folding (⌥⌘.).
*/
import type { EditorState, Text } from "@codemirror/state";
export type RegionMarker = { start: true; name: string } | { start: false };
const COMMENT_START = /^\s*(?:\/\/+|\/\*+|#|--|;+|%|<!--|')?\s*/;
const START = /^#?region\b\s*(.*)$/i;
const END = /^(?:#?endregion\b|<\/editor-fold\s*>)/i;
const FOLD = /^<editor-fold\b([^>]*)>/i;
const DESC = /\bdesc\s*=\s*"([^"]*)"/i;
/** The region marker on a line of text, if it is one. */
export function regionMarker(line: string): RegionMarker | null {
if (!/region|editor-fold/i.test(line)) return null;
const body = line.replace(COMMENT_START, "").trimEnd();
if (END.test(body)) return { start: false };
const fold = FOLD.exec(body);
if (fold) return { start: true, name: DESC.exec(fold[1]!)?.[1]?.trim() ?? "" };
const region = START.exec(body);
if (!region) return null;
const name = region[1]!
.replace(/\s*(?:\*\/|-->)\s*$/, "")
.replace(/^"(.*)"$/, "$1")
.trim();
return { start: true, name };
}
export interface CustomRegion {
name: string;
/** 1-based lines of the start and end markers. */
startLine: number;
endLine: number;
}
/** Every matched region of `doc`, in document order of their start markers. */
export function customRegions(doc: Text): CustomRegion[] {
const out: CustomRegion[] = [];
const open: { name: string; line: number; index: number }[] = [];
for (let n = 1; n <= doc.lines; n++) {
const m = regionMarker(doc.line(n).text);
if (!m) continue;
if (m.start) {
open.push({ name: m.name, line: n, index: out.length });
out.push({ name: m.name, startLine: n, endLine: -1 });
} else {
const s = open.pop();
if (s) out[s.index]!.endLine = n;
}
}
return out.filter((r) => r.endLine > 0);
}
/** The name of the region whose start marker begins at `pos` (a fold's start), for its placeholder. */
export function regionNameAt(state: EditorState, pos: number): string | null {
const line = state.doc.lineAt(pos);
if (state.sliceDoc(line.from, pos).trim()) return null;
const m = regionMarker(line.text);
return m?.start && m.name ? m.name : null;
}
+13
Переглянути файл
@@ -11,6 +11,7 @@ import {
highlightSpecialChars,
keymap,
lineNumbers,
tooltips,
} from "@codemirror/view";
import {
bracketMatching,
@@ -301,8 +302,20 @@ export interface SetupOptions {
extra?: Extension;
}
/**
* Tooltips (hints, docs, completion) stay below the editor's top edge: one
* above the first lines flips below them instead of hiding under the tabs.
*/
const tooltipSpace = tooltips({
tooltipSpace: (view) => {
const top = view.scrollDOM.getBoundingClientRect().top;
return { top, left: 0, right: window.innerWidth, bottom: window.innerHeight };
},
});
export function baseExtensions(o: SetupOptions): Extension {
return [
tooltipSpace,
compartments.font.of(fontExtension(o.settings)),
openideaTheme,
compartments.lineNumbers.of(lineNumbersExtension(o.settings)),
+9
Переглянути файл
@@ -871,6 +871,15 @@ export class EditorController implements SlotHost {
}
}
/** Window › Editor Tabs › Goto Next / Previous Splitter: the active tab of the next split pane, wrapping. */
focusSplitter(dir: 1 | -1) {
const s = useTabs.getState();
const ids = paneIds(s).filter((id) => s.panes[id]?.active);
if (ids.length < 2) return;
const next = ids[(ids.indexOf(s.activePane) + dir + ids.length) % ids.length]!;
this.activate(next, s.panes[next]!.active!);
}
// --- tab commands -----------------------------------------------------------------------------
private apply(f: (s: TabsState) => TabsState) {
+21 -3
Переглянути файл
@@ -1,6 +1,6 @@
/** Actions of the LSP editor layer (ids from keymaps/intellij-macos.ts). */
import type { EditorView } from "@codemirror/view";
import { registerActions, type Action } from "@/platform/actions";
import { executeAction, registerActions, type Action } from "@/platform/actions";
import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api";
import { maybeEditorService } from "@/workbench/contributions";
import { useProject } from "@/platform/project";
@@ -8,8 +8,8 @@ import { explicitCompletion } from "./completion";
import { triggerSignatureHelp } from "./signature";
import { quickDefinition, quickDocumentation } from "./hover";
import { expressionTypeInfo } from "./typeInfo";
import { gotoTargets, highlightUsages } from "./navigation";
import { gotoDiagnostic } from "./diagnostics";
import { gotoHighlightedUsage, gotoTargets, highlightUsages } from "./navigation";
import { gotoDiagnostic, showErrorDescription } from "./diagnostics";
import { applyKindSilently, applyQuickFix, runKindAction, showIntentions } from "./intentions";
import { implementPythonAbstractMethods } from "./pythonMembers";
import { workflowSettings, type ActionsOnSaveValid } from "@/platform/workflowSettings";
@@ -227,7 +227,25 @@ export function registerLspActions(): () => void {
),
editorAction("navigate.nextError", "Next Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, 1)),
editorAction("navigate.prevError", "Previous Highlighted Error", "Navigate", (v) => gotoDiagnostic(v, -1)),
editorAction("code.showErrorDescription", "Error Description", "View", showErrorDescription, {
keywords: ["problem", "warning", "tooltip"],
}),
editorAction("find.highlightUsages", "Highlight Usages in File", "Find", highlightUsages),
// Shares Next / Previous Occurrence's key on Windows (as in IDEA): that runs without highlighted usages.
editorAction(
"navigate.nextHighlightedUsage",
"Next Highlighted Usage",
"Navigate",
(v) => gotoHighlightedUsage(v, 1) || executeAction("navigate.nextOccurrence"),
{ keyWhen: "editorTextFocus" },
),
editorAction(
"navigate.prevHighlightedUsage",
"Previous Highlighted Usage",
"Navigate",
(v) => gotoHighlightedUsage(v, -1) || executeAction("navigate.prevOccurrence"),
{ keyWhen: "editorTextFocus" },
),
{
// Also outside an editor, where nothing applies: IDEA's "Nothing here" popup.
id: "refactor.thisMenu",
+15
Переглянути файл
@@ -304,3 +304,18 @@ export function gotoDiagnostic(view: EditorView, dir: 1 | -1): boolean {
});
return true;
}
/** ⌘F1 Show Error Description: the problems at the caret, worst first. */
export function showErrorDescription(view: EditorView): boolean {
const at = diagnosticsAt(view, view.state.selection.main.head);
if (!at.length) {
showHint(view, "No problems at the caret");
return false;
}
const worst = at[0]!.diagnostic.severity;
showHint(view, [...new Set(at.map((p) => p.diagnostic.message))].join("\n"), {
tone: worst === "error" ? "error" : worst === "warning" ? "warning" : "info",
timeout: 10000,
});
return true;
}
+13 -4
Переглянути файл
@@ -10,7 +10,8 @@ import { txTemplate } from "@/platform/i18n";
interface Hint {
pos: number;
content: string | (() => HTMLElement);
/** Text, or DOM built when shown (with `destroy` when it holds e.g. a React root). */
content: string | (() => HTMLElement | { dom: HTMLElement; destroy: () => void });
tone: "info" | "error" | "warning";
}
@@ -34,9 +35,17 @@ const hintField = StateField.define<Hint | null>({
const dom = document.createElement("div");
dom.className = `cm-lsp-hint cm-lsp-hint-${h.tone}`;
dom.setAttribute("role", "status");
if (typeof h.content === "string") dom.textContent = txTemplate(h.content);
else dom.appendChild(h.content());
return { dom };
if (typeof h.content === "string") {
dom.textContent = txTemplate(h.content);
return { dom };
}
const built = h.content();
if (built instanceof HTMLElement) {
dom.appendChild(built);
return { dom };
}
dom.appendChild(built.dom);
return { dom, destroy: built.destroy };
},
}
: null,
+17
Переглянути файл
@@ -627,6 +627,23 @@ export function highlightUsages(view: EditorView) {
void fetchHighlights(view, true);
}
/**
* ⌃⌥↓ / ⌃⌥↑: the next (previous) highlighted usage of the element at the caret,
* wrapping, with the caret at the same offset inside it.
*/
export function gotoHighlightedUsage(view: EditorView, dir: 1 | -1): boolean {
const st = view.state.field(highlightField, false);
const ranges: { from: number; to: number }[] = [];
st?.deco.between(0, view.state.doc.length, (from, to) => void ranges.push({ from, to }));
const head = view.state.selection.main.head;
const current = ranges.findIndex((r) => r.from <= head && head <= r.to);
if (ranges.length < 2 || current < 0) return false;
const target = ranges[(current + dir + ranges.length) % ranges.length]!;
const pos = Math.min(target.from + (head - ranges[current]!.from), target.to);
view.dispatch({ selection: EditorSelection.cursor(pos), scrollIntoView: true, userEvent: "select.navigate" });
return true;
}
const highlightPlugin = ViewPlugin.fromClass(
class {
private request = debounce(() => void fetchHighlights(this.view, false), 300);
+12 -1
Переглянути файл
@@ -14,7 +14,8 @@ import {
import { AnalysisDialogHost } from "./dialogs";
import { resetDuplicates } from "./duplicates";
import { DuplicatesHeaderActions, DuplicatesView } from "./DuplicatesView";
import { installInspectionProfileSync, resetInspection } from "./inspect";
import { installInspectionProfileSync, resetInspection, runInspection } from "./inspect";
import { defaultScope } from "./scope";
import { InspectionHeaderActions, InspectionView, useInspectionHeaderMenu } from "./InspectionView";
export function activate(): () => void {
@@ -57,6 +58,16 @@ export function activate(): () => void {
keywords: ["inspection", "analyze", "diagnostics", "problems"],
run: () => void showInspectCodeDialog(),
},
{
// IDEA's "Inspect Code with Editor Settings": the current file, no scope dialog.
id: "analyze.inspectFile",
title: "Inspect Current File",
category: "Code",
icon: "toolwindow-inspection",
when: "projectOpen && textEditorActive",
keywords: ["inspect code with editor settings", "inspection", "analyze file"],
run: () => runInspection(defaultScope("file")),
},
{
id: "code.cleanup",
title: "Code Cleanup…",
+27 -1
Переглянути файл
@@ -22,6 +22,7 @@ import { debugUnsupportedReason, launchConfigFor } from "./launch";
import { isRemoteConfig, REMOTE_JVM, remoteAttachConfig, remoteJvmTarget } from "./remote";
import {
activeSession,
evaluate,
isLive,
rerunSession,
revealFrame,
@@ -34,7 +35,8 @@ import {
type DebugSession,
} from "./state";
import { focusedDebugView } from "./ui/focusedView";
import { expressionAt } from "./editor/valueTooltip";
import { expressionAt, valueHint } from "./editor/valueTooltip";
import { showHint } from "@/editor/lsp/hint";
import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep";
import {
openBreakpointsDialog,
@@ -147,6 +149,22 @@ function evaluateSeed(): string {
return expressionAt(ed.view, sel.head)?.text ?? "";
}
async function quickEvaluate() {
const view = activeEditor()?.view;
if (!view) return;
const sel = view.state.selection.main;
const expression = evaluateSeed().trim();
if (!expression) return showHint(view, "Nothing to evaluate at the caret");
const session = activeSession();
try {
const value = await evaluate(expression, "hover", session);
if (!session) return;
showHint(view, () => valueHint(expression, value, session.id), { pos: sel.from, timeout: 60_000 });
} catch (e) {
showHint(view, e instanceof Error ? e.message : String(e), { pos: sel.from, tone: "error" });
}
}
/**
* Remote JVM Debug in listen mode: the IDE listens and the remote JVM connects; java-debug
* then attaches through a loopback relay. Null when cancelled.
@@ -592,6 +610,14 @@ export function registerDebugActions(): () => void {
when: "debugSessionActive",
run: () => openEvaluateDialog(evaluateSeed()),
},
{
// ⌥⌘F8: the selection (or expression at the caret) evaluated in a value hint, no dialog.
id: "debug.quickEvaluate",
title: "Quick Evaluate Expression",
category: "Debug",
when: "debugSessionActive && textEditorActive",
run: () => quickEvaluate(),
},
{
id: "debug.showToolWindow",
title: "Show Debug Tool Window",
+42 -32
Переглянути файл
@@ -4,6 +4,7 @@
* evaluates it in the selected frame.
*/
import type { Extension } from "@codemirror/state";
import type { DebugSessionId, Variable } from "@/bindings";
import { hoverTooltip, type EditorView } from "@codemirror/view";
import { workflowSettings } from "@/platform/workflowSettings";
import { navigationModifierHeld } from "@/platform/navigationModifier";
@@ -37,6 +38,46 @@ export function expressionAt(view: EditorView, pos: number): { from: number; to:
return { from: line.from + start, to: line.from + end, text };
}
/** IDEA's value hint: `expression = value {type}`, objects expandable into a variables tree in place. */
export function valueHint(
expression: string,
value: Variable,
session: DebugSessionId,
): { dom: HTMLElement; destroy: () => void } {
const dom = document.createElement("div");
dom.className = `${styles.valueTooltip} ${selectableText}`;
let tree: { unmount(): void } | null = null;
if (value.variablesReference > 0) {
// IDEA's value hint: the "+" expands the object into a variables tree in place.
const expand = document.createElement("button");
expand.type = "button";
expand.className = styles.valueExpand!;
expand.setAttribute("aria-label", tx("Expand value"));
expand.setAttribute("aria-expanded", "false");
expand.innerHTML = iconMarkup("chevron-right", 12) ?? "";
expand.addEventListener("mousedown", (e) => e.preventDefault());
expand.addEventListener("click", () => {
if (tree) return;
expand.remove();
dom.classList.replace(selectableText, styles.valueTooltipTree!);
dom.replaceChildren();
tree = mountValueTree(dom, expression, value, session);
});
dom.append(expand);
}
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expression} = `;
dom.append(name, inlineValue(value.value, 400));
if (value.typeName) {
const type = document.createElement("span");
type.className = styles.valueType!;
type.textContent = ` {${value.typeName}}`;
dom.append(type);
}
return { dom, destroy: () => tree?.unmount() };
}
export function valueTooltip(): Extension {
return hoverTooltip(
async (view, pos) => {
@@ -61,38 +102,7 @@ export function valueTooltip(): Extension {
end: expr.to,
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = `${styles.valueTooltip} ${selectableText}`;
let tree: { unmount(): void } | null = null;
if (value.variablesReference > 0) {
// IDEA's value hint: the "+" expands the object into a variables tree in place.
const expand = document.createElement("button");
expand.type = "button";
expand.className = styles.valueExpand!;
expand.setAttribute("aria-label", tx("Expand value"));
expand.setAttribute("aria-expanded", "false");
expand.innerHTML = iconMarkup("chevron-right", 12) ?? "";
expand.addEventListener("mousedown", (e) => e.preventDefault());
expand.addEventListener("click", () => {
if (tree) return;
expand.remove();
dom.classList.replace(selectableText, styles.valueTooltipTree!);
dom.replaceChildren();
tree = mountValueTree(dom, expr.text, value, session.id);
});
dom.append(expand);
}
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expr.text} = `;
dom.append(name, inlineValue(value.value, 400));
if (value.typeName) {
const type = document.createElement("span");
type.className = styles.valueType!;
type.textContent = ` {${value.typeName}}`;
dom.append(type);
}
return { dom, destroy: () => tree?.unmount() };
return valueHint(expr.text, value, session.id);
},
};
},
+29
Переглянути файл
@@ -18,6 +18,7 @@ import {
findFileUsages,
folderOf,
leadNode,
openCopyDialog,
openMoveDialog,
openRenameDialog,
openSelection,
@@ -28,6 +29,7 @@ import {
type PathKind,
} from "./operations";
import { nameOf, parentOf } from "./paths";
import { showFilePathPopup } from "./filePath";
import { showNewPopup, treeHandle } from "./ProjectView";
import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor";
import {
@@ -317,6 +319,33 @@ export function registerProjectActions(): () => void {
keywords: ["remove", "trash"],
run: ({ arg }) => deleteSelection(nodesFrom(arg)),
},
{
// F5 (CopyElement): the Copy dialog for the selection, or for the file in the editor.
id: "project.copyElement",
title: "Copy…",
category: "Project View",
icon: "copy",
when: OPEN,
keyWhen: `${FOCUS} || editorFocus`,
keywords: ["copy file", "duplicate file", "clone"],
run: ({ arg, ctx }) => {
if (arg !== undefined || ctx.projectViewFocus) return openCopyDialog(nodesFrom(arg));
const path = activeEditor()?.ctx.path;
if (path) openCopyDialog(nodesFrom(path));
},
},
{
id: "navigate.showFilePath",
title: "File Path",
category: "Navigate",
when: OPEN,
keywords: ["reveal in finder", "show in explorer", "path popup"],
run: ({ arg, ctx }) => {
const node = typeof arg === "string" || ctx.projectViewFocus ? nodesFrom(arg)[0] : undefined;
const path = node?.path ?? activeEditor()?.ctx.path;
if (path) showFilePathPopup(path, node?.dir ?? false);
},
},
{ id: "project.copy", title: "Copy", category: "Project View", when: FOCUS, run: () => copySelection(false) },
{ id: "project.cut", title: "Cut", category: "Project View", when: FOCUS, run: () => copySelection(true) },
{ id: "project.paste", title: "Paste", category: "Project View", when: FOCUS, run: () => paste() },
+1 -1
Переглянути файл
@@ -105,7 +105,7 @@ describe("project view context menu", () => {
"-",
"Compare With…",
]);
expect(sub(items, "Refactor")).toEqual(["Rename…", "Move File…", "-", "Safe Delete…"]);
expect(sub(items, "Refactor")).toEqual(["Rename…", "Move File…", "Copy File…", "-", "Safe Delete…"]);
expect(sub(items, "Open In")).toEqual([revealLabel(), "Terminal"]);
expect(sub(items, "Git")?.slice(0, 4)).toEqual(["Commit File…", "Add", "-", "Annotate with Git Blame"]);
});
+7
Переглянути файл
@@ -19,6 +19,7 @@ import {
deleteSelection,
findFileUsages,
folderOf,
openCopyDialog,
openMoveDialog,
openRenameDialog,
paste,
@@ -225,6 +226,12 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
shortcut: keyLabel("project.move"),
run: () => openMoveDialog(nonRoot),
},
nonRoot.length > 0 && {
id: "copy",
label: nonRoot.length === 1 ? (nonRoot[0]!.dir ? "Copy Directory…" : "Copy File…") : "Copy…",
shortcut: keyLabel("project.copyElement"),
run: () => openCopyDialog(nonRoot),
},
nonRoot.length > 0 && { kind: "separator" },
nonRoot.length > 0 && {
id: "safeDelete",
+18
Переглянути файл
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { filePathChain } from "./filePath";
describe("filePathChain", () => {
it("lists the file then every folder up to the root", () => {
expect(filePathChain("/work/app/src/main.ts")).toEqual([
"/work/app/src/main.ts",
"/work/app/src",
"/work/app",
"/work",
"/",
]);
});
it("stops at a Windows drive root", () => {
expect(filePathChain("C:/Users/me/a.txt")).toEqual(["C:/Users/me/a.txt", "C:/Users/me", "C:/Users", "C:/"]);
});
});
+45
Переглянути файл
@@ -0,0 +1,45 @@
/** Navigate › File Path (⌥⌘F12): the file and its folders, each revealed in the OS file manager. */
import { openPopup } from "@/editor/lsp/popupStore";
import { notifications } from "@/workbench/contributions";
import { tx } from "@/platform/i18n";
import { nameOf, parentOf } from "./paths";
import { revealLabel } from "./contextMenu";
const DRIVE_ROOT = /^[A-Za-z]:\/?$/;
/** `path` then each folder above it, up to the file system (or drive) root. */
export function filePathChain(path: string): string[] {
const out = [path];
for (let p = path; p !== "/" && !DRIVE_ROOT.test(p);) {
const parent = parentOf(p);
if (parent === p) break;
p = /^[A-Za-z]:$/.test(parent) ? `${parent}/` : parent;
out.push(p);
}
return out;
}
async function reveal(path: string) {
try {
const { revealItemInDir } = await import("@tauri-apps/plugin-opener");
await revealItemInDir(path);
} catch (e) {
const fileManager = revealLabel();
notifications().notify({
level: "error",
title: tx`Cannot reveal in ${fileManager}`,
body: e instanceof Error ? e.message : String(e),
});
}
}
export function showFilePathPopup(path: string, isDir = false) {
const items = filePathChain(path).map((p, i) => ({
id: p,
label: nameOf(p) || p,
detail: i === 0 ? undefined : p,
icon: i === 0 && !isDir ? "file" : "folder",
run: () => reveal(p),
}));
openPopup({ kind: "list", anchor: { x: window.innerWidth / 2 - 160, y: 96 }, title: "File Path", items });
}
+9
Переглянути файл
@@ -175,6 +175,15 @@ export function openMoveDialog(nodes: ProjectNode[] = selectedNodes(), target?:
openMoveDialogFor(paths, target, paths.length === 1 && movable.some((n) => n.path === paths[0] && n.dir));
}
/** Copy… (F5): IDEA's Copy dialog for the selection, into the folder it is in. */
export function openCopyDialog(nodes: ProjectNode[] = selectedNodes()) {
const copyable = nodes.filter((n) => !n.root && !n.group);
const paths = topLevelPaths(copyable);
if (!paths.length) return;
const single = paths.length === 1 && copyable.some((n) => n.path === paths[0] && n.dir);
openCopyDialogFor(paths, parentOf(paths[0]!), single);
}
function openMoveDialogFor(paths: string[], target?: string, dir?: boolean, done?: () => void) {
if (!paths.length) return;
openFileRefactorDialog({
+16
Переглянути файл
@@ -350,6 +350,22 @@ export function activate(): () => void {
useRun.setState({ chooserOpen: true, chooserMode: arg === "select" ? "select" : "run" });
},
},
{
id: "run.chooseDebugConfig",
title: "Debug…",
description: "Choose a run/debug configuration and debug it",
category: "Run",
keywords: ["choose and debug configuration"],
when: "projectOpen",
run: async () => {
if (useRun.getState().chooserOpen || chooserJustClosed()) {
useRun.setState({ chooserOpen: false });
return;
}
await ensureConfigs();
useRun.setState({ chooserOpen: true, chooserMode: "debug" });
},
},
{
id: "run.editConfigs",
title: "Edit Configurations…",
+10 -5
Переглянути файл
@@ -398,7 +398,12 @@ export const CONFIGURE_TEMPLATES_KEY = "\u0000configure";
* surrounders (mnemonics 1–9, 0, A–Z), then the `$SELECTION$` live templates
* and "Configure Live Templates…".
*/
export function surroundWith(view: EditorView, target?: { from: number; to: number } | null) {
export function surroundWith(
view: EditorView,
target?: { from: number; to: number } | null,
/** Surround with Live Template (⌥⌘J): only the `$SELECTION$` templates. */
templatesOnly = false,
) {
const state = view.state;
const { language } = state.facet(templateDoc);
const templates = applicableTemplates(liveTemplates(), language).filter((t) => usesSelection(t.body));
@@ -420,12 +425,12 @@ export function surroundWith(view: EditorView, target?: { from: number; to: numb
const last = state.doc.lineAt(to);
const lines = !selected || (from === first.from + indent.length && !state.sliceDoc(to, last.to).trim());
const inline = selected && first.number === last.number && !!selection.trim();
const surrounders = surroundersFor(language, { lines, inline });
const surrounders = templatesOnly ? [] : surroundersFor(language, { lines, inline });
const entries = surroundEntries(surrounders, templates);
const byId = new Map<string, Surrounder | LiveTemplate>([...surrounders, ...templates].map((x) => [x.id, x]));
const items: ChooserItem[] = [];
for (const e of entries) {
if (e.kind === "template" && !items.some((i) => i.section))
if (e.kind === "template" && !templatesOnly && !items.some((i) => i.section))
items.push({ key: "\u0000templates", label: "Live templates", section: true });
const x = byId.get(e.id)!;
items.push(
@@ -445,10 +450,10 @@ export function surroundWith(view: EditorView, target?: { from: number; to: numb
items.push({ key: CONFIGURE_TEMPLATES_KEY, label: "Configure Live Templates…" });
}
showChooser({
title: "Surround With",
title: templatesOnly ? "Surround with Live Template" : "Surround With",
anchor: caretAnchor(view),
mnemonics: true,
emptyText: "Nothing to surround with here",
emptyText: templatesOnly ? "No live templates to surround with" : "Nothing to surround with here",
items,
onPick: (item) => {
view.focus();
+13
Переглянути файл
@@ -210,6 +210,19 @@ export function activate(): () => void {
if (activeEditor()?.view === editor.view) surroundWith(editor.view, target);
},
},
{
id: "code.surroundWithLiveTemplate",
title: "Surround with Live Template…",
category: "Code",
when: EDITOR_WHEN,
keywords: ["wrap", "snippet", "template", "selection"],
run: async () => {
const editor = activeEditor();
if (!editor) return;
const target = await surroundTarget(editor.view, editor.ctx);
if (activeEditor()?.view === editor.view) surroundWith(editor.view, target, true);
},
},
{
id: "emmet.updateTag",
title: "Update Tag with Emmet",
+35
Переглянути файл
@@ -319,6 +319,41 @@ const surround = [
},
],
),
...group(
"Surround",
"surround.java",
["java"],
[
{
abbrev: "C",
id: "callable",
description: "Surround with Callable",
body: "java.util.concurrent.Callable<$RET$> callable = new java.util.concurrent.Callable<$RET$>() {\n\tpublic $RET$ call() throws Exception {\n\t\t$SELECTION$\n\t\t$END$\n\t}\n};",
variables: [v("RET", undefined, '"Object"')],
},
{
abbrev: "RL",
id: "readLock",
description: "Surround with ReadWriteLock.readLock",
body: "$LOCK$.readLock().lock();\ntry {\n\t$SELECTION$\n} finally {\n\t$LOCK$.readLock().unlock();\n}$END$",
variables: [v("LOCK")],
},
{
abbrev: "WL",
id: "writeLock",
description: "Surround with ReadWriteLock.writeLock",
body: "$LOCK$.writeLock().lock();\ntry {\n\t$SELECTION$\n} finally {\n\t$LOCK$.writeLock().unlock();\n}$END$",
variables: [v("LOCK")],
},
{
abbrev: "I",
id: "iterate",
description: "Iterate Iterable or array",
body: "for ($ELEMENT_TYPE$ $VAR$ : $SELECTION$) {\n\t$END$\n}",
variables: [v("ELEMENT_TYPE", undefined, '"var"'), v("VAR", "suggestVariableName()", '"item"')],
},
],
),
];
/*
+2
Переглянути файл
@@ -49,6 +49,8 @@
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
/* The abbreviation keeps its width; a long description truncates instead. */
flex-shrink: 0;
}
.active .label {
color: var(--fg-strong);
+22
Переглянути файл
@@ -55,6 +55,26 @@ describe("IDEA keymap parity", () => {
// Vcs.Log.GoToRef: Find in the Git Log table.
["vcs.log.goToRef", ["Meta+F"], ["Ctrl+F"]],
// Back / Forward also carry the mouse side buttons (`<mouse-shortcut keystroke="button4"/>`).
["editor.codeBlockStartWithSelection", ["Meta+Alt+Shift+["], ["Ctrl+Shift+["]],
["editor.codeBlockEndWithSelection", ["Meta+Alt+Shift+]"], ["Ctrl+Shift+]"]],
["code.collapseBlock", ["Meta+Shift+."], ["Ctrl+Shift+."]],
["code.autoIndentLines", ["Ctrl+Alt+I"], ["Ctrl+Alt+I"]],
["code.showErrorDescription", ["Meta+F1"], ["Ctrl+F1"]],
["code.moveElementLeft", ["Meta+Alt+Shift+Left"], ["Ctrl+Alt+Shift+Left"]],
["code.moveElementRight", ["Meta+Alt+Shift+Right"], ["Ctrl+Alt+Shift+Right"]],
["code.surroundWithLiveTemplate", ["Meta+Alt+J"], ["Ctrl+Alt+J"]],
["navigate.customRegion", ["Meta+Alt+."], ["Ctrl+Alt+."]],
["navigate.showFilePath", ["Meta+Alt+F12"], ["Ctrl+Alt+F12"]],
["navigate.nextHighlightedUsage", ["Ctrl+Alt+Down"], ["Ctrl+Alt+Down"]],
["analyze.inspectFile", ["Alt+Shift+I"], ["Alt+Shift+I"]],
["project.copyElement", ["F5"], ["F5"]],
["debug.quickEvaluate", ["Meta+Alt+F8"], ["Ctrl+Alt+F8"]],
["run.chooseDebugConfig", ["Ctrl+Alt+D"], ["Alt+Shift+F9"]],
["editor.nextSplitter", ["Alt+Tab"], ["Alt+Tab"]],
["toolwindow.resizeLeft", ["Ctrl+Alt+Left"], ["Ctrl+Alt+Shift+Left"]],
["toolwindow.closeContent", ["Meta+W", "Ctrl+Shift+F4"], ["Ctrl+F4", "Ctrl+Shift+F4"]],
["layout.restoreDefault", ["Shift+F12"], ["Shift+F12"]],
["workbench.stopBackgroundProcesses", ["Meta+Shift+F2"], ["Ctrl+Shift+F2"]],
["navigate.back", ["Meta+[", "Meta+Alt+Left", "Button4"], ["Ctrl+Alt+Left", "Button4"]],
["navigate.forward", ["Meta+]", "Meta+Alt+Right", "Button5"], ["Ctrl+Alt+Right", "Button5"]],
])("%s", (id, mac, win) => {
@@ -78,6 +98,8 @@ describe("IDEA keymap parity", () => {
order(km, "navigate.nextError", "vcs.changelist.edit");
order(km, "debug.setValue", "vcs.changelist.edit");
order(km, "find.replaceInFile", "find.rerun");
order(km, "navigate.nextOccurrence", "navigate.nextHighlightedUsage");
order(km, "code.moveElementLeft", "toolwindow.resizeLeft");
order(km, "find.inFile", "vcs.log.goToRef");
// Inline (text editor only) wins over New Branch.
order(km, "vcs.newBranch", "refactor.inline");
+35 -1
Переглянути файл
@@ -50,6 +50,7 @@ export const intellijMacos: Record<string, string[]> = {
"navigate.gotoBookmark8": ["Ctrl+8"],
"navigate.gotoBookmark9": ["Ctrl+9"],
"navigate.selectIn": ["Alt+F1"],
"navigate.showFilePath": ["Meta+Alt+F12"],
"navigate.lastEditLocation": ["Meta+Shift+Backspace"],
"navigate.nextEditLocation": [],
"navigate.gotoTest": ["Meta+Shift+T"],
@@ -97,9 +98,12 @@ export const intellijMacos: Record<string, string[]> = {
"code.moveLineDown": ["Alt+Shift+Down"],
"code.moveStatementUp": ["Meta+Shift+Up"],
"code.moveStatementDown": ["Meta+Shift+Down"],
"code.moveElementLeft": ["Meta+Alt+Shift+Left"],
"code.moveElementRight": ["Meta+Alt+Shift+Right"],
"code.completeStatement": ["Meta+Shift+Enter"],
"code.surroundWith": ["Meta+Alt+T"],
"code.insertLiveTemplate": ["Meta+J"],
"code.surroundWithLiveTemplate": ["Meta+Alt+J"],
"code.fold": ["Meta+-"],
"code.unfold": ["Meta+="],
"code.foldAll": ["Meta+Shift+-"],
@@ -117,6 +121,25 @@ export const intellijMacos: Record<string, string[]> = {
"editor.scrollToCenter": ["Ctrl+L"],
"editor.codeBlockStart": ["Meta+Alt+["],
"editor.codeBlockEnd": ["Meta+Alt+]"],
"editor.codeBlockStartWithSelection": ["Meta+Alt+Shift+["],
"editor.codeBlockEndWithSelection": ["Meta+Alt+Shift+]"],
"navigate.customRegion": ["Meta+Alt+."],
"code.expandToLevel1": ["Meta+* 1"],
"code.expandToLevel2": ["Meta+* 2"],
"code.expandToLevel3": ["Meta+* 3"],
"code.expandToLevel4": ["Meta+* 4"],
"code.expandToLevel5": ["Meta+* 5"],
"code.expandAllToLevel1": ["Meta+Shift+* 1"],
"code.expandAllToLevel2": ["Meta+Shift+* 2"],
"code.expandAllToLevel3": ["Meta+Shift+* 3"],
"code.expandAllToLevel4": ["Meta+Shift+* 4"],
"code.expandAllToLevel5": ["Meta+Shift+* 5"],
"code.collapseBlock": ["Meta+Shift+."],
"code.autoIndentLines": ["Ctrl+Alt+I"],
"analyze.inspectFile": ["Alt+Shift+I"],
"code.showErrorDescription": ["Meta+F1"],
"navigate.nextHighlightedUsage": ["Ctrl+Alt+Down"],
"navigate.prevHighlightedUsage": ["Ctrl+Alt+Up"],
// ⌥ pressed twice and held, then ↑ / ↓.
"editor.cloneCaretAbove": ["Alt Alt+Up"],
"editor.cloneCaretBelow": ["Alt Alt+Down"],
@@ -153,12 +176,14 @@ export const intellijMacos: Record<string, string[]> = {
"file.openProject": ["Meta+Shift+Alt+O"],
"file.newScratchFile": ["Meta+Shift+N"],
"editor.closeTab": ["Meta+W"],
"toolwindow.closeContent": ["Meta+W"],
"toolwindow.closeContent": ["Meta+W", "Ctrl+Shift+F4"],
"editor.reopenClosedTab": [],
"file.toggleReadOnly": [],
"editor.nextTab": ["Ctrl+Right", "Meta+Shift+]"],
"editor.prevTab": ["Ctrl+Left", "Meta+Shift+["],
"editor.splitRight": ["Meta+\\"],
"editor.nextSplitter": ["Alt+Tab"],
"editor.prevSplitter": ["Alt+Shift+Tab"],
"window.toggleFullScreen": ["Meta+Ctrl+F"],
"window.next": ["Meta+Alt+`"],
"window.previous": ["Meta+Alt+Shift+`"],
@@ -172,6 +197,7 @@ export const intellijMacos: Record<string, string[]> = {
"project.rename": ["Shift+F6"],
"project.move": ["F6"],
"project.delete": ["Delete", "Backspace", "Meta+Backspace"],
"project.copyElement": ["F5"],
"project.copy": ["Meta+C"],
"project.cut": ["Meta+X"],
"project.paste": ["Meta+V"],
@@ -199,6 +225,11 @@ export const intellijMacos: Record<string, string[]> = {
"toolwindow.terminal": ["Alt+F12"],
"toolwindow.hideAll": ["Meta+Shift+F12"],
"toolwindow.maximize": ["Meta+Shift+'"],
"toolwindow.resizeLeft": ["Ctrl+Alt+Left"],
"toolwindow.resizeRight": ["Ctrl+Alt+Right"],
"toolwindow.resizeUp": ["Ctrl+Alt+Up"],
"toolwindow.resizeDown": ["Ctrl+Alt+Down"],
"layout.restoreDefault": ["Shift+F12"],
"toolwindow.jumpToLast": ["F12"],
"toolwindow.hideActive": ["Shift+Escape"],
"toolwindow.focusEditor": ["Escape"],
@@ -221,6 +252,7 @@ export const intellijMacos: Record<string, string[]> = {
"web.openInBrowser": ["Ctrl+Shift+R"],
"run.debugContext": ["Ctrl+Shift+D"],
"run.stop": ["Meta+F2"],
"workbench.stopBackgroundProcesses": ["Meta+Shift+F2"],
// Rerun in the Run / Debug tool window only (when / keyWhen); Meta+R stays with the editor elsewhere.
"run.rerun": ["Meta+R"],
"coverage.manageReports": ["Meta+Alt+F6"],
@@ -229,6 +261,7 @@ export const intellijMacos: Record<string, string[]> = {
"find.rerun": ["Meta+R"],
"usages.rerun": ["Meta+R"],
"run.chooseConfig": ["Ctrl+Alt+R"],
"run.chooseDebugConfig": ["Ctrl+Alt+D"],
"run.editConfigs": ["Ctrl+Alt+Shift+R"],
"debug.toggleBreakpoint": ["Meta+F8"],
"debug.viewBreakpoints": ["Meta+Shift+F8"],
@@ -247,6 +280,7 @@ export const intellijMacos: Record<string, string[]> = {
"debug.setValue": ["F2"],
"debug.jumpToSource": ["F4", "Meta+Down"],
"debug.evaluate": ["Alt+F8"],
"debug.quickEvaluate": ["Meta+Alt+F8"],
// VCS
"vcs.operations": ["Ctrl+V"],
+37 -1
Переглянути файл
@@ -51,6 +51,7 @@ export const intellijWindows: Record<string, string[]> = {
"navigate.gotoBookmark8": ["Ctrl+8"],
"navigate.gotoBookmark9": ["Ctrl+9"],
"navigate.selectIn": ["Alt+F1"],
"navigate.showFilePath": ["Ctrl+Alt+F12"],
"navigate.lastEditLocation": ["Ctrl+Shift+Backspace"],
"navigate.nextEditLocation": [],
"navigate.gotoTest": ["Ctrl+Shift+T"],
@@ -98,9 +99,12 @@ export const intellijWindows: Record<string, string[]> = {
"code.moveLineDown": ["Alt+Shift+Down"],
"code.moveStatementUp": ["Ctrl+Shift+Up"],
"code.moveStatementDown": ["Ctrl+Shift+Down"],
"code.moveElementLeft": ["Ctrl+Alt+Shift+Left"],
"code.moveElementRight": ["Ctrl+Alt+Shift+Right"],
"code.completeStatement": ["Ctrl+Shift+Enter"],
"code.surroundWith": ["Ctrl+Alt+T"],
"code.insertLiveTemplate": ["Ctrl+J"],
"code.surroundWithLiveTemplate": ["Ctrl+Alt+J"],
"code.fold": ["Ctrl+-"],
"code.unfold": ["Ctrl+="],
"code.foldAll": ["Ctrl+Shift+-"],
@@ -118,6 +122,26 @@ export const intellijWindows: Record<string, string[]> = {
"editor.scrollToCenter": ["Ctrl+M"],
"editor.codeBlockStart": ["Ctrl+["],
"editor.codeBlockEnd": ["Ctrl+]"],
"editor.codeBlockStartWithSelection": ["Ctrl+Shift+["],
"editor.codeBlockEndWithSelection": ["Ctrl+Shift+]"],
"navigate.customRegion": ["Ctrl+Alt+."],
"code.expandToLevel1": ["Ctrl+* 1"],
"code.expandToLevel2": ["Ctrl+* 2"],
"code.expandToLevel3": ["Ctrl+* 3"],
"code.expandToLevel4": ["Ctrl+* 4"],
"code.expandToLevel5": ["Ctrl+* 5"],
"code.expandAllToLevel1": ["Ctrl+Shift+* 1"],
"code.expandAllToLevel2": ["Ctrl+Shift+* 2"],
"code.expandAllToLevel3": ["Ctrl+Shift+* 3"],
"code.expandAllToLevel4": ["Ctrl+Shift+* 4"],
"code.expandAllToLevel5": ["Ctrl+Shift+* 5"],
"code.collapseBlock": ["Ctrl+Shift+."],
"code.autoIndentLines": ["Ctrl+Alt+I"],
"analyze.inspectFile": ["Alt+Shift+I"],
"code.showErrorDescription": ["Ctrl+F1"],
// After Next / Previous Occurrence: these win the shared key in a text editor.
"navigate.nextHighlightedUsage": ["Ctrl+Alt+Down"],
"navigate.prevHighlightedUsage": ["Ctrl+Alt+Up"],
// Ctrl pressed twice and held, then Up / Down.
"editor.cloneCaretAbove": ["Ctrl Ctrl+Up"],
"editor.cloneCaretBelow": ["Ctrl Ctrl+Down"],
@@ -153,12 +177,14 @@ export const intellijWindows: Record<string, string[]> = {
"file.openProject": [],
"file.newScratchFile": ["Ctrl+Alt+Shift+Insert"],
"editor.closeTab": ["Ctrl+F4"],
"toolwindow.closeContent": ["Ctrl+F4"],
"toolwindow.closeContent": ["Ctrl+F4", "Ctrl+Shift+F4"],
"editor.reopenClosedTab": [],
"file.toggleReadOnly": [],
"editor.nextTab": ["Alt+Right"],
"editor.prevTab": ["Alt+Left"],
"editor.splitRight": [],
"editor.nextSplitter": ["Alt+Tab"],
"editor.prevSplitter": ["Alt+Shift+Tab"],
"window.toggleFullScreen": [],
"window.next": ["Ctrl+Alt+]"],
"window.previous": ["Ctrl+Alt+["],
@@ -172,6 +198,7 @@ export const intellijWindows: Record<string, string[]> = {
"project.rename": ["Shift+F6"],
"project.move": ["F6"],
"project.delete": ["Delete"],
"project.copyElement": ["F5"],
"project.copy": ["Ctrl+C", "Ctrl+Insert"],
"project.cut": ["Ctrl+X", "Shift+Delete"],
"project.paste": ["Ctrl+V", "Shift+Insert"],
@@ -199,6 +226,7 @@ export const intellijWindows: Record<string, string[]> = {
"toolwindow.terminal": ["Alt+F12"],
"toolwindow.hideAll": ["Ctrl+Shift+F12"],
"toolwindow.maximize": ["Ctrl+Shift+'"],
"layout.restoreDefault": ["Shift+F12"],
"toolwindow.jumpToLast": ["F12"],
"toolwindow.hideActive": ["Shift+Escape"],
"toolwindow.focusEditor": ["Escape"],
@@ -218,6 +246,7 @@ export const intellijWindows: Record<string, string[]> = {
"web.openInBrowser": ["Ctrl+Shift+F10"],
"run.debugContext": [],
"run.stop": ["Ctrl+F2"],
"workbench.stopBackgroundProcesses": ["Ctrl+Shift+F2"],
// Rerun in the Run / Debug tool window only (when / keyWhen); Ctrl+F5 stays with the editor elsewhere.
"run.rerun": ["Ctrl+F5"],
"coverage.manageReports": ["Ctrl+Alt+F6"],
@@ -226,6 +255,7 @@ export const intellijWindows: Record<string, string[]> = {
"find.rerun": ["Ctrl+F5"],
"usages.rerun": ["Ctrl+F5"],
"run.chooseConfig": ["Alt+Shift+F10"],
"run.chooseDebugConfig": ["Alt+Shift+F9"],
"run.editConfigs": [],
"debug.toggleBreakpoint": ["Ctrl+F8"],
"debug.viewBreakpoints": ["Ctrl+Shift+F8"],
@@ -244,6 +274,7 @@ export const intellijWindows: Record<string, string[]> = {
"debug.setValue": ["F2"],
"debug.jumpToSource": ["F4"],
"debug.evaluate": ["Alt+F8"],
"debug.quickEvaluate": ["Ctrl+Alt+F8"],
// VCS
"vcs.operations": ["Alt+`"],
@@ -257,6 +288,11 @@ export const intellijWindows: Record<string, string[]> = {
"vcs.showDiff": ["Ctrl+D"],
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
// After Next / Previous Change: these win the shared keys in a tool window.
"toolwindow.resizeLeft": ["Ctrl+Alt+Shift+Left"],
"toolwindow.resizeRight": ["Ctrl+Alt+Shift+Right"],
"toolwindow.resizeUp": ["Ctrl+Alt+Shift+Up"],
"toolwindow.resizeDown": ["Ctrl+Alt+Shift+Down"],
"vcs.branches": ["Ctrl+Shift+`"],
"vcs.annotate": [],
"vcs.localHistory": [],
+35
Переглянути файл
@@ -26,6 +26,41 @@ export const vscodeMacos: Record<string, string[]> = {
"navigate.nextError": ["F8"],
"navigate.prevError": ["Shift+F8"],
"navigate.callHierarchy": ["Alt+Shift+H"],
"navigate.showFilePath": [],
"navigate.customRegion": [],
// editor.action.wordHighlight.next / prev
"navigate.nextHighlightedUsage": ["F7"],
"navigate.prevHighlightedUsage": ["Shift+F7"],
"editor.codeBlockStartWithSelection": [],
"editor.codeBlockEndWithSelection": [],
"code.collapseBlock": [],
"code.expandToLevel1": [],
"code.expandAllToLevel1": [],
"code.expandToLevel2": [],
"code.expandAllToLevel2": [],
"code.expandToLevel3": [],
"code.expandAllToLevel3": [],
"code.expandToLevel4": [],
"code.expandAllToLevel4": [],
"code.expandToLevel5": [],
"code.expandAllToLevel5": [],
"code.autoIndentLines": [],
"code.showErrorDescription": [],
"code.surroundWithLiveTemplate": [],
"code.moveElementLeft": [],
"code.moveElementRight": [],
"analyze.inspectFile": [],
"project.copyElement": [],
"run.chooseDebugConfig": [],
"debug.quickEvaluate": [],
"editor.nextSplitter": [],
"editor.prevSplitter": [],
"toolwindow.resizeLeft": [],
"toolwindow.resizeRight": [],
"toolwindow.resizeUp": [],
"toolwindow.resizeDown": [],
"layout.restoreDefault": [],
"workbench.stopBackgroundProcesses": [],
"navigate.typeHierarchy": [],
"navigate.toggleBookmark": [],
"navigate.showBookmarks": [],
+33
Переглянути файл
@@ -398,6 +398,39 @@
"menu.editor.closeAllTabs": "Close All Tabs",
"menu.editor.reopenClosedTab": "Reopen Closed Tab",
"menu.editor.moveToOppositeGroup": "Move to Opposite Group",
"menu.navigate.showFilePath": "File Path",
"menu.navigate.customRegion": "Custom Folding…",
"menu.code.showErrorDescription": "Error Description",
"menu.code.surroundWithLiveTemplate": "Surround with Live Template…",
"menu.code.autoIndentLines": "Auto-Indent Lines",
"menu.code.moveStatementUp": "Move Statement Up",
"menu.code.moveStatementDown": "Move Statement Down",
"menu.code.moveElementLeft": "Move Element Left",
"menu.code.moveElementRight": "Move Element Right",
"menu.code.collapseBlock": "Fold Code Block",
"menu.analyze.inspectFile": "Inspect Current File",
"menu.project.copyElement": "Copy…",
"menu.run.chooseDebugConfig": "Debug…",
"menu.debug.quickEvaluate": "Quick Evaluate Expression",
"menu.resizeToolWindow": "Resize",
"menu.toolwindow.resizeLeft": "Stretch to Left",
"menu.toolwindow.resizeRight": "Stretch to Right",
"menu.toolwindow.resizeUp": "Stretch to Top",
"menu.toolwindow.resizeDown": "Stretch to Bottom",
"menu.editor.nextSplitter": "Go to Next Splitter",
"menu.editor.prevSplitter": "Go to Previous Splitter",
"menu.expandToLevel": "Expand to Level",
"menu.expandAllToLevel": "Expand All to Level",
"menu.code.expandToLevel1": "1",
"menu.code.expandAllToLevel1": "1",
"menu.code.expandToLevel2": "2",
"menu.code.expandAllToLevel2": "2",
"menu.code.expandToLevel3": "3",
"menu.code.expandAllToLevel3": "3",
"menu.code.expandToLevel4": "4",
"menu.code.expandAllToLevel4": "4",
"menu.code.expandToLevel5": "5",
"menu.code.expandAllToLevel5": "5",
"run.services.configurationCount": "{count} configurations",
"search.bookmarks.count": "{count, plural, one {# bookmark} other {# bookmarks}}",
"settings.zoom.hint": "Change with {zoomIn} or {zoomOut}. Set to 100% with {reset}"
+49 -1
Переглянути файл
@@ -4541,5 +4541,53 @@
"Show Tags": "显示标签",
"Branches Popup Options": "分支弹出窗口选项",
"Pull into '{cur}' Using Rebase": "使用变基拉取到 '{cur}'",
"Pull into '{cur}' Using Merge": "使用合并拉取到 '{cur}'"
"Pull into '{cur}' Using Merge": "使用合并拉取到 '{cur}'",
"No problems at the caret": "光标处没有问题",
"Error Description": "错误描述",
"Next Highlighted Usage": "下一个高亮的用法",
"Previous Highlighted Usage": "上一个高亮的用法",
"Move Caret to Code Block Start with Selection": "将文本光标移至代码块开头并选择",
"Move Caret to Code Block End with Selection": "将文本光标移至代码块结尾并选择",
"Auto-Indent Lines": "自动缩进行",
"Stretch to Left": "向左拉伸",
"Stretch to Right": "向右拉伸",
"Stretch to Top": "向上拉伸",
"Stretch to Bottom": "向下拉伸",
"Copy File…": "复制文件…",
"Copy Directory…": "复制目录…",
"Copy…": "复制…",
"Fold Code Block": "折叠代码块",
"Go to Next Splitter": "转到下一个拆分器",
"Go to Previous Splitter": "转到上一个拆分器",
"File Path": "文件路径",
"Cannot reveal in {fileManager}": "无法在{fileManager}中显示",
"Surround with Live Template": "用实时模板包围",
"Surround with Live Template…": "用实时模板包围…",
"Quick Evaluate Expression": "快速计算表达式",
"Nothing to evaluate at the caret": "光标处没有可计算的内容",
"Choose a run/debug configuration and debug it": "选择一个运行/调试配置并调试",
"Inspect Current File": "检查当前文件",
"No custom folding regions in this file": "此文件中没有自定义折叠区域",
"Custom Folding Regions": "自定义折叠区域",
"(no description)": "(无描述)",
"Custom Folding…": "自定义折叠…",
"Move Element Left": "向左移动元素",
"Move Element Right": "向右移动元素",
"Stop Background Processes…": "停止后台进程…",
"No background processes to stop": "没有可停止的后台进程",
"Expand to Level 1": "展开到级别 1",
"Expand All to Level 1": "全部展开到级别 1",
"Expand to Level 2": "展开到级别 2",
"Expand All to Level 2": "全部展开到级别 2",
"Expand to Level 3": "展开到级别 3",
"Expand All to Level 3": "全部展开到级别 3",
"Expand to Level 4": "展开到级别 4",
"Expand All to Level 4": "全部展开到级别 4",
"Expand to Level 5": "展开到级别 5",
"Expand All to Level 5": "全部展开到级别 5",
"Surround with Callable": "用 Callable 包围",
"Surround with ReadWriteLock.readLock": "用 ReadWriteLock.readLock 包围",
"Surround with ReadWriteLock.writeLock": "用 ReadWriteLock.writeLock 包围",
"Iterate Iterable or array": "迭代 Iterable 或数组",
"No live templates to surround with": "没有可用于包围的实时模板"
}
+33
Переглянути файл
@@ -398,6 +398,39 @@
"menu.editor.closeAllTabs": "关闭所有标签页",
"menu.editor.reopenClosedTab": "重新打开已关闭的标签页",
"menu.editor.moveToOppositeGroup": "移动到另一组",
"menu.navigate.showFilePath": "文件路径",
"menu.navigate.customRegion": "自定义折叠…",
"menu.code.showErrorDescription": "错误描述",
"menu.code.surroundWithLiveTemplate": "用实时模板包围…",
"menu.code.autoIndentLines": "自动缩进行",
"menu.code.moveStatementUp": "上移语句",
"menu.code.moveStatementDown": "下移语句",
"menu.code.moveElementLeft": "向左移动元素",
"menu.code.moveElementRight": "向右移动元素",
"menu.code.collapseBlock": "折叠代码块",
"menu.analyze.inspectFile": "检查当前文件",
"menu.project.copyElement": "复制…",
"menu.run.chooseDebugConfig": "调试…",
"menu.debug.quickEvaluate": "快速计算表达式",
"menu.resizeToolWindow": "调整大小",
"menu.toolwindow.resizeLeft": "向左拉伸",
"menu.toolwindow.resizeRight": "向右拉伸",
"menu.toolwindow.resizeUp": "向上拉伸",
"menu.toolwindow.resizeDown": "向下拉伸",
"menu.editor.nextSplitter": "转到下一个拆分器",
"menu.editor.prevSplitter": "转到上一个拆分器",
"menu.expandToLevel": "展开到级别",
"menu.expandAllToLevel": "全部展开到级别",
"menu.code.expandToLevel1": "1",
"menu.code.expandAllToLevel1": "1",
"menu.code.expandToLevel2": "2",
"menu.code.expandAllToLevel2": "2",
"menu.code.expandToLevel3": "3",
"menu.code.expandAllToLevel3": "3",
"menu.code.expandToLevel4": "4",
"menu.code.expandAllToLevel4": "4",
"menu.code.expandToLevel5": "5",
"menu.code.expandAllToLevel5": "5",
"run.services.configurationCount": "{count} 个配置",
"search.bookmarks.count": "{count} 个书签",
"settings.zoom.hint": "使用 {zoomIn} 或 {zoomOut} 更改,使用 {reset} 重置为 100%"
+21
Переглянути файл
@@ -8,6 +8,8 @@ import { notify } from "./notificationStore";
import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications";
import { activateToolWindow } from "./toolwindows/store";
import { toggleTheme } from "./theme";
import { useTasks } from "./tasks";
import { showStatusInfo } from "./contributions";
export async function openProjectDialog() {
if (!isTauri()) {
@@ -35,6 +37,18 @@ export async function openProjectDialog() {
}
}
/** Stop Background Processes (⇧⌘F2): the one running cancellable task, else a choice of them. */
export function stopBackgroundProcesses() {
const running = useTasks.getState().tasks.filter((t) => t.cancel && !t.done);
if (!running.length) return showStatusInfo("No background processes to stop");
if (running.length === 1) return running[0]!.cancel!();
showContextMenu({ x: window.innerWidth / 2 - 160, y: 96 }, [
...running.map((t) => ({ id: String(t.id), label: t.title, icon: "stop", run: () => t.cancel!() })),
{ kind: "separator" as const },
{ id: "all", label: "Stop All", run: () => running.forEach((t) => t.cancel!()) },
]);
}
/** Generic actions owned by the shell. */
export function registerShellActions(): () => void {
return registerActions([
@@ -55,6 +69,13 @@ export function registerShellActions(): () => void {
if (useProject.getState().project && (await confirmLeaveProject())) await closeProject();
},
},
{
id: "workbench.stopBackgroundProcesses",
title: "Stop Background Processes…",
category: "Window",
keywords: ["cancel task", "background tasks", "progress"],
run: stopBackgroundProcesses,
},
{
id: "view.toggleTheme",
title: "Toggle Light/Dark Theme",
+26
Переглянути файл
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useTasks } from "./tasks";
import { stopBackgroundProcesses } from "./shellActions";
const task = (id: number, cancel?: () => void, done = false) => ({
id,
title: `Task ${id}`,
message: null,
fraction: null,
done,
startedAt: 0,
cancel,
});
afterEach(() => useTasks.setState({ tasks: [] }));
describe("stopBackgroundProcesses", () => {
it("stops the only running cancellable task", () => {
const cancel = vi.fn();
const finished = vi.fn();
useTasks.setState({ tasks: [task(1, cancel), task(2), task(3, finished, true)] });
stopBackgroundProcesses();
expect(cancel).toHaveBeenCalledOnce();
expect(finished).not.toHaveBeenCalled();
});
});
+11
Переглянути файл
@@ -23,6 +23,7 @@ import {
hideToolWindow,
jumpToLastToolWindow,
lastUsedToolWindow,
resizeActiveToolWindow,
useToolWindowLayout,
toggleMaximizeToolWindow,
} from "./store";
@@ -242,6 +243,16 @@ export function installToolWindowActions(): () => void {
if (id) toggleMaximizeToolWindow(id);
},
},
...(["left", "right", "up", "down"] as const).map((dir): Action => ({
id: `toolwindow.resize${dir[0]!.toUpperCase()}${dir.slice(1)}`,
title: { left: "Stretch to Left", right: "Stretch to Right", up: "Stretch to Top", down: "Stretch to Bottom" }[
dir
],
category: "Window",
keywords: ["resize tool window", "tool window size"],
when: "toolWindowFocus",
run: () => resizeActiveToolWindow(dir),
})),
]);
return () => {
+19
Переглянути файл
@@ -15,6 +15,8 @@ import {
loadToolWindowLayout,
moveToolWindow,
installToolWindowLayoutSync,
resizeActiveToolWindow,
RESIZE_STEP,
resetToolWindowPlacements,
setToolWindowSplit,
slotOfId,
@@ -96,6 +98,23 @@ describe("tool window semantics", () => {
expect(isToolWindowVisible("project")).toBe(true);
});
it("resizes the active window by moving its inner edge", () => {
showToolWindow("terminal");
setFocusedToolWindow("terminal");
const before = useToolWindowLayout.getState().sizes.bottom;
resizeActiveToolWindow("up");
expect(useToolWindowLayout.getState().sizes.bottom).toBe(before + RESIZE_STEP);
resizeActiveToolWindow("down");
resizeActiveToolWindow("down");
expect(useToolWindowLayout.getState().sizes.bottom).toBe(before - RESIZE_STEP);
resizeActiveToolWindow("left");
expect(useToolWindowLayout.getState().sizes.bottom).toBe(before - RESIZE_STEP);
setFocusedToolWindow("project");
const left = useToolWindowLayout.getState().sizes.left;
resizeActiveToolWindow("right");
expect(useToolWindowLayout.getState().sizes.left).toBe(left + RESIZE_STEP);
});
it("ignores unknown ids and hiding a window that isn't visible", () => {
showToolWindow("nope");
hideToolWindow("structure");
+24
Переглянути файл
@@ -513,6 +513,30 @@ export function setToolWindowSize(anchor: ToolWindowAnchor, px: number) {
update({ sizes: { ...s.sizes, [anchor]: v } });
}
export type ResizeDirection = "left" | "right" | "up" | "down";
/** Pixels per Resize Tool Window step. */
export const RESIZE_STEP = 24;
/** Size change of a window docked at `anchor` for an arrow: moving its inner edge outwards grows it. */
export function resizeDelta(anchor: ToolWindowAnchor, dir: ResizeDirection): number {
const grow = { left: "right", right: "left", bottom: "up" } as const;
const shrink = { left: "left", right: "right", bottom: "down" } as const;
return dir === grow[anchor] ? RESIZE_STEP : dir === shrink[anchor] ? -RESIZE_STEP : 0;
}
/** Window › Resize Tool Window: moves the inner edge of the active tool window by one step. */
export function resizeActiveToolWindow(dir: ResizeDirection) {
const s = useToolWindowLayout.getState();
const id = s.focused ?? s.lastActive;
const anchor = id && isToolWindowVisible(id) ? anchorOf(id) : undefined;
if (!anchor) return;
const delta = resizeDelta(anchor, dir);
if (!delta) return;
const room = (anchor === "bottom" ? window.innerHeight : window.innerWidth) - 160;
setToolWindowSize(anchor, Math.min(room, s.sizes[anchor] + delta));
}
/** Share of `anchor`'s side given to its primary area when both areas show. */
export function setToolWindowSplit(anchor: ToolWindowAnchor, ratio: number) {
const s = useToolWindowLayout.getState();