mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
345cc7ec81 | ||
|
|
a404804ee7 |
No files matched your search
+59
-1
@@ -409,8 +409,10 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("navigate.gotoTest", "Test"),
|
||||
SEP,
|
||||
act("navigate.fileStructure", "File Structure"),
|
||||
act("navigate.showFilePath", "File Path"),
|
||||
act("navigate.callHierarchy", "Call Hierarchy"),
|
||||
act("navigate.typeHierarchy", "Type Hierarchy"),
|
||||
act("navigate.customRegion", "Custom Folding…"),
|
||||
SEP,
|
||||
act("navigate.nextMethod", "Next Method"),
|
||||
act("navigate.previousMethod", "Previous Method"),
|
||||
@@ -439,6 +441,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("code.parameterInfo", "Parameter Info"),
|
||||
act("code.quickDocumentation", "Quick Documentation"),
|
||||
act("code.expressionTypeInfo", "Expression Type Info"),
|
||||
act("code.showErrorDescription", "Error Description"),
|
||||
act("code.showIntentions", "Show Context Actions"),
|
||||
act("code.generate", "Generate…"),
|
||||
act("code.overrideMethods", "Override Methods…"),
|
||||
@@ -446,9 +449,11 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
SEP,
|
||||
act("code.insertLiveTemplate", "Insert Live Template…"),
|
||||
act("code.surroundWith", "Surround With…"),
|
||||
act("code.surroundWithLiveTemplate", "Surround with Live Template…"),
|
||||
act("code.unwrap", "Unwrap/Remove…"),
|
||||
SEP,
|
||||
act("code.reformat", "Reformat Code"),
|
||||
act("code.autoIndentLines", "Auto-Indent Lines"),
|
||||
act("code.optimizeImports", "Optimize Imports"),
|
||||
act("code.cleanup", "Code Cleanup…"),
|
||||
SEP,
|
||||
@@ -459,6 +464,10 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("code.shrinkSelection", "Shrink Selection"),
|
||||
act("code.duplicateLine", "Duplicate Line or Selection"),
|
||||
act("code.deleteLine", "Delete Line"),
|
||||
act("code.moveStatementUp", "Move Statement Up"),
|
||||
act("code.moveStatementDown", "Move Statement Down"),
|
||||
act("code.moveElementLeft", "Move Element Left"),
|
||||
act("code.moveElementRight", "Move Element Right"),
|
||||
act("code.moveLineUp", "Move Line Up"),
|
||||
act("code.moveLineDown", "Move Line Down"),
|
||||
SEP,
|
||||
@@ -469,13 +478,43 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("code.foldToggle", "Expand/Collapse"),
|
||||
act("code.foldAll", "Collapse All"),
|
||||
act("code.unfoldAll", "Expand All"),
|
||||
sub(
|
||||
"expandToLevel",
|
||||
"Expand to Level",
|
||||
vec![
|
||||
act("code.expandToLevel1", "1"),
|
||||
act("code.expandToLevel2", "2"),
|
||||
act("code.expandToLevel3", "3"),
|
||||
act("code.expandToLevel4", "4"),
|
||||
act("code.expandToLevel5", "5"),
|
||||
],
|
||||
),
|
||||
sub(
|
||||
"expandAllToLevel",
|
||||
"Expand All to Level",
|
||||
vec![
|
||||
act("code.expandAllToLevel1", "1"),
|
||||
act("code.expandAllToLevel2", "2"),
|
||||
act("code.expandAllToLevel3", "3"),
|
||||
act("code.expandAllToLevel4", "4"),
|
||||
act("code.expandAllToLevel5", "5"),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
act("code.collapseBlock", "Fold Code Block"),
|
||||
act("code.foldSelection", "Fold Selection / Remove Region"),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
act("analyze.inspectCode", "Inspect Code…"),
|
||||
sub("analyzeCode", "Analyze Code", vec![act("analyze.locateDuplicates", "Locate Duplicates…")]),
|
||||
sub(
|
||||
"analyzeCode",
|
||||
"Analyze Code",
|
||||
vec![
|
||||
act("analyze.inspectFile", "Inspect Current File"),
|
||||
act("analyze.locateDuplicates", "Locate Duplicates…"),
|
||||
],
|
||||
),
|
||||
act("analyze.stackTrace", "Analyze Stack Trace or Thread Dump…"),
|
||||
],
|
||||
));
|
||||
@@ -497,6 +536,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("refactor.inline", "Inline…"),
|
||||
SEP,
|
||||
act("refactor.move", "Move…"),
|
||||
act("project.copyElement", "Copy…"),
|
||||
act("refactor.safeDelete", "Safe Delete…"),
|
||||
],
|
||||
));
|
||||
@@ -526,6 +566,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("run.stop", "Stop"),
|
||||
SEP,
|
||||
act("run.chooseConfig", "Run…"),
|
||||
act("run.chooseDebugConfig", "Debug…"),
|
||||
act("runAnything.show", "Run Anything…"),
|
||||
act("run.editConfigs", "Edit Configurations…"),
|
||||
act("debug.attachToProcess", "Attach to Process…"),
|
||||
@@ -543,6 +584,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("debug.showExecutionPoint", "Show Execution Point"),
|
||||
act("debug.resetFrame", "Reset Frame"),
|
||||
act("debug.evaluate", "Evaluate Expression…"),
|
||||
act("debug.quickEvaluate", "Quick Evaluate Expression"),
|
||||
sub(
|
||||
"debuggingActions",
|
||||
"Debugging Actions",
|
||||
@@ -706,6 +748,16 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("toolwindow.hideActive", "Hide Active Tool Window"),
|
||||
act("toolwindow.maximize", "Maximize Tool Window"),
|
||||
act("toolwindow.closeContent", "Close Active Tab"),
|
||||
sub(
|
||||
"resizeToolWindow",
|
||||
"Resize",
|
||||
vec![
|
||||
act("toolwindow.resizeLeft", "Stretch to Left"),
|
||||
act("toolwindow.resizeRight", "Stretch to Right"),
|
||||
act("toolwindow.resizeUp", "Stretch to Top"),
|
||||
act("toolwindow.resizeDown", "Stretch to Bottom"),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
act("toolwindow.jumpToLast", "Jump to Last Tool Window"),
|
||||
act("toolwindow.focusEditor", "Focus Editor"),
|
||||
@@ -728,6 +780,8 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("editor.splitDown", "Split Down"),
|
||||
act("editor.unsplit", "Unsplit"),
|
||||
act("editor.moveToOppositeGroup", "Move to Opposite Group"),
|
||||
act("editor.nextSplitter", "Go to Next Splitter"),
|
||||
act("editor.prevSplitter", "Go to Previous Splitter"),
|
||||
],
|
||||
),
|
||||
SEP,
|
||||
@@ -1427,6 +1481,10 @@ mod tests {
|
||||
"toolwindow.closeContent",
|
||||
"toolwindow.jumpToLast",
|
||||
"toolwindow.focusEditor",
|
||||
"toolwindow.resizeLeft",
|
||||
"toolwindow.resizeRight",
|
||||
"toolwindow.resizeUp",
|
||||
"toolwindow.resizeDown",
|
||||
]
|
||||
.contains(id)
|
||||
}));
|
||||
|
||||
+121
-3
@@ -1,8 +1,15 @@
|
||||
/** Editor actions (ids from keymaps/intellij-macos.ts). */
|
||||
import { EditorSelection, type EditorState } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { addCursorAbove, addCursorBelow, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
|
||||
import { syntaxTree, toggleFold } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
addCursorAbove,
|
||||
addCursorBelow,
|
||||
indentSelection,
|
||||
moveLineDown,
|
||||
moveLineUp,
|
||||
toggleBlockComment,
|
||||
} from "@codemirror/commands";
|
||||
import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
|
||||
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
|
||||
import { executeAction, registerActions, type Action } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
@@ -37,16 +44,23 @@ import {
|
||||
collapseRecursively,
|
||||
expandAll,
|
||||
expandRecursively,
|
||||
collapseBlock,
|
||||
expandToLevelEffects,
|
||||
foldAtCaret,
|
||||
foldSelection,
|
||||
unfoldAtCaret,
|
||||
} from "./cm/folding";
|
||||
import { reverseLines, sortLines } from "./cm/lines";
|
||||
import { customRegions } from "./cm/regions";
|
||||
import { moveElementLeft, moveElementRight } from "./cm/moveElement";
|
||||
import { caretAnchor, openPopup } from "@/editor/lsp/popupStore";
|
||||
import {
|
||||
cursorCodeBlockEnd,
|
||||
cursorCodeBlockStart,
|
||||
cursorMatchBrace,
|
||||
scrollToCenter,
|
||||
selectCodeBlockEnd,
|
||||
selectCodeBlockStart,
|
||||
splitLine,
|
||||
} from "./cm/caretCommands";
|
||||
import { isOverwrite, toggleOverwrite } from "./cm/overwrite";
|
||||
@@ -314,6 +328,56 @@ function completeCurrentStatement(view: EditorView, slot: EditorSlot) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigate › Custom Folding (⌥⌘.): the file's custom regions; picking one moves the caret to it. */
|
||||
function showCustomRegions(view: EditorView) {
|
||||
const regions = customRegions(view.state.doc);
|
||||
if (!regions.length) return void executeAction("editor.showHint", { text: "No custom folding regions in this file" });
|
||||
const head = view.state.selection.main.head;
|
||||
openPopup({
|
||||
kind: "list",
|
||||
anchor: caretAnchor(view, head),
|
||||
title: "Custom Folding Regions",
|
||||
items: regions.map((r) => ({
|
||||
id: String(r.startLine),
|
||||
label: r.name || "(no description)",
|
||||
detail: `:${r.startLine}`,
|
||||
icon: "folder",
|
||||
run: () => {
|
||||
const line = view.state.doc.line(Math.min(r.startLine, view.state.doc.lines));
|
||||
const at = line.from + /^\s*/.exec(line.text)![0].length;
|
||||
view.dispatch({
|
||||
selection: { anchor: at },
|
||||
effects: EditorView.scrollIntoView(at, { y: "start", yMargin: 40 }),
|
||||
userEvent: "select.navigate",
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** Folding › Expand to Level / Expand All to Level (titles kept literal for the text catalog). */
|
||||
const FOLD_LEVELS: Array<[number, string, string]> = [
|
||||
[1, "Expand to Level 1", "Expand All to Level 1"],
|
||||
[2, "Expand to Level 2", "Expand All to Level 2"],
|
||||
[3, "Expand to Level 3", "Expand All to Level 3"],
|
||||
[4, "Expand to Level 4", "Expand All to Level 4"],
|
||||
[5, "Expand to Level 5", "Expand All to Level 5"],
|
||||
];
|
||||
|
||||
function foldLevel(view: EditorView, level: number, all: boolean) {
|
||||
const effects = expandToLevelEffects(view.state, level, all);
|
||||
if (!effects.length) return;
|
||||
// A caret inside a new fold would unfold it again: it moves to the fold's start.
|
||||
const head = view.state.selection.main.head;
|
||||
const hiding = effects
|
||||
.filter((e) => e.is(foldEffect))
|
||||
.map((e) => e.value as { from: number; to: number })
|
||||
.filter((r) => r.from < head && head < r.to)
|
||||
.sort((a, b) => a.from - b.from)[0];
|
||||
view.dispatch({ effects, selection: hiding ? { anchor: hiding.from } : undefined });
|
||||
}
|
||||
|
||||
// --- cyclic expand word / copy reference ------------------------------------------------
|
||||
|
||||
/** Text of the other open documents for Cyclic Expand Word (bounded). */
|
||||
@@ -425,6 +489,12 @@ export function registerEditorActions(): () => void {
|
||||
text("code.deleteLine", "Delete Line", (v) => deleteLine(v)),
|
||||
text("code.moveLineUp", "Move Line Up", (v) => moveLineUp(v)),
|
||||
text("code.moveLineDown", "Move Line Down", (v) => moveLineDown(v)),
|
||||
text("code.moveElementLeft", "Move Element Left", (v) => moveElementLeft(v), {
|
||||
keywords: ["swap argument", "move parameter"],
|
||||
}),
|
||||
text("code.moveElementRight", "Move Element Right", (v) => moveElementRight(v), {
|
||||
keywords: ["swap argument", "move parameter"],
|
||||
}),
|
||||
text("code.moveStatementUp", "Move Statement Up", (v, slot) => moveStatement(v, slot, true)),
|
||||
text("code.moveStatementDown", "Move Statement Down", (v, slot) => moveStatement(v, slot, false)),
|
||||
text("code.extendSelection", "Extend Selection", (_v, slot) => void queueSelection(slot, extend), {
|
||||
@@ -490,6 +560,38 @@ export function registerEditorActions(): () => void {
|
||||
text("editor.codeBlockEnd", "Move Caret to Code Block End", (v) => cursorCodeBlockEnd(v), {
|
||||
category: "Editor Actions",
|
||||
}),
|
||||
text(
|
||||
"editor.codeBlockStartWithSelection",
|
||||
"Move Caret to Code Block Start with Selection",
|
||||
(v) => selectCodeBlockStart(v),
|
||||
{ category: "Editor Actions" },
|
||||
),
|
||||
text(
|
||||
"editor.codeBlockEndWithSelection",
|
||||
"Move Caret to Code Block End with Selection",
|
||||
(v) => selectCodeBlockEnd(v),
|
||||
{
|
||||
category: "Editor Actions",
|
||||
},
|
||||
),
|
||||
text("navigate.customRegion", "Custom Folding…", (v) => showCustomRegions(v), {
|
||||
category: "Navigate",
|
||||
keywords: ["region", "editor-fold", "go to region"],
|
||||
}),
|
||||
...FOLD_LEVELS.flatMap(([level, one, all]) => [
|
||||
text(`code.expandToLevel${level}`, one, (v) => foldLevel(v, level, false), {
|
||||
category: "Folding",
|
||||
keywords: ["unfold", "fold level"],
|
||||
}),
|
||||
text(`code.expandAllToLevel${level}`, all, (v) => foldLevel(v, level, true), {
|
||||
category: "Folding",
|
||||
keywords: ["unfold", "fold level"],
|
||||
}),
|
||||
]),
|
||||
text("code.collapseBlock", "Fold Code Block", (v) => collapseBlock(v), { category: "Folding", keywords: ["fold"] }),
|
||||
text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), {
|
||||
keywords: ["reindent", "fix indentation"],
|
||||
}),
|
||||
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => addCursorAbove(v), { category: "Editor Actions" }),
|
||||
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => addCursorBelow(v), { category: "Editor Actions" }),
|
||||
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
|
||||
@@ -750,6 +852,22 @@ export function registerEditorActions(): () => void {
|
||||
when: "editorTabsOpen",
|
||||
run: () => ctrl.split(useTabs.getState().activePane, "column"),
|
||||
},
|
||||
{
|
||||
id: "editor.nextSplitter",
|
||||
title: "Go to Next Splitter",
|
||||
category: "Window",
|
||||
keywords: ["split", "editor group", "pane"],
|
||||
when: "editorSplit",
|
||||
run: () => ctrl.focusSplitter(1),
|
||||
},
|
||||
{
|
||||
id: "editor.prevSplitter",
|
||||
title: "Go to Previous Splitter",
|
||||
category: "Window",
|
||||
keywords: ["split", "editor group", "pane"],
|
||||
when: "editorSplit",
|
||||
run: () => ctrl.focusSplitter(-1),
|
||||
},
|
||||
{
|
||||
id: "editor.moveToOppositeGroup",
|
||||
title: "Move to Opposite Group",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -304,3 +304,18 @@ export function gotoDiagnostic(view: EditorView, dir: 1 | -1): boolean {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** ⌘F1 Show Error Description: the problems at the caret, worst first. */
|
||||
export function showErrorDescription(view: EditorView): boolean {
|
||||
const at = diagnosticsAt(view, view.state.selection.main.head);
|
||||
if (!at.length) {
|
||||
showHint(view, "No problems at the caret");
|
||||
return false;
|
||||
}
|
||||
const worst = at[0]!.diagnostic.severity;
|
||||
showHint(view, [...new Set(at.map((p) => p.diagnostic.message))].join("\n"), {
|
||||
tone: worst === "error" ? "error" : worst === "warning" ? "warning" : "info",
|
||||
timeout: 10000,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
+13
-4
@@ -10,7 +10,8 @@ import { txTemplate } from "@/platform/i18n";
|
||||
|
||||
interface Hint {
|
||||
pos: number;
|
||||
content: string | (() => HTMLElement);
|
||||
/** Text, or DOM built when shown (with `destroy` when it holds e.g. a React root). */
|
||||
content: string | (() => HTMLElement | { dom: HTMLElement; destroy: () => void });
|
||||
tone: "info" | "error" | "warning";
|
||||
}
|
||||
|
||||
@@ -34,9 +35,17 @@ const hintField = StateField.define<Hint | null>({
|
||||
const dom = document.createElement("div");
|
||||
dom.className = `cm-lsp-hint cm-lsp-hint-${h.tone}`;
|
||||
dom.setAttribute("role", "status");
|
||||
if (typeof h.content === "string") dom.textContent = txTemplate(h.content);
|
||||
else dom.appendChild(h.content());
|
||||
return { dom };
|
||||
if (typeof h.content === "string") {
|
||||
dom.textContent = txTemplate(h.content);
|
||||
return { dom };
|
||||
}
|
||||
const built = h.content();
|
||||
if (built instanceof HTMLElement) {
|
||||
dom.appendChild(built);
|
||||
return { dom };
|
||||
}
|
||||
dom.appendChild(built.dom);
|
||||
return { dom, destroy: built.destroy };
|
||||
},
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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]) };
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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:/"]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 `/`. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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%"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user