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/app/src/utils/global-clipboard.test.ts b/packages/app/src/utils/global-clipboard.test.ts index f4a4244b7..4d4a6f1da 100644 --- a/packages/app/src/utils/global-clipboard.test.ts +++ b/packages/app/src/utils/global-clipboard.test.ts @@ -808,4 +808,221 @@ 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) + }) + + 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) + }) + + // --- CM6 copy/cut via __amcEditor bridge --- + + test('mod+C on a CM6 target reads from __amcEditor bridge, not DOM selection', () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + // Stash a mock __amcEditor bridge that returns model text + ;(container as any).__amcEditor = { + getSelectedText: () => "model selection text", + cutSelectedText: () => "", + } + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + // DOM text is garbled (simulates unified mode with decoration widgets) + cmContent.textContent = "garbled deleted original modified mixed" + container.appendChild(cmContent) + document.body.appendChild(container) + + const event = keydown(cmContent, "c") + + // The bridge should use the model text, not the DOM selection + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toEqual([ + { source: "amicode", kind: "clipboard-write", text: "model selection text" }, + ]) + }) + + test('mod+X on a CM6 target calls cutSelectedText on the bridge', () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + let cutCalled = false + ;(container as any).__amcEditor = { + getSelectedText: () => "should not be called", + cutSelectedText: () => { cutCalled = true; return "cut text" }, + } + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + cmContent.textContent = "hello world" + container.appendChild(cmContent) + document.body.appendChild(container) + + const event = keydown(cmContent, "x", { metaKey: true }) + + expect(event.defaultPrevented).toBe(true) + expect(cutCalled).toBe(true) + expect(bridge.posted).toEqual([ + { source: "amicode", kind: "clipboard-write", text: "cut text" }, + ]) + }) + + test('mod+C on a CM6 target with no selection is a no-op', () => { + const bridge = framedWindow() + install(bridge.win) + const container = document.createElement("div") + container.setAttribute("data-amc-clipboard", "codemirror") + ;(container as any).__amcEditor = { + getSelectedText: () => "", + cutSelectedText: () => "", + } + const cmContent = document.createElement("div") + cmContent.setAttribute("contenteditable", "true") + cmContent.textContent = "hello" + container.appendChild(cmContent) + document.body.appendChild(container) + + const event = keydown(cmContent, "c") + + // No text selected — no bridge post, no preventDefault + expect(bridge.posted).toHaveLength(0) + }) }) diff --git a/packages/app/src/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts index 2d27aa972..cb60259ef 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 @@ -169,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 } @@ -286,6 +302,24 @@ 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 + + // CM6 copy/cut: read from the model bridge (not the DOM — unified mode + // DOM includes deleted-line decoration widgets that contaminate the text). + if (insideEditor && (key === "c" || key === "x")) { + const bridge = (insideEditor as any).__amcEditor + if (bridge) { + const text = key === "x" ? bridge.cutSelectedText() : bridge.getSelectedText() + if (text) { + event.preventDefault() + writeClipboardViaBridge(text, win) + } + return + } + } + // --- Select all --- if (key === "a") { event.preventDefault() 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..8f0b4f1a3 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, isolateHistory, 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, @@ -119,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))", @@ -228,6 +235,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 +261,8 @@ export function baseExtensions(opts: { highlightActiveLine(), highlightSpecialChars(), drawSelection(), + bracketMatching(), + keymap.of([...defaultKeymap, ...historyKeymap]), EditorView.lineWrapping, buildSyntaxHighlightStyle(), opts.theme, @@ -270,7 +283,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 @@ -382,6 +395,28 @@ export function createDiffEditor(opts: { return editorView } + // Stash a lightweight bridge on the parent element so the global clipboard + // handler can read/cut the CM6 model selection without importing @codemirror/*. + ;(opts.parent as any).__amcEditor = { + getSelectedText(): string { + const view = getActiveView() + if (!view) return "" + const { from, to } = view.state.selection.main + return from < to ? view.state.sliceDoc(from, to) : "" + }, + cutSelectedText(): string { + const view = getActiveView() + if (!view) return "" + const { from, to } = view.state.selection.main + if (from >= to) return "" + const text = view.state.sliceDoc(from, to) + if (!view.state.readOnly) { + view.dispatch({ changes: { from, to }, userEvent: "delete.cut" }) + } + return text + }, + } + return { get editorView() { return getActiveView() @@ -401,6 +436,7 @@ export function createDiffEditor(opts: { editorView?.destroy() mergeView = null editorView = null + delete (opts.parent as any).__amcEditor }, revert(original: string) { const view = getActiveView() @@ -408,13 +444,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 81ea57481..53d8a0ee5 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,350 @@ 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") + }) + + 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") + }) + + 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") + }) +}) + +// --------------------------------------------------------------------------- +// 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