From 927826074083d392c43ad9ec5afd925f6b2d4cf2 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 6 Sep 2026 16:15:30 -0400 Subject: [PATCH 01/11] feat(session-ui): add CM6 history, keymap, and bracketMatching extensions Add @codemirror/commands dependency and wire history(), defaultKeymap, historyKeymap, and bracketMatching() into the diff editor. - history() in editableExtensions (only when readOnly is false) - keymap + bracketMatching in baseExtensions (shared by all panes) Part of #302 --- bun.lock | 3 + packages/session-ui/package.json | 1 + .../v2/components/editable-diff-view-core.ts | 10 ++- .../v2/components/editable-diff-view.test.ts | 88 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 18dd17158..fe8d5144d 100644 --- a/bun.lock +++ b/bun.lock @@ -799,6 +799,7 @@ "name": "@opencode-ai/session-ui", "version": "1.18.10", "dependencies": { + "@codemirror/commands": "6.11.0", "@codemirror/lang-css": "6.3.1", "@codemirror/lang-html": "6.4.12", "@codemirror/lang-javascript": "6.2.5", @@ -1493,6 +1494,8 @@ "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], + "@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="], + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], "@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="], diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 6672f469d..0d6e1df3e 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -41,6 +41,7 @@ "vite": "catalog:" }, "dependencies": { + "@codemirror/commands": "6.11.0", "@codemirror/lang-css": "6.3.1", "@codemirror/lang-html": "6.4.12", "@codemirror/lang-javascript": "6.2.5", diff --git a/packages/session-ui/src/v2/components/editable-diff-view-core.ts b/packages/session-ui/src/v2/components/editable-diff-view-core.ts index a5100fb1d..c5079bb10 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view-core.ts +++ b/packages/session-ui/src/v2/components/editable-diff-view-core.ts @@ -11,18 +11,20 @@ import { Annotation, Compartment, EditorState, Transaction, ChangeSet, type Extension } from "@codemirror/state" import { EditorView, + keymap, lineNumbers, drawSelection, highlightActiveLine, highlightSpecialChars, } from "@codemirror/view" +import { history, defaultKeymap, historyKeymap } from "@codemirror/commands" import { MergeView, unifiedMergeView, originalDocChangeEffect, getOriginalDoc, } from "@codemirror/merge" -import { type LanguageSupport } from "@codemirror/language" +import { type LanguageSupport, bracketMatching } from "@codemirror/language" import { HighlightStyle, syntaxHighlighting, @@ -228,6 +230,10 @@ export function editableExtensions(opts: { EditorState.readOnly.of(opts.readOnly), ] + if (!opts.readOnly) { + exts.push(history()) + } + if (opts.onChange && !opts.readOnly) { exts.push( EditorView.updateListener.of((update) => { @@ -250,6 +256,8 @@ export function baseExtensions(opts: { highlightActiveLine(), highlightSpecialChars(), drawSelection(), + bracketMatching(), + keymap.of([...defaultKeymap, ...historyKeymap]), EditorView.lineWrapping, buildSyntaxHighlightStyle(), opts.theme, diff --git a/packages/session-ui/src/v2/components/editable-diff-view.test.ts b/packages/session-ui/src/v2/components/editable-diff-view.test.ts index 81ea57481..73d686b2d 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view.test.ts +++ b/packages/session-ui/src/v2/components/editable-diff-view.test.ts @@ -1140,6 +1140,94 @@ describe("minimalChanges", () => { }) }) +// --------------------------------------------------------------------------- +// Undo / redo — history() extension +// --------------------------------------------------------------------------- + +describe("undo/redo (history extension)", () => { + let parent: HTMLDivElement + let handle: DiffEditorHandle + + beforeEach(() => { + parent = document.createElement("div") + document.body.appendChild(parent) + }) + + afterEach(() => { + handle?.destroy() + parent.remove() + }) + + test("undo reverses a user edit (split mode)", async () => { + const { undo } = await import("@codemirror/commands") + const theme = buildThemeExtension("dark") + handle = createDiffEditor({ + parent, + original: "original", + modified: "hello", + diffStyle: "split", + readOnly: false, + theme, + }) + + const view = handle.editorView! + // Simulate a user edit + view.dispatch({ + changes: { from: 5, insert: " world" }, + }) + expect(handle.getContent()).toBe("hello world") + + // Undo should reverse it + undo(view) + expect(handle.getContent()).toBe("hello") + }) + + test("undo reverses a user edit (unified mode)", async () => { + const { undo } = await import("@codemirror/commands") + const theme = buildThemeExtension("dark") + handle = createDiffEditor({ + parent, + original: "original", + modified: "hello", + diffStyle: "unified", + readOnly: false, + theme, + }) + + const view = handle.editorView! + view.dispatch({ + changes: { from: 5, insert: " world" }, + }) + expect(handle.getContent()).toBe("hello world") + + undo(view) + expect(handle.getContent()).toBe("hello") + }) + + test("redo re-applies an undone edit", async () => { + const { undo, redo } = await import("@codemirror/commands") + const theme = buildThemeExtension("dark") + handle = createDiffEditor({ + parent, + original: "original", + modified: "hello", + diffStyle: "split", + readOnly: false, + theme, + }) + + const view = handle.editorView! + view.dispatch({ + changes: { from: 5, insert: " world" }, + }) + undo(view) + expect(handle.getContent()).toBe("hello") + + redo(view) + expect(handle.getContent()).toBe("hello world") + }) +}) + // --------------------------------------------------------------------------- // Syntax highlight style // --------------------------------------------------------------------------- From 1ee51402c474ce458113c50ff5930156ef3171b4 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 6 Sep 2026 16:15:56 -0400 Subject: [PATCH 02/11] test(session-ui): verify external updates excluded from undo history updateModified and updateOriginal use addToHistory.of(false), which the new history() extension respects. These tests confirm the invariant. Part of #302 --- .../v2/components/editable-diff-view.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/session-ui/src/v2/components/editable-diff-view.test.ts b/packages/session-ui/src/v2/components/editable-diff-view.test.ts index 73d686b2d..ad292ca7d 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view.test.ts +++ b/packages/session-ui/src/v2/components/editable-diff-view.test.ts @@ -1226,6 +1226,49 @@ describe("undo/redo (history extension)", () => { redo(view) expect(handle.getContent()).toBe("hello world") }) + + test("undo does NOT undo external updateModified", async () => { + const { undo } = await import("@codemirror/commands") + const theme = buildThemeExtension("dark") + handle = createDiffEditor({ + parent, + original: "original", + modified: "initial", + diffStyle: "split", + readOnly: false, + theme, + }) + + // External update (server push) + handle.updateModified("server pushed") + expect(handle.getContent()).toBe("server pushed") + + // Undo should NOT reverse the external update + const view = handle.editorView! + undo(view) + expect(handle.getContent()).toBe("server pushed") + }) + + test("undo does NOT undo external updateOriginal (split mode)", async () => { + const { undo } = await import("@codemirror/commands") + const theme = buildThemeExtension("dark") + handle = createDiffEditor({ + parent, + original: "old original", + modified: "modified", + diffStyle: "split", + readOnly: false, + theme, + }) + + handle.updateOriginal("new original") + const origView = handle.mergeView!.a + expect(origView.state.doc.toString()).toBe("new original") + + // Undo on the original pane should not reverse the external update + undo(origView) + expect(origView.state.doc.toString()).toBe("new original") + }) }) // --------------------------------------------------------------------------- From 787ff6d49a4d5edf42508848e1806b0cbbd3f911 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 6 Sep 2026 16:18:21 -0400 Subject: [PATCH 03/11] feat(session-ui): make revert undoable via isolateHistory (D7) Use isolateHistory.of('full') on the revert dispatch so it always creates its own undo group. Cmd+Z after revert now restores the pre-revert edits. Updated docstrings to match. Part of #302 --- .../v2/components/editable-diff-view-core.ts | 11 +++++-- .../v2/components/editable-diff-view.test.ts | 31 +++++++++++++++++++ .../src/v2/components/editable-diff-view.tsx | 2 +- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/session-ui/src/v2/components/editable-diff-view-core.ts b/packages/session-ui/src/v2/components/editable-diff-view-core.ts index c5079bb10..c035ed3ab 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view-core.ts +++ b/packages/session-ui/src/v2/components/editable-diff-view-core.ts @@ -17,7 +17,7 @@ import { highlightActiveLine, highlightSpecialChars, } from "@codemirror/view" -import { history, defaultKeymap, historyKeymap } from "@codemirror/commands" +import { history, isolateHistory, defaultKeymap, historyKeymap } from "@codemirror/commands" import { MergeView, unifiedMergeView, @@ -278,7 +278,7 @@ export interface DiffEditorHandle { scrollDOM: HTMLElement | null /** Destroy all editor instances. */ destroy: () => void - /** Revert to original: replace content, clear undo history. */ + /** Revert to original: replace content (the revert itself is undoable via Cmd+Z). */ revert: (original: string) => void /** Get the current document content. */ getContent: () => string @@ -416,13 +416,18 @@ export function createDiffEditor(opts: { // Replace entire document with original — mark as external so // the onChange listener does not fire (the caller handles state). + // isolateHistory ensures the revert is its own undo group so + // Cmd+Z after revert restores the pre-revert edits (D7). view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: original, }, - annotations: externalUpdate.of(true), + annotations: [ + externalUpdate.of(true), + isolateHistory.of("full"), + ], }) }, getContent() { diff --git a/packages/session-ui/src/v2/components/editable-diff-view.test.ts b/packages/session-ui/src/v2/components/editable-diff-view.test.ts index ad292ca7d..f6ea7e33a 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view.test.ts +++ b/packages/session-ui/src/v2/components/editable-diff-view.test.ts @@ -1269,6 +1269,37 @@ describe("undo/redo (history extension)", () => { undo(origView) expect(origView.state.doc.toString()).toBe("new original") }) + + test("revert is undoable — Cmd+Z restores pre-revert edits (D7)", async () => { + const { undo, undoDepth } = await import("@codemirror/commands") + const theme = buildThemeExtension("dark") + handle = createDiffEditor({ + parent, + original: "original text", + modified: "original text", + diffStyle: "split", + readOnly: false, + theme, + }) + + const view = handle.editorView! + + // User makes an edit + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: "user edits here" }, + }) + expect(handle.getContent()).toBe("user edits here") + // History should have recorded the user edit + expect(undoDepth(view.state)).toBeGreaterThan(0) + + // Revert to original + handle.revert("original text") + expect(handle.getContent()).toBe("original text") + + // Undo the revert — should restore user edits + undo(view) + expect(handle.getContent()).toBe("user edits here") + }) }) // --------------------------------------------------------------------------- diff --git a/packages/session-ui/src/v2/components/editable-diff-view.tsx b/packages/session-ui/src/v2/components/editable-diff-view.tsx index 24e752fc2..1e2fea851 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view.tsx +++ b/packages/session-ui/src/v2/components/editable-diff-view.tsx @@ -54,7 +54,7 @@ export type EditableDiffViewProps = { onChange: (content: string) => void /** * Called when the user reverts — the component replaces the document with - * `original` and clears the undo history before invoking this callback. + * `original` (the revert is undoable via Cmd+Z) before invoking this callback. */ onRevert: () => void /** Optional ref callback for the container element. */ From 037c5185a65d6ced82e344827f26282c7ff2b202 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 6 Sep 2026 16:20:44 -0400 Subject: [PATCH 04/11] feat(app): delegate Z/Y/A to CM6 in global clipboard handler Add CLIPBOARD_EDITOR_SELECTOR for data-amc-clipboard='codemirror'. The global handler returns early (no preventDefault) for undo/redo/ select-all when the target is inside a CM6 editor, letting CM6's own keymap handle them. Clipboard chords (C/X/V) still bridge. Part of #302 --- .../app/src/utils/global-clipboard.test.ts | 111 ++++++++++++++++++ packages/app/src/utils/global-clipboard.ts | 10 ++ 2 files changed, 121 insertions(+) diff --git a/packages/app/src/utils/global-clipboard.test.ts b/packages/app/src/utils/global-clipboard.test.ts index f4a4244b7..7dc51671f 100644 --- a/packages/app/src/utils/global-clipboard.test.ts +++ b/packages/app/src/utils/global-clipboard.test.ts @@ -808,4 +808,115 @@ describe("installGlobalClipboardFallback", () => { expect(received[0].type).toBe("image/png") expect(el.value).toBe("") // text was NOT inserted }) + + // --- CM6 editor delegation (data-amc-clipboard="codemirror") --- + + test('mod+Z on a CM6 target is NOT intercepted — CM6 history handles undo', () => { + const bridge = framedWindow() + install(bridge.win) + // Simulate CM6 DOM: container[data-amc-clipboard="codemirror"] > .cm-editor > .cm-scroller > .cm-content[contenteditable] + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + const cmEditor = document.createElement("div") + cmEditor.className = "cm-editor" + const cmScroller = document.createElement("div") + cmScroller.className = "cm-scroller" + const cmContent = document.createElement("div") + cmContent.className = "cm-content" + cmContent.setAttribute("contenteditable", "true") + cmContent.textContent = "hello world" + cmScroller.appendChild(cmContent) + cmEditor.appendChild(cmScroller) + container.appendChild(cmEditor) + document.body.appendChild(container) + + const event = keydown(cmContent, "z") + + // NOT prevented — CM6's own history keymap handles undo + expect(event.defaultPrevented).toBe(false) + }) + + test('mod+Shift+Z (redo) on a CM6 target is NOT intercepted', () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + container.appendChild(cmContent) + document.body.appendChild(container) + + const event = keydown(cmContent, "z", { shiftKey: true }) + + expect(event.defaultPrevented).toBe(false) + }) + + test('mod+Y (redo) on a CM6 target is NOT intercepted', () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + container.appendChild(cmContent) + document.body.appendChild(container) + + const event = keydown(cmContent, "y") + + expect(event.defaultPrevented).toBe(false) + }) + + test('mod+A on a CM6 target is NOT intercepted — CM6 selectAll handles it', () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + cmContent.textContent = "code content" + container.appendChild(cmContent) + document.body.appendChild(container) + + const event = keydown(cmContent, "a") + + // NOT prevented — CM6's defaultKeymap handles select-all (editor-scoped, not panel-wide) + expect(event.defaultPrevented).toBe(false) + }) + + test('mod+C on a CM6 target STILL bridges — clipboard needs the OS bridge in iframe', () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + cmContent.textContent = "selected text" + container.appendChild(cmContent) + document.body.appendChild(container) + selectWithin(cmContent, 0, 8) + + const event = keydown(cmContent, "c") + + // C/X/V still go through the bridge — only Z/Y/A are delegated to CM6 + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toEqual([{ source: "amicode", kind: "clipboard-write", text: "selected" }]) + }) + + test('mod+V on a CM6 target STILL bridges — paste needs the OS bridge in iframe', async () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + cmContent.textContent = "hello" + container.appendChild(cmContent) + document.body.appendChild(container) + + const event = keydown(cmContent, "v") + + // V is still intercepted — paste goes through the bridge + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted.some((m) => m.kind === "clipboard-request")).toBe(true) + }) }) diff --git a/packages/app/src/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts index 2d27aa972..0ce28da5b 100644 --- a/packages/app/src/utils/global-clipboard.ts +++ b/packages/app/src/utils/global-clipboard.ts @@ -51,6 +51,12 @@ let fullSessionCopyPending = false // copying from the prompt would paste stale content. export const CLIPBOARD_SELF_SELECTOR = '[data-amc-clipboard="self"]' +// CodeMirror 6 editors manage their own document model, history, and selection. +// Undo/redo/select-all must NOT be intercepted (CM6's keymap handles them); +// clipboard chords (C/X/V) still bridge through this handler because the +// VS Code iframe can't reach the OS clipboard natively. +const CLIPBOARD_EDITOR_SELECTOR = '[data-amc-clipboard="codemirror"]' + // When a file is copied in Finder, the clipboard carries both the image data // AND the filename as plain text. Detect this so we prefer the image. const IMAGE_FILENAME_RE = /^[^\n]{1,255}\.(png|jpe?g|gif|webp|avif|tiff?|bmp|svg|ico|heic)$/i @@ -286,6 +292,10 @@ export function installGlobalClipboardFallback(win: Window = window): () => void return } + // --- Managed editor (CodeMirror 6) — delegate undo/redo/select-all, bridge clipboard --- + const insideEditor = target instanceof Element && target.closest(CLIPBOARD_EDITOR_SELECTOR) + if (insideEditor && (key === "z" || key === "y" || key === "a")) return + // --- Select all --- if (key === "a") { event.preventDefault() From b0263d5d443461f0dd65753ad89060d2ee90057e Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 6 Sep 2026 16:21:58 -0400 Subject: [PATCH 05/11] feat(app): use execCommand('delete') for CM6 cut targets Inside a CM6 editor, cut deletion uses execCommand('delete') instead of range.deleteContents() so CM6's mutation observer creates a proper undo-tracked transaction. Non-CM6 contenteditable targets keep the existing range.deleteContents() + dispatchInput('deleteByCut') path. Part of #302 --- .../app/src/utils/global-clipboard.test.ts | 32 +++++++++++++++++++ packages/app/src/utils/global-clipboard.ts | 14 ++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/app/src/utils/global-clipboard.test.ts b/packages/app/src/utils/global-clipboard.test.ts index 7dc51671f..0ec30d3e9 100644 --- a/packages/app/src/utils/global-clipboard.test.ts +++ b/packages/app/src/utils/global-clipboard.test.ts @@ -919,4 +919,36 @@ describe("installGlobalClipboardFallback", () => { expect(event.defaultPrevented).toBe(true) expect(bridge.posted.some((m) => m.kind === "clipboard-request")).toBe(true) }) + + test('cut on a CM6 target uses execCommand("delete") — no manual deleteByCut dispatch', () => { + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + cmContent.textContent = "hello world" + container.appendChild(cmContent) + document.body.appendChild(container) + selectWithin(cmContent, 0, 5) + + // Observe input events — CM6 targets should NOT get a manual deleteByCut + const seen = observeInput() + + const text = extractSelection(cmContent, { cut: true }) + + expect(text).toBe("hello") + // No manual deleteByCut event — CM6's execCommand("delete") fires its own beforeinput + expect(seen.filter((e) => e.inputType === "deleteByCut")).toHaveLength(0) + }) + + test("cut on a non-CM6 contenteditable still dispatches deleteByCut", () => { + const el = editableDiv("hello world") + selectWithin(el, 0, 6) + const seen = observeInput() + + const text = extractSelection(el, { cut: true }) + + expect(text).toBe("hello ") + // Non-CM6 targets still get the manual deleteByCut + expect(seen.filter((e) => e.inputType === "deleteByCut")).toHaveLength(1) + }) }) diff --git a/packages/app/src/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts index 0ce28da5b..9c0dbec7c 100644 --- a/packages/app/src/utils/global-clipboard.ts +++ b/packages/app/src/utils/global-clipboard.ts @@ -175,8 +175,18 @@ export function extractSelection(el: HTMLElement, opts: { cut?: boolean } = {}): const text = selection.toString() if (!text) return "" if (opts.cut) { - range.deleteContents() // leaves the selection collapsed at the cut point - dispatchInput(el, "deleteByCut") + if (el.closest(CLIPBOARD_EDITOR_SELECTOR)) { + // CM6 manages its own document model — execCommand("delete") fires a + // beforeinput event that CM6's mutation observer catches, creating a + // proper undo-tracked transaction. range.deleteContents() would bypass it. + const doc = el.ownerDocument + if (typeof doc.execCommand === "function") { + doc.execCommand("delete") + } + } else { + range.deleteContents() // leaves the selection collapsed at the cut point + dispatchInput(el, "deleteByCut") + } } return text } From 28824df07688a52456acba8db4b2c7c002122548 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 6 Sep 2026 16:22:16 -0400 Subject: [PATCH 06/11] feat(session-ui): mark diff editor container for CM6 clipboard delegation Add data-amc-clipboard='codemirror' to the EditableDiffView container div so global-clipboard.ts recognizes it and delegates Z/Y/A to CM6. Part of #302 --- packages/session-ui/src/v2/components/editable-diff-view.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/session-ui/src/v2/components/editable-diff-view.tsx b/packages/session-ui/src/v2/components/editable-diff-view.tsx index 1e2fea851..6c1c64dd5 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view.tsx +++ b/packages/session-ui/src/v2/components/editable-diff-view.tsx @@ -168,6 +168,7 @@ export function EditableDiffView(props: EditableDiffViewProps): JSX.Element { props.ref?.(el) }} class="editable-diff-view" + data-amc-clipboard="codemirror" data-diff-style={props.diffStyle} data-read-only={props.readOnly ? "" : undefined} style={{ From 882ac552a1ea71e732a40e224b0529ef97a49d91 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 6 Sep 2026 16:51:05 -0400 Subject: [PATCH 07/11] fix(session-ui): boost selection highlight specificity to beat CM6 defaults CM6's built-in default uses a 5-class child-combinator selector for .cm-selectionBackground with hardcoded colors (#233 dark, #d7d4f0 light). Our theme's lower-specificity descendant selector lost, making selections invisible in dark mode. Use matching child-combinator depth so our CSS variable (--v2-background-bg-layer-03) wins. Part of #302 --- .../v2/components/editable-diff-view-core.ts | 13 +++-- .../v2/components/editable-diff-view.test.ts | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/session-ui/src/v2/components/editable-diff-view-core.ts b/packages/session-ui/src/v2/components/editable-diff-view-core.ts index c035ed3ab..59006cf70 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view-core.ts +++ b/packages/session-ui/src/v2/components/editable-diff-view-core.ts @@ -121,10 +121,15 @@ export function buildThemeExtension(mode: "light" | "dark"): Extension { ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--v2-text-text-base, var(--text-strong))", }, - "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": - { - backgroundColor: "var(--v2-background-bg-layer-03, var(--background-weak))", - }, + // Selection highlight — override CM6's built-in defaults (#d7d4f0 light, + // #233 dark) with our theme tokens. The child-combinator selector matches + // CM6's internal specificity so our rule wins. + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { + background: "var(--v2-background-bg-layer-03, var(--background-weak))", + }, + ".cm-selectionBackground": { + backgroundColor: "var(--v2-background-bg-layer-03, var(--background-weak))", + }, ".cm-panels": { backgroundColor: "var(--v2-background-bg-base, var(--background-base))", color: "var(--v2-text-text-base, var(--text-strong))", diff --git a/packages/session-ui/src/v2/components/editable-diff-view.test.ts b/packages/session-ui/src/v2/components/editable-diff-view.test.ts index f6ea7e33a..773688f00 100644 --- a/packages/session-ui/src/v2/components/editable-diff-view.test.ts +++ b/packages/session-ui/src/v2/components/editable-diff-view.test.ts @@ -1302,6 +1302,60 @@ describe("undo/redo (history extension)", () => { }) }) +// --------------------------------------------------------------------------- +// Selection highlight visibility — theme specificity +// --------------------------------------------------------------------------- + +describe("selection highlight visibility", () => { + let parent: HTMLDivElement + let handle: DiffEditorHandle + + beforeEach(() => { + parent = document.createElement("div") + document.body.appendChild(parent) + }) + + afterEach(() => { + handle?.destroy() + parent.remove() + }) + + test("theme injects a high-specificity .cm-selectionBackground rule that beats CM6 defaults", () => { + const theme = buildThemeExtension("dark") + handle = createDiffEditor({ + parent, + original: "a", + modified: "b", + diffStyle: "split", + readOnly: false, + theme, + }) + + // Extract individual CSS rules from all