vscode: Fix a list of issues with copy/paste/undo/redo (#554)

- Pasted content wasn't treated as nomral input. It didn't activate the send button and wasn't sent in followup unse user typed other things.
- Copy on click of worktree path didn't work.
- Cmd/Ctrl-Z didn't undo/redo
- Added "Add to vibe-kanban" action which adds the vscode editor selection to vibe-kanbans input box.
This commit is contained in:
Solomon
2025-08-22 15:10:23 +01:00
committed by GitHub
parent 05db26948d
commit 3d6013ba32
2 changed files with 200 additions and 46 deletions

View File

@@ -51,6 +51,7 @@ import {
} from '@/components/context/taskDetailsContext.ts';
import { useConfig } from '@/components/config-provider.tsx';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts';
import { writeClipboardViaBridge } from '@/vscode/bridge';
import { useProcessSelection } from '@/contexts/ProcessSelectionContext';
// Helper function to get the display name for different editor types
@@ -404,7 +405,7 @@ function CurrentAttempt({
const handleCopyWorktreePath = useCallback(async () => {
try {
await navigator.clipboard.writeText(selectedAttempt.container_ref || '');
await writeClipboardViaBridge(selectedAttempt.container_ref || '');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {

View File

@@ -1,7 +1,13 @@
// VS Code Webview iframe keyboard bridge
// Forwards key events to the parent window so the VS Code webview can re-dispatch
// them and preserve editor/global shortcuts when focused inside the iframe.
//
// Purpose
// - Make typing, paste/cut/undo/redo inside the iframe feel like a regular browser
// input/textarea/contentEditable.
// - Still allow VS Code to handle global/editor shortcuts by forwarding non-text
// editing keys to the parent webview.
// - Bridge clipboard reads/writes when navigator.clipboard is restricted.
/** Returns true when running inside an iframe (vs top-level window). */
function inIframe(): boolean {
try {
return window.self !== window.top;
@@ -10,6 +16,7 @@ function inIframe(): boolean {
}
}
/** Minimal serializable keyboard event shape used across the bridge. */
type KeyPayload = {
key: string;
code: string;
@@ -22,6 +29,7 @@ type KeyPayload = {
location: number;
};
/** Convert a KeyboardEvent to a serializable payload for postMessage. */
function serializeKeyEvent(e: KeyboardEvent): KeyPayload {
return {
key: e.key,
@@ -36,35 +44,44 @@ function serializeKeyEvent(e: KeyboardEvent): KeyPayload {
};
}
function isMac() {
return navigator.platform.toUpperCase().includes('MAC');
}
/** Platform check used for shortcut detection. */
const isMac = () => navigator.platform.toUpperCase().includes('MAC');
function isCopy(e: KeyboardEvent) {
return (
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'c'
);
}
function isCut(e: KeyboardEvent) {
return (
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'x'
);
}
function isPaste(e: KeyboardEvent) {
return (
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'v'
);
}
/** True for Cmd/Ctrl+C (no Shift/Alt). */
const isCopy = (e: KeyboardEvent) =>
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'c';
/** True for Cmd/Ctrl+X (no Shift/Alt). */
const isCut = (e: KeyboardEvent) =>
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'x';
/** True for Cmd/Ctrl+V (no Shift/Alt). */
const isPaste = (e: KeyboardEvent) =>
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'v';
/** True for Cmd/Ctrl+Z. */
const isUndo = (e: KeyboardEvent) =>
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'z';
/** True for redo (Cmd+Shift+Z on macOS, Ctrl+Y elsewhere). */
const isRedo = (e: KeyboardEvent) =>
(isMac() ? e.metaKey : e.ctrlKey) &&
!e.altKey &&
((isMac() && e.shiftKey && e.key.toLowerCase() === 'z') ||
(!isMac() && !e.shiftKey && e.key.toLowerCase() === 'y'));
/**
* Returns the currently focused editable element (input/textarea/contentEditable)
* or null when focus is not within an editable.
*/
function activeEditable():
| HTMLInputElement
| HTMLTextAreaElement
@@ -80,6 +97,7 @@ function activeEditable():
return null;
}
/** Attempt to write to the OS clipboard. Returns true on success. */
async function writeClipboardText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
@@ -93,6 +111,7 @@ async function writeClipboardText(text: string): Promise<boolean> {
}
}
/** Attempt to read from the OS clipboard. Returns empty string on failure. */
async function readClipboardText(): Promise<string> {
try {
return await navigator.clipboard.readText();
@@ -101,6 +120,7 @@ async function readClipboardText(): Promise<string> {
}
}
/** Best-effort selection extractor for inputs, textareas, and contentEditable. */
function getSelectedText(): string {
const el = activeEditable() as
| HTMLInputElement
@@ -117,36 +137,137 @@ function getSelectedText(): string {
return sel ? sel.toString() : '';
}
/** Perform a browser-like cut on an input/textarea and emit input/change events. */
function cutFromInput(el: HTMLInputElement | HTMLTextAreaElement) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
if (end > start) {
const selected = el.value.slice(start, end);
void writeClipboardText(selected);
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + after;
el.setSelectionRange(start, start);
el.dispatchEvent(new Event('input', { bubbles: true }));
if (typeof el.setRangeText === 'function') {
el.setRangeText('', start, end, 'end');
} else {
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + after;
el.setSelectionRange(start, start);
}
const ie =
typeof (window as any).InputEvent !== 'undefined'
? new (window as any).InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'deleteByCut',
})
: new Event('input', { bubbles: true });
el.dispatchEvent(ie as Event);
el.dispatchEvent(new Event('change', { bubbles: true }));
}
}
/** Paste text at the current caret position in an input/textarea and emit events. */
function pasteIntoInput(
el: HTMLInputElement | HTMLTextAreaElement,
text: string
) {
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + text + after;
const caret = start + text.length;
el.setSelectionRange(caret, caret);
el.dispatchEvent(new Event('input', { bubbles: true }));
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? el.value.length;
if (typeof el.setRangeText === 'function') {
el.setRangeText(text, start, end, 'end');
} else {
const before = el.value.slice(0, start);
const after = el.value.slice(end);
el.value = before + text + after;
const caret = start + text.length;
el.setSelectionRange(caret, caret);
}
el.focus();
const ie =
typeof (window as any).InputEvent !== 'undefined'
? new (window as any).InputEvent('input', {
bubbles: true,
composed: true,
inputType: 'insertFromPaste',
data: text,
})
: new Event('input', { bubbles: true });
el.dispatchEvent(ie as Event);
el.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* Insert text at the caret for the currently active editable.
* Uses native mechanisms (setRangeText/execCommand) and emits input events so
* controlled frameworks (like React) update state predictably.
*/
function insertTextAtCaretGeneric(text: string) {
const el =
(activeEditable() as
| HTMLInputElement
| HTMLTextAreaElement
| (HTMLElement & { isContentEditable: boolean })
| null) ||
(document.querySelector(
'textarea, input:not([type=checkbox]):not([type=radio])'
) as HTMLTextAreaElement | HTMLInputElement | null);
if (!el) return;
if ((el as HTMLInputElement).selectionStart !== undefined) {
pasteIntoInput(el as HTMLInputElement | HTMLTextAreaElement, text);
} else {
try {
document.execCommand('insertText', false, text);
(el as any).dispatchEvent?.(new Event('input', { bubbles: true }));
} catch {
(el as HTMLElement).innerText += text;
}
}
}
// Lightweight retry for cases where add-to arrives before an editable exists
/** CSS selector for a reasonable first editable fallback. */
const EDITABLE_SELECTOR =
'textarea, input:not([type=checkbox]):not([type=radio])';
/** Interval (ms) between retries while we wait for an editable to appear. */
const RETRY_INTERVAL_MS = 100;
/** Maximum number of retry attempts before giving up. */
const MAX_RETRY_ATTEMPTS = 15;
let insertRetryTimer: number | null = null;
const insertQueue: string[] = [];
function enqueueInsert(text: string) {
insertQueue.push(text);
if (insertRetryTimer != null) return;
let attempts = 0;
const run = () => {
attempts++;
const el =
activeEditable() ||
(document.querySelector(EDITABLE_SELECTOR) as
| HTMLTextAreaElement
| HTMLInputElement
| null);
if (el) {
// drain queue
while (insertQueue.length > 0) {
insertTextAtCaretGeneric(insertQueue.shift() as string);
}
if (insertRetryTimer != null) {
window.clearInterval(insertRetryTimer);
insertRetryTimer = null;
}
return;
}
if (attempts >= MAX_RETRY_ATTEMPTS && insertRetryTimer != null) {
window.clearInterval(insertRetryTimer);
insertRetryTimer = null;
}
};
insertRetryTimer = window.setInterval(run, RETRY_INTERVAL_MS);
}
/** Request map to resolve clipboard paste requests from the extension. */
const pasteResolvers: Record<string, (text: string) => void> = {};
/** Ask the extension to copy text to the OS clipboard (fallback path). */
export function parentClipboardWrite(text: string) {
try {
window.parent.postMessage(
@@ -158,6 +279,7 @@ export function parentClipboardWrite(text: string) {
}
}
/** Ask the extension to read text from the OS clipboard (fallback path). */
export function parentClipboardRead(): Promise<string> {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).slice(2);
@@ -173,6 +295,7 @@ export function parentClipboardRead(): Promise<string> {
});
}
/** Message union used for iframe <-> extension communications. */
type IframeMessage = {
type: string;
event?: KeyPayload;
@@ -180,6 +303,7 @@ type IframeMessage = {
requestId?: string;
};
// Handle messages from the parent webview (clipboard, add-to input)
window.addEventListener('message', (e: MessageEvent) => {
const data: unknown = e?.data;
if (!data || typeof data !== 'object') return;
@@ -191,8 +315,19 @@ window.addEventListener('message', (e: MessageEvent) => {
delete pasteResolvers[msg.requestId];
}
}
if (msg.type === 'VIBE_ADD_TO_INPUT' && typeof msg.text === 'string') {
const el =
activeEditable() ||
(document.querySelector(EDITABLE_SELECTOR) as
| HTMLTextAreaElement
| HTMLInputElement
| null);
if (el) insertTextAtCaretGeneric(msg.text);
else enqueueInsert(msg.text);
}
});
/** Install keyboard + clipboard handlers when running inside an iframe. */
export function installVSCodeIframeKeyboardBridge() {
if (!inIframe()) return;
@@ -226,6 +361,24 @@ export function installVSCodeIframeKeyboardBridge() {
cutFromInput(el);
return;
}
} else if (isUndo(e)) {
e.preventDefault();
e.stopPropagation();
try {
document.execCommand('undo');
} catch {
/* empty */
}
return;
} else if (isRedo(e)) {
e.preventDefault();
e.stopPropagation();
try {
document.execCommand('redo');
} catch {
/* empty */
}
return;
} else if (isPaste(e)) {
const el = activeEditable() as
| HTMLInputElement
@@ -237,9 +390,7 @@ export function installVSCodeIframeKeyboardBridge() {
e.stopPropagation();
let text = await readClipboardText();
if (!text) text = await parentClipboardRead();
if ((el as HTMLInputElement).selectionStart !== undefined)
pasteIntoInput(el as HTMLInputElement | HTMLTextAreaElement, text);
else document.execCommand('insertText', false, text);
insertTextAtCaretGeneric(text);
return;
}
}
@@ -259,6 +410,7 @@ export function installVSCodeIframeKeyboardBridge() {
document.addEventListener('keypress', onKeyPress, true);
}
/** Copy helper that prefers navigator.clipboard and falls back to the bridge. */
export async function writeClipboardViaBridge(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
@@ -269,6 +421,7 @@ export async function writeClipboardViaBridge(text: string): Promise<boolean> {
}
}
/** Paste helper that prefers navigator.clipboard and falls back to the bridge. */
export async function readClipboardViaBridge(): Promise<string> {
try {
return await navigator.clipboard.readText();