From 966b9319d4541eaaa2183db1c560669c81df7cc9 Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Thu, 29 Oct 2026 08:53:23 +0000 Subject: [PATCH] feat(session-ui): syntax-aware code folding in CM6 editors (harmoniqs/amicode#1517) Wire foldGutter() and foldKeymap into baseExtensions(), add explicit Mod-Shift-[ / Mod-Shift-] fold/unfold bindings (foldKeymap uses Cmd-Alt on mac), style the fold gutter with the existing v2 tokens, and cover it with tests. Manifest hashes refreshed for the overlay edit. --- packages/app-bundle/manifest.json | 4 +- .../src/v2/components/editor-core.test.ts | 69 ++++++++++++++++++- .../src/v2/components/editor-core.ts | 32 ++++++++- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index c6dfc3db8..c15175daf 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -918,8 +918,8 @@ "packages/session-ui/src/v2/components/editable-diff-view-core.ts": "f01a341c4c1cdb4a9522f9ccd3c856bd3b9b7310c01d87327b3ce777052c8c59", "packages/session-ui/src/v2/components/editable-diff-view.test.ts": "ab206fa17779e6e9015d343ae37e3067dfc1821a93b6ba566b7a0dce0fc19acb", "packages/session-ui/src/v2/components/editable-diff-view.tsx": "f682e398ece864b5b854ca36698581c7e1cc5964eed96e7b45cabfcb64748b24", - "packages/session-ui/src/v2/components/editor-core.test.ts": "bd6552c0e7080afd554cb57ad690cc1504555712274c8b02702c64bb09460234", - "packages/session-ui/src/v2/components/editor-core.ts": "8306fc9b87fffe853c96212664ba5003c4e020c8779fc7d5410c26b543736a7e", + "packages/session-ui/src/v2/components/editor-core.test.ts": "1efdf2860abe85f96b4b0453dd0357d599326cb964e580ea382d4eac30d633d7", + "packages/session-ui/src/v2/components/editor-core.ts": "aa23db589635a0169c9ff190f0682cabab3d76671603e0a2cc52685811347cb4", "packages/session-ui/src/v2/components/file-name-picker.test.tsx": "1ce9741e315b549165d390c74b5885a43580aa70e24de987f9ba8388cd521b41", "packages/session-ui/src/v2/components/markdown-utils.test.ts": "10e1034293bf385cf50d860bf1667d8370abc61dcee0c5a93295f21f5f55876a", "packages/session-ui/src/v2/components/markdown-utils.ts": "3bc47cfed5b99ab23f857d5324be3189f3a7e800d991103d72ef1c2f970b910b", diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.test.ts index b102e6d43..144cf902b 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.test.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.test.ts @@ -8,7 +8,9 @@ import { externalUpdate, detectMode, } from "./editor-core" -import { EditorView } from "@codemirror/view" +import { EditorView, runScopeHandlers } from "@codemirror/view" +import { EditorState } from "@codemirror/state" +import { foldable, foldedRanges, syntaxTree } from "@codemirror/language" /** * Tests for editor-core — the extracted general-purpose CM6 setup functions @@ -115,6 +117,71 @@ describe("baseExtensions (via editor-core)", () => { }) }) +// --------------------------------------------------------------------------- +// Code folding (#1517) — fold gutter, Mod-Shift-[ / Mod-Shift-] shortcuts +// --------------------------------------------------------------------------- + +describe("baseExtensions code folding (#1517)", () => { + const SRC = "function f() {\n const a = 1\n return a\n}\n" + + async function makeView(): Promise { + const language = await loadLanguage("ts") + expect(language).not.toBeNull() + const parent = document.createElement("div") + document.body.appendChild(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: SRC, + selection: { anchor: 0 }, + extensions: baseExtensions({ theme: buildThemeExtension("dark"), language }), + }), + }) + // Make sure the lezer tree is fully parsed so fold ranges are available. + syntaxTree(view.state) + return view + } + + function keyEvent(init: KeyboardEventInit): KeyboardEvent { + return new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }) + } + + test("renders a fold gutter next to the line numbers", async () => { + const view = await makeView() + expect(view.dom.querySelector(".cm-foldGutter")).not.toBeNull() + expect(view.dom.querySelector(".cm-lineNumbers")).not.toBeNull() + view.destroy() + }) + + test("the lezer grammar reports a foldable region on the function line", async () => { + const view = await makeView() + const line = view.state.doc.line(1) + expect(foldable(view.state, line.from, line.to)).not.toBeNull() + view.destroy() + }) + + test("Mod-Shift-[ folds the region at the cursor and Mod-Shift-] unfolds it", async () => { + const view = await makeView() + const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform) + const mod = isMac ? { metaKey: true } : { ctrlKey: true } + const count = () => { + let n = 0 + foldedRanges(view.state).between(0, view.state.doc.length, () => { n++ }) + return n + } + expect(count()).toBe(0) + + const fold = keyEvent({ key: "{", code: "BracketLeft", keyCode: 219, shiftKey: true, ...mod }) + expect(runScopeHandlers(view, fold, "editor")).toBe(true) + expect(count()).toBe(1) + + const unfold = keyEvent({ key: "}", code: "BracketRight", keyCode: 221, shiftKey: true, ...mod }) + expect(runScopeHandlers(view, unfold, "editor")).toBe(true) + expect(count()).toBe(0) + view.destroy() + }) +}) + // --------------------------------------------------------------------------- // editableExtensions — readOnly/onChange contract preserved // --------------------------------------------------------------------------- diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.ts b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.ts index 9007f2e00..95a96a536 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/editor-core.ts @@ -22,7 +22,14 @@ import { } from "@codemirror/view" import { history, defaultKeymap, historyKeymap } from "@codemirror/commands" import { search, searchKeymap, highlightSelectionMatches } from "@codemirror/search" -import { type LanguageSupport, bracketMatching } from "@codemirror/language" +import { + type LanguageSupport, + bracketMatching, + foldGutter, + foldKeymap, + foldCode, + unfoldCode, +} from "@codemirror/language" import { HighlightStyle, syntaxHighlighting, @@ -189,6 +196,14 @@ export function buildThemeExtension(mode: "light" | "dark"): Extension { ".cm-activeLine": { backgroundColor: "color-mix(in srgb, var(--v2-background-bg-layer-01, var(--background-weak)) 50%, transparent)", }, + ".cm-foldGutter .cm-gutterElement": { + cursor: "pointer", + padding: "0 4px", + color: "var(--v2-text-text-muted, var(--text-weak))", + }, + ".cm-foldGutter .cm-gutterElement:hover": { + color: "var(--v2-text-text-base, var(--text-strong))", + }, ".cm-foldPlaceholder": { backgroundColor: "transparent", border: "none", @@ -296,6 +311,16 @@ export function editableExtensions(opts: { return exts } +/** + * Cmd/Ctrl+Shift+[ folds the region at the cursor, Cmd/Ctrl+Shift+] unfolds it + * (#1517). `foldKeymap` binds these to Ctrl-Shift on non-mac but to Cmd-Alt on + * mac, so the mac chord is added explicitly; `foldKeymap` stays for fold-all. + */ +const foldShortcutKeymap = [ + { key: "Mod-Shift-[", run: foldCode }, + { key: "Mod-Shift-]", run: unfoldCode }, +] + export function baseExtensions(opts: { theme: Extension language?: LanguageSupport | null @@ -304,11 +329,14 @@ export function baseExtensions(opts: { }): Extension[] { return [ lineNumbers(), + // Syntax-aware code folding (#1517): chevrons in the gutter next to the + // line numbers; fold regions come from each language's lezer grammar. + foldGutter(), highlightActiveLine(), highlightSpecialChars(), drawSelection(), bracketMatching(), - keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), + keymap.of([...foldShortcutKeymap, ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]), search(), highlightSelectionMatches(), EditorView.lineWrapping,