diff --git a/src/editor/core/gutterMenu.ts b/src/editor/core/gutterMenu.ts new file mode 100644 index 000000000..c01f56d23 --- /dev/null +++ b/src/editor/core/gutterMenu.ts @@ -0,0 +1,51 @@ +/** The line-number gutter's context menu, IDEA's EditorGutterPopupMenu. */ +import { executeAction } from "@/platform/actions"; +import { editorPrefs } from "@/platform/editorSettings"; +import { call, commands } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { menuGroupItems } from "@/workbench/contributions"; +import type { MenuItem } from "@/ui"; +import type { EditorSlot } from "./model/slot"; + +/** Whether a right-click on `target` belongs to the gutter menu (contributed gutters with their own menu handle theirs). */ +export function inGutterMenuArea(target: EventTarget | null): boolean { + const t = target as HTMLElement | null; + return !!t?.closest?.(".cm-gutters") && !t.closest(".cm-vcs-blame"); +} + +async function setEditorSetting(key: string, value: unknown) { + useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) }); +} + +function toggle(label: string, key: string, on: boolean): MenuItem { + return { label, checked: on, run: () => void setEditorSetting(key, !on) }; +} + +function appearanceItems(): MenuItem[] { + const p = editorPrefs(); + const whitespace = !!useProject.getState().settings?.editor?.showWhitespace; + return [ + toggle("Show Line Numbers", "appearance.lineNumbers", p.appearance.lineNumbers), + toggle("Show Gutter Icons", "showGutterIcons", p.showGutterIcons), + toggle("Show Folding Outline", "folding.showOutline", p.folding.showOutline), + toggle("Show Indent Guides", "appearance.indentGuides", p.appearance.indentGuides), + toggle("Show Whitespaces", "showWhitespace", whitespace), + toggle("Show Method Separators", "appearance.methodSeparators", p.appearance.methodSeparators), + toggle("Show Breadcrumbs", "breadcrumbs.show", p.breadcrumbs.show), + ]; +} + +const openSettings = (page: string) => () => void executeAction("file.settings", page); + +export function gutterMenuItems(slot: EditorSlot, line: number): MenuItem[] { + const model = slot.model; + return [ + // Contributed groups (Annotate with Git Blame, bookmarks), each followed by a separator. + ...menuGroupItems("editorGutter", { paths: [model.path], text: slot.currentState().doc.toString(), line }), + { label: "Soft-Wrap", checked: model.softWrapOn(), run: () => model.toggleSoftWrap() }, + { label: "Configure Soft Wraps…", run: openSettings("editor.general") }, + { kind: "separator" }, + { label: "Appearance", submenu: appearanceItems() }, + { label: "Configure Gutter Icons…", run: openSettings("editor.general.gutterIcons") }, + ]; +} diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index 48039ce9f..03f8067b7 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -17,9 +17,11 @@ import { type ChangeSet, type TransactionSpec, } from "@codemirror/state"; -import { EditorView, keymap, type ViewUpdate } from "@codemirror/view"; +import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view"; import { _addEditor, _removeEditor, documentSynced, isExternalChange, type EditorDocContext, type TextEditorHandle } from "@/editor/api"; +import { showContextMenu } from "@/ui"; import { baseExtensions, compartments, contributionExtensions } from "../cm/setup"; +import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu"; import type { DocModel } from "./document"; export interface SlotHost { @@ -107,6 +109,18 @@ export class EditorSlot { return true; }, }), + ViewPlugin.define((view) => { + // Gutters sit outside the content DOM. Gutter extensions with their own menu (breakpoints, + // annotations) handle the event first and prevent its default. + const onContextMenu = (e: MouseEvent) => { + if (e.defaultPrevented || !inGutterMenuArea(e.target)) return; + e.preventDefault(); + const block = view.lineBlockAtHeight(e.clientY - view.documentTop); + showContextMenu(e, gutterMenuItems(this, view.state.doc.lineAt(block.from).number - 1)); + }; + view.dom.addEventListener("contextmenu", onContextMenu); + return { destroy: () => view.dom.removeEventListener("contextmenu", onContextMenu) }; + }), ]; } diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index ae67f850c..615e1e62e 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -6,13 +6,11 @@ import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; import { Decoration, EditorView, GutterMarker, ViewPlugin, lineNumberMarkers, type ViewUpdate } from "@codemirror/view"; import { editorsFor, mirroredChange, remoteChange, useEditorRegistry, type EditorDocContext } from "@/editor/api"; -import { showContextMenu } from "@/ui"; import { isConditional, mapLines, type BreakpointEntry, type BreakpointStatus } from "../breakpoints/model"; import { addBreakpoint, breakpointsOf, setFileBreakpoints, - toggleTemporaryBreakpoint, updateBreakpoint, useBreakpoints, } from "../breakpoints/store"; @@ -21,14 +19,11 @@ import { removeSpecial, setSpecialLines, specialAt, - toggleFieldAt, - toggleMethodAt, updateSpecial, useSpecialBreakpoints, type SpecialBreakpoint, } from "../breakpoints/special"; import { toggleBreakpointSmart } from "../breakpoints/toggle"; -import { methodAt, fieldAt } from "../breakpoints/symbols"; import { sessionsRunning, useDebug } from "../state"; import { openBreakpointPopover } from "../ui/popoverStore"; import styles from "./editor.module.css"; @@ -172,7 +167,8 @@ export function breakpointGutter(ctx: EditorDocContext): Extension { }), ]; view.dom.addEventListener("mousedown", this.onMouseDown); - view.dom.addEventListener("contextmenu", this.onContextMenu); + // Inside view.dom, so this runs before the editor's gutter menu listener there. + view.scrollDOM.addEventListener("contextmenu", this.onContextMenu); } scheduleRebuild() { @@ -234,59 +230,18 @@ export function breakpointGutter(ctx: EditorDocContext): Extension { if (!inBreakpointArea(e)) return; const line = lineAtEvent(this.view, e); if (line == null) return; - e.preventDefault(); - e.stopPropagation(); - const at = { x: e.clientX, y: e.clientY }; + // Other lines get the editor's gutter menu, like IDEA. const bp = specialAt(path).find((b) => b.line === line) ?? breakpointsOf(path).find((b) => b.line === line); - if (bp) { - openBreakpointPopover(path, bp.id, at); - return; - } - if (!canPlaceBreakpoint(ctx.language)) return; - const text = this.view.state.doc.toString(); - const method = methodAt(ctx.language, text, line); - const field = fieldAt(ctx.language, text, line); - showContextMenu(at, [ - { label: "Toggle Line Breakpoint", icon: "breakpoint", run: () => addBreakpoint(path, line) }, - { - label: "Toggle Temporary Line Breakpoint", - icon: "breakpoint", - run: () => toggleTemporaryBreakpoint(path, line), - }, - ...(method - ? [ - { - label: `Toggle Method Breakpoint (${method.label})`, - icon: "breakpoint-method", - run: () => void toggleMethodAt(path, line, method.name), - }, - ] - : []), - ...(field - ? [ - { - label: `Toggle Field Watchpoint (${field.label})`, - icon: "breakpoint-field", - run: () => void toggleFieldAt(path, line, field), - }, - ] - : []), - { - label: "Add Conditional Breakpoint…", - run: () => openBreakpointPopover(path, addBreakpoint(path, line).id, at), - }, - { - label: "Add Logpoint…", - run: () => openBreakpointPopover(path, addBreakpoint(path, line).id, at, true), - }, - ]); + if (!bp) return; + e.preventDefault(); + openBreakpointPopover(path, bp.id, { x: e.clientX, y: e.clientY }); }; destroy() { this.destroyed = true; this.unsubs.forEach((u) => u()); this.view.dom.removeEventListener("mousedown", this.onMouseDown); - this.view.dom.removeEventListener("contextmenu", this.onContextMenu); + this.view.scrollDOM.removeEventListener("contextmenu", this.onContextMenu); } }, ); diff --git a/src/features/search/index.ts b/src/features/search/index.ts index 02264412d..47a344278 100644 --- a/src/features/search/index.ts +++ b/src/features/search/index.ts @@ -5,12 +5,14 @@ */ import { lazyComponent } from "@/ui"; import { activeEditor, registerEditorContribution } from "@/editor/api"; -import { registerActions } from "@/platform/actions"; +import { executeAction, registerActions } from "@/platform/actions"; +import { shortcutLabel } from "@/platform/keymap"; import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import { registerOverlay, registerToolWindow } from "@/workbench/contributions"; +import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions"; import { bookmarkGutter } from "./bookmarks/gutter"; import { + hasBookmark, loadBookmarks, renameBookmarks, toggleBookmark, @@ -46,7 +48,8 @@ function closeAllPopups(except?: PopupKind) { if (except !== "everywhere") hideSearchEverywhere(); if (except !== "recent") hideRecentPopup(); if (except !== "find") hideFindPopup(); - if (except !== "fileStructure" && useFileStructurePopup.getState().open) useFileStructurePopup.setState({ open: false }); + if (except !== "fileStructure" && useFileStructurePopup.getState().open) + useFileStructurePopup.setState({ open: false }); } function jumpBookmark(dir: 1 | -1) { @@ -59,7 +62,9 @@ function jumpBookmark(dir: 1 | -1) { const cur = `${path ?? ""}\u0000${String(line).padStart(9, "0")}`; const sorted = order.sort((a, b) => (a.k < b.k ? -1 : a.k > b.k ? 1 : 0)); const next = - dir === 1 ? (sorted.find((x) => x.k > cur) ?? sorted[0]) : ([...sorted].reverse().find((x) => x.k < cur) ?? sorted[sorted.length - 1]); + dir === 1 + ? (sorted.find((x) => x.k > cur) ?? sorted[0]) + : ([...sorted].reverse().find((x) => x.k < cur) ?? sorted[sorted.length - 1]); if (next) void openLocation(next.b.path, { line: next.b.line, col: 0 }); } @@ -113,6 +118,22 @@ export function activate(): void | (() => void) { order: 50, extensions: (ctx) => bookmarkGutter(ctx.path), }), + registerMenuGroup({ + id: "search.bookmarks", + menus: ["editorGutter"], + order: 20, + items: (_menu, { paths: [path], line }) => { + if (!path || line == null) return []; + const on = hasBookmark(useBookmarks.getState().items, path, line); + return [ + { + label: on ? "Remove Bookmark" : "Add Bookmark", + shortcut: shortcutLabel("navigate.toggleBookmark"), + run: () => void executeAction("navigate.toggleBookmark", { path, line }), + }, + ]; + }, + }), registerActions([ { @@ -298,7 +319,8 @@ export function activate(): void | (() => void) { onProject(s.project?.root ?? null); resetTracking(); // A directory scope pointed into the previous project. - if (useFindPopup.getState().scopeTab === "directory") useFindPopup.setState({ scopeTab: "project", directory: "" }); + if (useFindPopup.getState().scopeTab === "directory") + useFindPopup.setState({ scopeTab: "project", directory: "" }); }), onIdeEvent("fsChanged", (e) => { if (!e.removed.length) return; diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index acbebe997..f1d96e2c1 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -4,21 +4,23 @@ * merge and Local History editors, remote operations. */ import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings"; -import { registerEditorContribution } from "@/editor/api"; +import { editorsFor, registerEditorContribution } from "@/editor/api"; import { notifications, registerEditorKind, + registerMenuGroup, registerOverlay, registerTitleItem, registerToolWindow, } from "@/workbench/contributions"; import { executeAction } from "@/platform/actions"; +import { getContext } from "@/platform/context"; import { lazyComponent } from "@/ui"; import { registerVcsActions } from "./actions"; import { installChangelists } from "./changelists"; import { registerHistoryActions, trackOperationContext } from "./historyActions"; import { controlLabel, controlOperation, inProgress } from "./operations"; -import { blameSlot } from "./blame"; +import { annotationsShown, blameSlot } from "./blame"; import { BranchWidget, useBranchesPopup } from "./BranchWidget"; import { showConflictsDialog } from "./conflictsUi"; import { registerCompareActions } from "./compare"; @@ -103,7 +105,10 @@ export function activate(): () => void { installIncomingCheck(), installFileConfirmations(), // Settings › Git › Sign-off commit: the default of Commit Options › Sign-off Commit. - onWorkflowSettingsChange((w) => w.vcs.git.signOff, (signOff) => useCommit.setState({ signOff })), + onWorkflowSettingsChange( + (w) => w.vcs.git.signOff, + (signOff) => useCommit.setState({ signOff }), + ), registerVcsActions(), registerHistoryActions(), registerLocalHistoryActions(), @@ -159,6 +164,21 @@ export function activate(): () => void { order: 60, extensions: (ctx) => [changeMarkers(ctx), blameSlot()], }), + registerMenuGroup({ + id: "vcs.gutter", + menus: ["editorGutter"], + order: 10, + items: (_menu, { paths: [path] }) => { + if (!path || !getContext().vcsAvailable) return []; + const shown = editorsFor(path).some((h) => annotationsShown(h.view)); + return [ + { + label: shown ? "Close Annotations" : "Annotate with Git Blame", + run: () => void executeAction("vcs.annotate", path), + }, + ]; + }, + }), ]; return () => { onConflictsAppeared(() => undefined); diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index bcc3e2606..8c806a52c 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -510,9 +510,9 @@ export function projectViewRoots(map: ReadonlyMap