Răsfoiți Sursa

#3 Fixed copy & paste in VS Code for Mac OS

paviko 3 săptămâni în urmă
părinte
comite
70a449858c

+ 11 - 13
hosts/vscode-plugin/resources/webview/index.html

@@ -178,7 +178,7 @@
           // Handle keyboard events from iframe (macOS fix)
           if (message && (message.type === "keydown-event" || message.type === "keyup-event")) {
             try {
-              const { ctrlKey, metaKey, shiftKey, altKey, code, key, hasSelection } = message.payload
+              const { ctrlKey, metaKey, shiftKey, altKey, code, key, hasSelection, inEditable } = message.payload
 
               // Handle specific VSCode shortcuts that need to be forwarded
               if (message.type === "keydown-event") {
@@ -254,21 +254,19 @@
                   return
                 }
 
-                // Copy (Cmd+C) - macOS only
-                if (metaKey && code === "KeyC") {
-                  window.vscode.postMessage({
-                    type: "executeCommand",
-                    command: "editor.action.clipboardCopyAction",
-                  })
+                // Copy/Cut/Paste: forward to VSCode only when iframe isn't editing
+                if (metaKey && code === "KeyC" && !inEditable && !hasSelection) {
+                  window.vscode.postMessage({ type: "executeCommand", command: "editor.action.clipboardCopyAction" })
                   return
                 }
 
-                // Paste (Cmd+V) - macOS only
-                if (metaKey && code === "KeyV") {
-                  window.vscode.postMessage({
-                    type: "executeCommand",
-                    command: "editor.action.clipboardPasteAction",
-                  })
+                if (metaKey && code === "KeyX" && !inEditable && !hasSelection) {
+                  window.vscode.postMessage({ type: "executeCommand", command: "editor.action.clipboardCutAction" })
+                  return
+                }
+
+                if (metaKey && code === "KeyV" && !inEditable) {
+                  window.vscode.postMessage({ type: "executeCommand", command: "editor.action.clipboardPasteAction" })
                   return
                 }
               }

+ 1 - 0
hosts/vscode-plugin/src/ui/CommunicationBridge.ts

@@ -557,6 +557,7 @@ export class CommunicationBridge implements PluginCommunicator {
                   "redo",
                   // Clipboard actions for macOS handling
                   "editor.action.clipboardCopyAction",
+                  "editor.action.clipboardCutAction",
                   "editor.action.clipboardPasteAction",
                 ])
                 const cmd = command as string // safe after type guard above

+ 10 - 0
packages/opencode/webgui/src/App.tsx

@@ -15,6 +15,7 @@ import { KeyboardShortcutsHelp } from "./components/KeyboardShortcutsHelp"
 import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"
 import { ideBridge } from "./lib/ideBridge"
 import { extractPathsFromDrop } from "./lib/dnd"
+import { initKeyboardHandler, destroyKeyboardHandler } from "./lib/keyboardHandler"
 
 const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac")
 
@@ -68,6 +69,15 @@ function AppInner({ connectionState }: { connectionState: ConnectionState }) {
     isModalOpen: isAnyModalOpen,
   })
 
+  // Fix Cmd/Ctrl clipboard shortcuts in VSCode webview iframe (macOS)
+  useEffect(() => {
+    const handler = initKeyboardHandler()
+    return () => {
+      handler.destroy()
+      destroyKeyboardHandler()
+    }
+  }, [])
+
   // Host → UI bridge messages
   useEffect(() => {
     const handler = (msg: any) => {

+ 11 - 0
packages/opencode/webgui/src/components/MessageInput/hooks/useEditorKeyboard.ts

@@ -31,6 +31,15 @@ export function useEditorKeyboard({ editor, contentEditableRef, parseWithRange,
     const el = contentEditableRef.current
     if (!el) return
 
+    const onPasteText = (e: Event) => {
+      const ev = e as CustomEvent<{ text?: string }>
+      const text = ev.detail?.text
+      if (!text) return
+      e.preventDefault()
+      e.stopPropagation()
+      insertPlainWithMentionsImpl(editor, parseWithRange, text)
+    }
+
     const onPaste = (e: ClipboardEvent) => {
       if (!e.clipboardData) return
       const plain = e.clipboardData.getData("text/plain")
@@ -40,8 +49,10 @@ export function useEditorKeyboard({ editor, contentEditableRef, parseWithRange,
       insertPlainWithMentionsImpl(editor, parseWithRange, plain)
     }
 
+    el.addEventListener("opencode:paste-text", onPasteText as any, true)
     el.addEventListener("paste", onPaste as any, true)
     return () => {
+      el.removeEventListener("opencode:paste-text", onPasteText as any, true)
       el.removeEventListener("paste", onPaste as any, true)
     }
   }, [contentEditableRef.current, editor, parseWithRange])

+ 226 - 0
packages/opencode/webgui/src/lib/keyboardHandler.ts

@@ -0,0 +1,226 @@
+type KeyPayload = {
+  ctrlKey: boolean
+  metaKey: boolean
+  shiftKey: boolean
+  altKey: boolean
+  code: string
+  key: string
+  hasSelection: boolean
+  inEditable: boolean
+}
+
+function isIframe(): boolean {
+  try {
+    return window.parent !== window
+  } catch {
+    return false
+  }
+}
+
+function isEditableElement(el: Element | null): boolean {
+  if (!el) return false
+  if (el instanceof HTMLInputElement) return !el.readOnly && !el.disabled
+  if (el instanceof HTMLTextAreaElement) return !el.readOnly && !el.disabled
+  if (el instanceof HTMLElement) {
+    if (el.isContentEditable) return true
+    if (el.closest('[contenteditable="true"]')) return true
+  }
+  return false
+}
+
+function selectionLengthInInput(el: Element | null): number {
+  if (!el) return 0
+  if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
+    const start = el.selectionStart ?? 0
+    const end = el.selectionEnd ?? 0
+    return Math.abs(end - start)
+  }
+  return 0
+}
+
+function hasDomSelection(): boolean {
+  const sel = window.getSelection?.()
+  if (!sel) return false
+  if (sel.isCollapsed) return false
+  return (sel.toString() || "").length > 0
+}
+
+function computeState(): { inEditable: boolean; hasSelection: boolean; active: Element | null } {
+  const active = document.activeElement
+  const inEditable = isEditableElement(active)
+  const hasSelection = selectionLengthInInput(active) > 0 || hasDomSelection()
+  return { inEditable, hasSelection, active }
+}
+
+function forwardToParent(type: "keydown-event" | "keyup-event", payload: KeyPayload) {
+  try {
+    if (!isIframe()) return
+    window.parent.postMessage({ type, payload }, "*")
+  } catch {}
+}
+
+function stop(ev: KeyboardEvent) {
+  ev.preventDefault()
+  ev.stopPropagation()
+  try {
+    ev.stopImmediatePropagation()
+  } catch {}
+}
+
+function dispatchPasteText(target: Element | null, text: string): boolean {
+  try {
+    const ev = new CustomEvent("opencode:paste-text", {
+      detail: { text },
+      bubbles: true,
+      cancelable: true,
+    })
+    ;(target ?? document.body).dispatchEvent(ev)
+    return ev.defaultPrevented
+  } catch {
+    return false
+  }
+}
+
+export class KeyboardHandler {
+  private onKeyDown: ((ev: KeyboardEvent) => void) | null = null
+  private onKeyUp: ((ev: KeyboardEvent) => void) | null = null
+  private onMessage: ((ev: MessageEvent) => void) | null = null
+
+  constructor() {
+    this.install()
+  }
+
+  private install() {
+    if (!isIframe()) return
+
+    this.onMessage = (ev: MessageEvent) => {
+      const msg = ev.data as any
+      if (!msg || typeof msg !== "object") return
+      if (msg.type !== "setParentOrigin") return
+      // We currently postMessage with '*' targetOrigin; parent validates our origin.
+      // Keeping this handler allows a future tighten-up without breaking protocol.
+    }
+    window.addEventListener("message", this.onMessage)
+
+    this.onKeyDown = (ev: KeyboardEvent) => {
+      const mod = ev.metaKey || ev.ctrlKey
+      const { inEditable, hasSelection, active } = computeState()
+
+      if (mod && (ev.code === "KeyC" || ev.code === "KeyX" || ev.code === "KeyV")) {
+        // Only take over clipboard shortcuts when the iframe is the intended target.
+        // Otherwise, let VSCode handle them (forwarded below).
+        if (inEditable || hasSelection) {
+          if (ev.code === "KeyC") {
+            try {
+              document.execCommand("copy")
+            } catch {}
+            stop(ev)
+            return
+          }
+
+          if (ev.code === "KeyX") {
+            try {
+              document.execCommand("cut")
+            } catch {}
+            stop(ev)
+            return
+          }
+
+          if (ev.code === "KeyV") {
+            const insert = async () => {
+              try {
+                const clip = navigator.clipboard
+                if (clip?.readText) {
+                  const text = await clip.readText()
+                  if (text) {
+                    const handled = dispatchPasteText(active, text)
+                    if (handled) return
+                    try {
+                      document.execCommand("insertText", false, text)
+                    } catch {}
+                    return
+                  }
+                }
+              } catch {}
+              try {
+                document.execCommand("paste")
+              } catch {}
+            }
+            stop(ev)
+            void insert()
+            return
+          }
+        }
+      }
+
+      if (mod && ev.code === "KeyA" && inEditable) {
+        try {
+          document.execCommand("selectAll")
+        } catch {}
+        stop(ev)
+        return
+      }
+
+      if (mod && ev.code === "KeyZ" && inEditable) {
+        try {
+          document.execCommand(ev.shiftKey ? "redo" : "undo")
+        } catch {}
+        stop(ev)
+        return
+      }
+
+      const payload: KeyPayload = {
+        ctrlKey: ev.ctrlKey,
+        metaKey: ev.metaKey,
+        shiftKey: ev.shiftKey,
+        altKey: ev.altKey,
+        code: ev.code,
+        key: ev.key,
+        hasSelection,
+        inEditable,
+      }
+      if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) forwardToParent("keydown-event", payload)
+    }
+
+    this.onKeyUp = (ev: KeyboardEvent) => {
+      const { inEditable, hasSelection } = computeState()
+      const payload: KeyPayload = {
+        ctrlKey: ev.ctrlKey,
+        metaKey: ev.metaKey,
+        shiftKey: ev.shiftKey,
+        altKey: ev.altKey,
+        code: ev.code,
+        key: ev.key,
+        hasSelection,
+        inEditable,
+      }
+      if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) forwardToParent("keyup-event", payload)
+    }
+
+    // Capture phase so we run before VSCode webview eats the event.
+    window.addEventListener("keydown", this.onKeyDown, true)
+    window.addEventListener("keyup", this.onKeyUp, true)
+  }
+
+  destroy() {
+    if (this.onMessage) window.removeEventListener("message", this.onMessage)
+    if (this.onKeyDown) window.removeEventListener("keydown", this.onKeyDown, true)
+    if (this.onKeyUp) window.removeEventListener("keyup", this.onKeyUp, true)
+    this.onMessage = null
+    this.onKeyDown = null
+    this.onKeyUp = null
+  }
+}
+
+let instance: KeyboardHandler | null = null
+
+export function initKeyboardHandler() {
+  if (instance) return instance
+  instance = new KeyboardHandler()
+  return instance
+}
+
+export function destroyKeyboardHandler() {
+  instance?.destroy()
+  instance = null
+}