diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index d0ee9cf12..360a02740 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -409,8 +409,10 @@ pub fn spec(os: Os) -> Vec { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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) })); diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 01c4b920f..d2304b86f 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -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", diff --git a/src/editor/core/cm/caretCommands.ts b/src/editor/core/cm/caretCommands.ts index 6e71d7680..a7eff8fc5 100644 --- a/src/editor/core/cm/caretCommands.ts +++ b/src/editor/core/cm/caretCommands.ts @@ -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 diff --git a/src/editor/core/cm/collapseBlock.test.ts b/src/editor/core/cm/collapseBlock.test.ts new file mode 100644 index 000000000..53a7fbf72 --- /dev/null +++ b/src/editor/core/cm/collapseBlock.test.ts @@ -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(); + }); +}); diff --git a/src/editor/core/cm/folding.test.ts b/src/editor/core/cm/folding.test.ts index 20a398e09..2166f0177 100644 --- a/src/editor/core/cm/folding.test.ts +++ b/src/editor/core/cm/folding.test.ts @@ -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([]); + }); +}); diff --git a/src/editor/core/cm/folding.ts b/src/editor/core/cm/folding.ts index 613766f02..f916b874e 100644 --- a/src/editor/core/cm/folding.ts +++ b/src/editor/core/cm/folding.ts @@ -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(); @@ -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[] { + const effects: StateEffect[] = []; + 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[] { } /** 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[] { + 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[] = []; + 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[] { 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; diff --git a/src/editor/core/cm/moveElement.test.ts b/src/editor/core/cm/moveElement.test.ts new file mode 100644 index 000000000..715c6db28 --- /dev/null +++ b/src/editor/core/cm/moveElement.test.ts @@ -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]"); + }); +}); diff --git a/src/editor/core/cm/moveElement.ts b/src/editor/core/cm/moveElement.ts new file mode 100644 index 000000000..ac53f0615 --- /dev/null +++ b/src/editor/core/cm/moveElement.ts @@ -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); diff --git a/src/editor/core/cm/regions.test.ts b/src/editor/core/cm/regions.test.ts new file mode 100644 index 000000000..dd15733d1 --- /dev/null +++ b/src/editor/core/cm/regions.test.ts @@ -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"], + ['// ', "Getters"], + ['', "Head"], + ["/* region Block */", "Block"], + ["//region", ""], + ])("start %s", (line, name) => { + expect(regionMarker(line)).toEqual({ start: true, name }); + }); + + it.each(["//endregion", "# endregion", "#endregion", "// ", ""])( + "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(); + }); +}); diff --git a/src/editor/core/cm/regions.ts b/src/editor/core/cm/regions.ts new file mode 100644 index 000000000..43a0aaa29 --- /dev/null +++ b/src/editor/core/cm/regions.ts @@ -0,0 +1,64 @@ +/** + * Custom folding regions: `//region Name` … `//endregion`, `#region` (C#, Python…), + * `` … ``, 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*$/, "") + .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; +} diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 29d14b7aa..cc19e6f39 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -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)), diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index a5f5efe20..8533125f8 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -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) { diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 69962eaf1..e114a9b57 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -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", diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index 472fa46b5..f4cc7850f 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -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; +} diff --git a/src/editor/lsp/hint.ts b/src/editor/lsp/hint.ts index 8054d8f38..d103f78ad 100644 --- a/src/editor/lsp/hint.ts +++ b/src/editor/lsp/hint.ts @@ -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({ 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, diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index 67731ef0d..b46b8555d 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -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); diff --git a/src/features/analysis/index.ts b/src/features/analysis/index.ts index 3378fef16..501bc8afb 100644 --- a/src/features/analysis/index.ts +++ b/src/features/analysis/index.ts @@ -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…", diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index db4134c8c..fe8ad3075 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -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", diff --git a/src/features/debugger/editor/valueTooltip.ts b/src/features/debugger/editor/valueTooltip.ts index 6fafe8cdd..6c528443e 100644 --- a/src/features/debugger/editor/valueTooltip.ts +++ b/src/features/debugger/editor/valueTooltip.ts @@ -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); }, }; }, diff --git a/src/features/project-tree/actions.ts b/src/features/project-tree/actions.ts index b3bf0d213..0a1755d0e 100644 --- a/src/features/project-tree/actions.ts +++ b/src/features/project-tree/actions.ts @@ -18,6 +18,7 @@ import { findFileUsages, folderOf, leadNode, + openCopyDialog, openMoveDialog, openRenameDialog, openSelection, @@ -28,6 +29,7 @@ import { type PathKind, } from "./operations"; import { nameOf, parentOf } from "./paths"; +import { showFilePathPopup } from "./filePath"; import { showNewPopup, treeHandle } from "./ProjectView"; import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor"; import { @@ -317,6 +319,33 @@ export function registerProjectActions(): () => void { keywords: ["remove", "trash"], run: ({ arg }) => deleteSelection(nodesFrom(arg)), }, + { + // F5 (CopyElement): the Copy dialog for the selection, or for the file in the editor. + id: "project.copyElement", + title: "Copy…", + category: "Project View", + icon: "copy", + when: OPEN, + keyWhen: `${FOCUS} || editorFocus`, + keywords: ["copy file", "duplicate file", "clone"], + run: ({ arg, ctx }) => { + if (arg !== undefined || ctx.projectViewFocus) return openCopyDialog(nodesFrom(arg)); + const path = activeEditor()?.ctx.path; + if (path) openCopyDialog(nodesFrom(path)); + }, + }, + { + id: "navigate.showFilePath", + title: "File Path", + category: "Navigate", + when: OPEN, + keywords: ["reveal in finder", "show in explorer", "path popup"], + run: ({ arg, ctx }) => { + const node = typeof arg === "string" || ctx.projectViewFocus ? nodesFrom(arg)[0] : undefined; + const path = node?.path ?? activeEditor()?.ctx.path; + if (path) showFilePathPopup(path, node?.dir ?? false); + }, + }, { id: "project.copy", title: "Copy", category: "Project View", when: FOCUS, run: () => copySelection(false) }, { id: "project.cut", title: "Cut", category: "Project View", when: FOCUS, run: () => copySelection(true) }, { id: "project.paste", title: "Paste", category: "Project View", when: FOCUS, run: () => paste() }, diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index 082af17f2..d269d7905 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -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"]); }); diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index 1e7df7846..7ce9108bc 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -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", diff --git a/src/features/project-tree/filePath.test.ts b/src/features/project-tree/filePath.test.ts new file mode 100644 index 000000000..8da1095f8 --- /dev/null +++ b/src/features/project-tree/filePath.test.ts @@ -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:/"]); + }); +}); diff --git a/src/features/project-tree/filePath.ts b/src/features/project-tree/filePath.ts new file mode 100644 index 000000000..13a73f4a7 --- /dev/null +++ b/src/features/project-tree/filePath.ts @@ -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 }); +} diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 73d841800..e53f22611 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -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({ diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 5e6e6336b..699455018 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -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…", diff --git a/src/features/templates/editor.ts b/src/features/templates/editor.ts index 3fa69ad91..1ff51930a 100644 --- a/src/features/templates/editor.ts +++ b/src/features/templates/editor.ts @@ -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([...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(); diff --git a/src/features/templates/index.ts b/src/features/templates/index.ts index 440a4ab11..daf9e4e8f 100644 --- a/src/features/templates/index.ts +++ b/src/features/templates/index.ts @@ -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", diff --git a/src/features/templates/live/builtins.ts b/src/features/templates/live/builtins.ts index 2d3a5b591..59868e79d 100644 --- a/src/features/templates/live/builtins.ts +++ b/src/features/templates/live/builtins.ts @@ -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"')], + }, + ], + ), ]; /* diff --git a/src/features/templates/popup/Chooser.module.css b/src/features/templates/popup/Chooser.module.css index d692b92fa..656a47d77 100644 --- a/src/features/templates/popup/Chooser.module.css +++ b/src/features/templates/popup/Chooser.module.css @@ -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); diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index fd36aeb80..c9d77b14f 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -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 (``). + ["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"); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 2477c0cdd..37ba17b51 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -50,6 +50,7 @@ export const intellijMacos: Record = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { "debug.setValue": ["F2"], "debug.jumpToSource": ["F4", "Meta+Down"], "debug.evaluate": ["Alt+F8"], + "debug.quickEvaluate": ["Meta+Alt+F8"], // VCS "vcs.operations": ["Ctrl+V"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 4afa0c4d4..3700a59a4 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -51,6 +51,7 @@ export const intellijWindows: Record = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { "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 = { "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": [], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index e396d9fe4..83c52adb9 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -26,6 +26,41 @@ export const vscodeMacos: Record = { "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": [], diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index e17125720..3c2084be7 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -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}" diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b6ca4bed5..e7dcf9157 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4541,5 +4541,53 @@ "Show Tags": "显示标签", "Branches Popup Options": "分支弹出窗口选项", "Pull into '{cur}' Using Rebase": "使用变基拉取到 '{cur}'", - "Pull into '{cur}' Using Merge": "使用合并拉取到 '{cur}'" + "Pull into '{cur}' Using Merge": "使用合并拉取到 '{cur}'", + "No problems at the caret": "光标处没有问题", + "Error Description": "错误描述", + "Next Highlighted Usage": "下一个高亮的用法", + "Previous Highlighted Usage": "上一个高亮的用法", + "Move Caret to Code Block Start with Selection": "将文本光标移至代码块开头并选择", + "Move Caret to Code Block End with Selection": "将文本光标移至代码块结尾并选择", + "Auto-Indent Lines": "自动缩进行", + "Stretch to Left": "向左拉伸", + "Stretch to Right": "向右拉伸", + "Stretch to Top": "向上拉伸", + "Stretch to Bottom": "向下拉伸", + "Copy File…": "复制文件…", + "Copy Directory…": "复制目录…", + "Copy…": "复制…", + "Fold Code Block": "折叠代码块", + "Go to Next Splitter": "转到下一个拆分器", + "Go to Previous Splitter": "转到上一个拆分器", + "File Path": "文件路径", + "Cannot reveal in {fileManager}": "无法在{fileManager}中显示", + "Surround with Live Template": "用实时模板包围", + "Surround with Live Template…": "用实时模板包围…", + "Quick Evaluate Expression": "快速计算表达式", + "Nothing to evaluate at the caret": "光标处没有可计算的内容", + "Choose a run/debug configuration and debug it": "选择一个运行/调试配置并调试", + "Inspect Current File": "检查当前文件", + "No custom folding regions in this file": "此文件中没有自定义折叠区域", + "Custom Folding Regions": "自定义折叠区域", + "(no description)": "(无描述)", + "Custom Folding…": "自定义折叠…", + "Move Element Left": "向左移动元素", + "Move Element Right": "向右移动元素", + "Stop Background Processes…": "停止后台进程…", + "No background processes to stop": "没有可停止的后台进程", + "Expand to Level 1": "展开到级别 1", + "Expand All to Level 1": "全部展开到级别 1", + "Expand to Level 2": "展开到级别 2", + "Expand All to Level 2": "全部展开到级别 2", + "Expand to Level 3": "展开到级别 3", + "Expand All to Level 3": "全部展开到级别 3", + "Expand to Level 4": "展开到级别 4", + "Expand All to Level 4": "全部展开到级别 4", + "Expand to Level 5": "展开到级别 5", + "Expand All to Level 5": "全部展开到级别 5", + "Surround with Callable": "用 Callable 包围", + "Surround with ReadWriteLock.readLock": "用 ReadWriteLock.readLock 包围", + "Surround with ReadWriteLock.writeLock": "用 ReadWriteLock.writeLock 包围", + "Iterate Iterable or array": "迭代 Iterable 或数组", + "No live templates to surround with": "没有可用于包围的实时模板" } diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 2a56b7040..da443b0c9 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -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%" diff --git a/src/workbench/shellActions.ts b/src/workbench/shellActions.ts index 7fa0bd995..bbce13f40 100644 --- a/src/workbench/shellActions.ts +++ b/src/workbench/shellActions.ts @@ -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", diff --git a/src/workbench/stopBackground.test.ts b/src/workbench/stopBackground.test.ts new file mode 100644 index 000000000..9cae63be5 --- /dev/null +++ b/src/workbench/stopBackground.test.ts @@ -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(); + }); +}); diff --git a/src/workbench/toolwindows/actions.ts b/src/workbench/toolwindows/actions.ts index 98195279f..b24c2ca24 100644 --- a/src/workbench/toolwindows/actions.ts +++ b/src/workbench/toolwindows/actions.ts @@ -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 () => { diff --git a/src/workbench/toolwindows/store.test.ts b/src/workbench/toolwindows/store.test.ts index fb3066c97..d05982b00 100644 --- a/src/workbench/toolwindows/store.test.ts +++ b/src/workbench/toolwindows/store.test.ts @@ -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"); diff --git a/src/workbench/toolwindows/store.ts b/src/workbench/toolwindows/store.ts index 4b990df1f..3c448ae94 100644 --- a/src/workbench/toolwindows/store.ts +++ b/src/workbench/toolwindows/store.ts @@ -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();