Compare commits

...
Author SHA1 Message Date
l0ng-ai 345cc7ec81 feat(project-tree): Sort the Project view by type or modification time, add mouse shortcuts in Keymap
- Project view Options: Sort by Name / Type / Modification Time (also in
  Find Action).
- Settings > Keymap: Add Mouse Shortcut records a side-button click with
  modifiers and checks conflicts like keyboard shortcuts.
2026-10-29 00:11:37 +08:00
l0ng-ai a404804ee7 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.
2026-10-28 23:49:14 +08:00
50 changed files with 1617 additions and 96 deletions

No files matched your search

+59 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
},
};
},
+19 -2
View File
@@ -1,6 +1,7 @@
import { shortcutLabel } from "@/platform/keymap";
import { IconButton, type MenuItem } from "@/ui";
import { VIEW_MODES, setViewMode, setViewOption, useProjectTree, type ViewOptions } from "./store";
import type { SortKey } from "./model";
/** The title's "Project ▾" pane switcher (IDEA: Project / Project Files / Open Files / …). */
export function useProjectTitleMenu(): { label: string; items: MenuItem[] } {
@@ -11,7 +12,10 @@ export function useProjectTitleMenu(): { label: string; items: MenuItem[] } {
};
}
export const OPTION_LABELS: Array<[keyof ViewOptions, string]> = [
/** The on / off view options. */
export type ToggleOption = { [K in keyof ViewOptions]: ViewOptions[K] extends boolean ? K : never }[keyof ViewOptions];
export const OPTION_LABELS: Array<[ToggleOption, string]> = [
["showHidden", "Show Hidden Files"],
["showExcluded", "Show Excluded Files"],
["compact", "Compact Middle Packages"],
@@ -40,6 +44,12 @@ export function ProjectHeaderActions() {
);
}
export const SORT_LABELS: Array<[SortKey, string]> = [
["name", "Sort by Name"],
["type", "Sort by Type"],
["modified", "Sort by Modification Time"],
];
/** View options in the header's "⋮" menu (IDEA: Project › Options › Appearance / Behavior). */
export function useProjectHeaderMenu(): MenuItem[] {
const options = useProjectTree((s) => s.options);
@@ -47,11 +57,18 @@ export function useProjectHeaderMenu(): MenuItem[] {
{ kind: "header", label: "Appearance" },
...OPTION_LABELS.slice(0, 4).map(([key, label]) => option(key, label, options)),
{ kind: "separator" },
...SORT_LABELS.map(([sortBy, label]): MenuItem => ({
id: `sort.${sortBy}`,
label,
checked: options.sortBy === sortBy,
run: () => setViewOption("sortBy", sortBy),
})),
{ kind: "separator" },
{ kind: "header", label: "Behavior" },
...OPTION_LABELS.slice(4).map(([key, label]) => option(key, label, options)),
];
}
function option(key: keyof ViewOptions, label: string, options: ViewOptions): MenuItem {
function option(key: ToggleOption, label: string, options: ViewOptions): MenuItem {
return { id: key, label, checked: options[key], run: () => setViewOption(key, !options[key]) };
}
+40 -1
View File
@@ -4,7 +4,7 @@ import { getAction, registerActions, type Action } from "@/platform/actions";
import { dirname, useProject } from "@/platform/project";
import { showContextMenu, type MenuItem } from "@/ui";
import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions";
import { OPTION_LABELS } from "./HeaderActions";
import { OPTION_LABELS, SORT_LABELS } from "./HeaderActions";
import { newMenuItems, revealLabel } from "./contextMenu";
import { openNewPopup } from "./newPopupModel";
import type { ProjectNode } from "./model";
@@ -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 {
@@ -170,6 +172,15 @@ export function registerProjectActions(): () => void {
},
}));
const sorts: Action[] = SORT_LABELS.map(([sortBy, label]) => ({
id: `project.sortBy.${sortBy}`,
title: tx`Project View: ${label}`,
category: "View",
keywords: ["project", "tree", "sort", "order"],
toggled: () => useProjectTree.getState().options.sortBy === sortBy,
run: () => setViewOption("sortBy", sortBy),
}));
return registerActions([
{
// A path argument (links, breadcrumbs, Open In › Project View) selects it in the project view.
@@ -317,6 +328,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() },
@@ -470,5 +508,6 @@ export function registerProjectActions(): () => void {
},
...toggles,
...modes,
...sorts,
]);
}
@@ -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
View File
@@ -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",
@@ -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
View File
@@ -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 });
}
+21
View File
@@ -6,6 +6,7 @@ import {
mergeListing,
nodeKeyFor,
planRefresh,
visibleEntries,
visibleInfo,
type Listings,
type TreeOptions,
@@ -191,3 +192,23 @@ describe("mergeListing", () => {
expect(mergeListing(undefined, prev)).toBe(prev);
});
});
describe("visibleEntries sorting", () => {
const files = [
entry("/r/b.ts", "file", { mtimeMs: 30 }),
entry("/r/a.md", "file", { mtimeMs: 10 }),
entry("/r/c.md", "file", { mtimeMs: 20 }),
entry("/r/src", "dir", { mtimeMs: 5 }),
];
const names = (sortBy: TreeOptions["sortBy"], foldersFirst = true) =>
visibleEntries(files, { ...DEFAULT_TREE_OPTIONS, sortBy, foldersFirst }).map((e) => e.name);
it("sorts by type, then name, folders first", () => {
expect(names("type")).toEqual(["src", "a.md", "c.md", "b.ts"]);
});
it("sorts by modification time, newest first", () => {
expect(names("modified")).toEqual(["src", "b.ts", "c.md", "a.md"]);
expect(names("modified", false)).toEqual(["b.ts", "c.md", "a.md", "src"]);
});
});
+23 -2
View File
@@ -15,13 +15,18 @@ export interface TreeOptions {
/** Merge single-child folder chains (`com/acme/orders` → `com.acme.orders`). */
compact: boolean;
foldersFirst: boolean;
/** Order within a folder: name, file type (extension), or newest modification first. */
sortBy: SortKey;
}
export type SortKey = "name" | "type" | "modified";
export const DEFAULT_TREE_OPTIONS: TreeOptions = {
showHidden: false,
showExcluded: true,
compact: true,
foldersFirst: true,
sortBy: "name",
};
export interface ProjectNode {
@@ -74,12 +79,28 @@ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "bas
/** Symlinks report the target's metadata; folders have size 0 (the entry has no target kind). */
export const isDirEntry = (e: FileEntry) => e.kind === "dir" || (e.kind === "symlink" && e.size === 0);
export function sortEntries(entries: readonly FileEntry[], foldersFirst: boolean): FileEntry[] {
const extensionOf = (name: string) => {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1) : "";
};
export function sortEntries(
entries: readonly FileEntry[],
foldersFirst: boolean,
sortBy: SortKey = "name",
): FileEntry[] {
return [...entries].sort((a, b) => {
if (foldersFirst) {
const d = Number(isDirEntry(b)) - Number(isDirEntry(a));
if (d) return d;
}
if (sortBy === "type") {
const t = collator.compare(extensionOf(a.name), extensionOf(b.name));
if (t) return t;
} else if (sortBy === "modified") {
const m = (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0);
if (m) return m;
}
return collator.compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
});
}
@@ -88,7 +109,7 @@ export function sortEntries(entries: readonly FileEntry[], foldersFirst: boolean
export function visibleEntries(listing: readonly FileEntry[], opts: TreeOptions): FileEntry[] {
const filtered = listing.filter((e) => isVisible(e, opts));
// The backend already sorts folders first (natural order); only re-sort when needed.
return opts.foldersFirst ? filtered : sortEntries(filtered, false);
return opts.foldersFirst && opts.sortBy === "name" ? filtered : sortEntries(filtered, opts.foldersFirst, opts.sortBy);
}
/** JVM-style source folders join compacted package chains with `.` instead of `/`. */
+9
View File
@@ -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({
+7 -1
View File
@@ -55,6 +55,12 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = {
previewTab: false,
};
function storedOptions(): ViewOptions {
const options = { ...DEFAULT_VIEW_OPTIONS, ...readJson<Partial<ViewOptions>>(OPTIONS_KEY) };
if (!["name", "type", "modified"].includes(options.sortBy)) options.sortBy = "name";
return options;
}
/** IDEA's Project view panes ("Project ▾" in the tool window header). */
export type ViewMode = "project" | "projectFiles" | "openFiles" | "changedFiles" | "scratches";
@@ -128,7 +134,7 @@ export const useProjectTree = create<ProjectTreeState>(() => ({
expanded: new Set(),
selected: new Set(),
lead: null,
options: { ...DEFAULT_VIEW_OPTIONS, ...readJson<Partial<ViewOptions>>(OPTIONS_KEY) },
options: storedOptions(),
editing: null,
clipboard: null,
revealRequest: null,
+16
View File
@@ -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…",
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { RecordShortcutDialog } from "../keymap/RecordShortcutDialog";
afterEach(cleanup);
const action = { id: "my.action", title: "My Action", category: "Test" };
describe("Mouse Shortcut dialog", () => {
it("records a side-button click with modifiers and submits it", () => {
const onSubmit = vi.fn();
render(
<RecordShortcutDialog
mouse
action={action}
actions={[action]}
draft={{ preset: "intellij-macos", overrides: {} }}
onCancel={() => {}}
onSubmit={onSubmit}
/>,
);
const pad = screen.getByRole("textbox", { name: "Mouse shortcut" });
fireEvent.mouseDown(pad, { button: 0 });
expect(screen.getByRole("button", { name: "OK" })).toBeDisabled();
fireEvent.mouseDown(pad, { button: 3, altKey: true });
fireEvent.click(screen.getByRole("button", { name: "OK" }));
expect(onSubmit).toHaveBeenCalledWith("Alt+Button4", [], false);
});
});
@@ -122,6 +122,19 @@
}
/* recorder */
.mousePad {
display: flex;
align-items: center;
justify-content: center;
height: 72px;
padding: 0 12px;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border-strong);
color: var(--fg-emphasis);
text-align: center;
cursor: default;
user-select: none;
}
.recorder {
display: flex;
align-items: center;
+22 -2
View File
@@ -170,6 +170,7 @@ export function KeymapPage() {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [lead, setLead] = useState<string | null>(null);
const [recordFor, setRecordFor] = useState<Action | null>(null);
const [recordMouse, setRecordMouse] = useState(false);
const [nameDialog, setNameDialog] = useState<NameDialog>(null);
const gearRef = useRef<HTMLButtonElement>(null);
@@ -249,7 +250,21 @@ export function KeymapPage() {
const menuFor = (action: Action): MenuItem[] => {
const bindings = resolveBindings(draft, action.id);
return [
{ label: "Add Keyboard Shortcut", icon: "keyboard", run: () => setRecordFor(action) },
{
label: "Add Keyboard Shortcut",
icon: "keyboard",
run: () => {
setRecordMouse(false);
setRecordFor(action);
},
},
{
label: "Add Mouse Shortcut",
run: () => {
setRecordMouse(true);
setRecordFor(action);
},
},
{ kind: "separator" },
...(bindings.length
? bindings.map((b) => ({
@@ -352,7 +367,11 @@ export function KeymapPage() {
icon="plus"
title="Add Keyboard Shortcut"
disabled={!leadAction}
onClick={() => leadAction && setRecordFor(leadAction)}
onClick={() => {
if (!leadAction) return;
setRecordMouse(false);
setRecordFor(leadAction);
}}
/>
</div>
@@ -417,6 +436,7 @@ export function KeymapPage() {
{recordFor && (
<RecordShortcutDialog
action={recordFor}
mouse={recordMouse}
actions={actions}
draft={draft}
onCancel={() => setRecordFor(null)}
@@ -1,12 +1,13 @@
/**
* IDEA's "Keyboard Shortcut" dialog: records a first (and optional second)
* stroke and lists conflicts live; OK with conflicts asks whether to remove the
* other assignments (Remove / Leave / Cancel).
* other assignments (Remove / Leave / Cancel). With `mouse` it is the "Mouse
* Shortcut" dialog: a click on its pad records the button and modifiers.
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { Checkbox, Dialog, Icon, Kbd, T } from "@/ui";
import type { Action } from "@/platform/actions";
import { normalizeBinding } from "@/platform/keymap";
import { formatBinding, mouseStrokeOf, normalizeBinding, suspendKeyboardDispatch } from "@/platform/keymap";
import { ask } from "@/workbench/messages";
import { findConflicts, resolveBindings, type Conflict, type KeymapDraft } from "./model";
import { groupPath } from "./groups";
@@ -21,14 +22,16 @@ export interface RecordShortcutDialogProps {
onCancel: () => void;
/** `removeConflicts`: also unbind the conflicting assignments. */
onSubmit: (binding: string, conflicts: Conflict[], removeConflicts: boolean) => void;
/** Record a mouse shortcut (side buttons with modifiers) instead of keys. */
mouse?: boolean;
}
export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmit }: RecordShortcutDialogProps) {
export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmit, mouse }: RecordShortcutDialogProps) {
const [first, setFirst] = useState("");
const [second, setSecond] = useState("");
const [useSecond, setUseSecond] = useState(false);
const binding = first && useSecond ? (second ? `${first} ${second}` : "") : first;
const binding = mouse ? first : first && useSecond ? (second ? `${first} ${second}` : "") : first;
const conflicts = useMemo(
() => (binding ? findConflicts(draft, binding, action.id, actions) : []),
[binding, draft, action.id, actions],
@@ -62,7 +65,7 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi
<Dialog
open
onClose={onCancel}
title={tx`Keyboard Shortcut: ${action.title}`}
title={mouse ? tx`Mouse Shortcut: ${action.title}` : tx`Keyboard Shortcut: ${action.title}`}
width={440}
buttons={[
{ label: "OK", role: "default", disabled: !binding || already, onClick: () => void ok() },
@@ -70,25 +73,18 @@ export function RecordShortcutDialog({ action, actions, draft, onCancel, onSubmi
]}
>
<div className={styles.recordBody}>
<div className={styles.recordRow}>
<span className={styles.recordLabel}>
<T>First stroke:</T>
</span>
<StrokeRecorder autoFocus value={first} onChange={setFirst} aria-label="First stroke" />
</div>
<div className={styles.recordRow}>
<Checkbox checked={useSecond} onChange={setUseSecond} label="Second stroke:" />
<StrokeRecorder
value={second}
onChange={setSecond}
disabled={!useSecond}
placeholder={useSecond ? "Press a shortcut" : ""}
aria-label="Second stroke"
{mouse ? (
<MouseRecorder value={first} onChange={setFirst} />
) : (
<KeyStrokes
first={first}
second={second}
useSecond={useSecond}
setFirst={setFirst}
setSecond={setSecond}
setUseSecond={setUseSecond}
/>
</div>
<div className={styles.recordHint}>
<T>Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish.</T>
</div>
)}
<div className={styles.conflictsLabel}>
<T>Conflicts:</T>
</div>
@@ -146,3 +142,69 @@ function ConflictList({ conflicts }: { conflicts: readonly Conflict[] }) {
</ul>
);
}
interface KeyStrokesProps {
first: string;
second: string;
useSecond: boolean;
setFirst: (v: string) => void;
setSecond: (v: string) => void;
setUseSecond: (v: boolean) => void;
}
function KeyStrokes({ first, second, useSecond, setFirst, setSecond, setUseSecond }: KeyStrokesProps) {
return (
<>
<div className={styles.recordRow}>
<span className={styles.recordLabel}>
<T>First stroke:</T>
</span>
<StrokeRecorder autoFocus value={first} onChange={setFirst} aria-label="First stroke" />
</div>
<div className={styles.recordRow}>
<Checkbox checked={useSecond} onChange={setUseSecond} label="Second stroke:" />
<StrokeRecorder
value={second}
onChange={setSecond}
disabled={!useSecond}
placeholder={useSecond ? "Press a shortcut" : ""}
aria-label="Second stroke"
/>
</div>
<div className={styles.recordHint}>
<T>Keys are recorded as pressed, including Tab and Esc. Use the buttons to finish.</T>
</div>
</>
);
}
/** Pad that records a side-button click with its modifiers; shortcuts stay off while the dialog is open. */
function MouseRecorder({ value, onChange }: { value: string; onChange: (binding: string) => void }) {
useEffect(() => suspendKeyboardDispatch(), []);
const record = (e: MouseEvent<HTMLDivElement>) => {
const stroke = mouseStrokeOf(e.nativeEvent);
if (!stroke) return;
e.preventDefault();
e.stopPropagation();
onChange(stroke);
};
return (
<div
role="textbox"
aria-readonly="true"
aria-label="Mouse shortcut"
className={styles.mousePad}
onMouseDown={record}
onAuxClick={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
{value ? (
<span className={styles.recorded}>{formatBinding(value)}</span>
) : (
<span className={styles.recorderPlaceholder}>
<T>Click here with a mouse side button, holding any modifiers</T>
</span>
)}
</div>
);
}
+10 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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"')],
},
],
),
];
/*
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}"
+56 -1
View File
@@ -4541,5 +4541,60 @@
"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": "没有可用于包围的实时模板",
"Sort by Name": "按名称排序",
"Sort by Type": "按类型排序",
"Sort by Modification Time": "按修改时间排序",
"Add Mouse Shortcut": "添加鼠标快捷键",
"Click here with a mouse side button, holding any modifiers": "按住任意修饰键,用鼠标侧键点击此处",
"Mouse shortcut": "鼠标快捷键",
"Mouse Shortcut: {action}": "鼠标快捷键:{action}"
}
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();