Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

217 changes: 217 additions & 0 deletions packages/app/src/utils/global-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
38 changes: 36 additions & 2 deletions packages/app/src/utils/global-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/session-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading