diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 6b75c98..d6c3707 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -300,11 +300,23 @@ The bracketed-paste mode is read at paste time from xterm's public `terminal.mod ### 8.6 Paste Content -The terminal pastes plain text only. It calls `navigator.clipboard.readText()` and writes the resulting string to the PTY (with bracketed-paste wrapping when enabled). If `readText` returns an empty string or throws (e.g. the document lacks focus or permission was denied), the paste is silently a no-op. +Paste reads the clipboard in three tiers, falling through in order: -File-URL handling, image paste, content-aware transformations, paste history, and credential warnings are out of scope (see §9). +1. **File references.** The platform adapter checks for OS file references (Finder/Explorer Copy of a file) via the sidecar/extension host. If present, each path is shell-escaped and the space-joined list is written to the PTY with a trailing space so the next token starts cleanly. Files are checked first so that a file-ref clipboard never reaches `navigator.clipboard.readText()` — on macOS WKWebView that call can trigger a native paste-permission popup when the clipboard came from another app. +2. **Plain text.** `navigator.clipboard.readText()`. If non-empty, the string is written to the PTY (with bracketed-paste wrapping when enabled by the inside program). +3. **Raw image data.** If neither of the above matches and the clipboard holds image bytes (e.g. a `Cmd+Shift+4` screenshot), the bytes are written to a newly-created private temp directory as `.png` and that single path is pasted as in tier 1. On Unix-like systems the temp directory is owner-only and the image file is written owner-readable/writable to avoid exposing clipboard screenshots to other local users. -### 8.7 Right-Click and Menu Paste +Each tier is implemented by a shared Node module (`standalone/sidecar/clipboard-ops.js`) that shells out to the OS-native clipboard tool: `osascript` on macOS, `Get-Clipboard` on Windows, `wl-paste`/`xclip` on Linux. The Tauri build reaches it through the existing sidecar; the VSCode build calls into the same module from its extension host. If every tier comes back empty, paste is a silent no-op. + +Content-aware transformations, paste history, credential warnings, and middle-click (X11 PRIMARY) paste remain out of scope (see §9). + +### 8.7 Drag-to-Paste + +Dragging files onto a terminal pane mirrors the paste chain above: escaped paths are typed at the current prompt, space-joined with a trailing space. Tauri receives the drop natively via `WindowEvent::DragDrop` and routes paths to the focused pane. + +Drag-to-paste is **not supported in the VSCode build**: VSCode's `WebviewView` (sidebar/panel) is excluded from external-file drop routing by the workbench, so the webview iframe never receives `dragover`/`drop` events for files dragged from the OS. See §9.2. VSCode users paste instead (§8.1/§8.5). + +### 8.8 Right-Click and Menu Paste Right-click and OS Edit-menu paste are not currently implemented; users paste via the keyboard shortcuts in §8.2. @@ -332,10 +344,10 @@ The following are explicitly not implemented today; they may be added in respons - A settings toggle to disable Ctrl+V interception on Windows and Linux. - A paste popup (parallel to the copy popup) for previewing or transforming paste content before it is committed. - Paste content transformations (strip trailing whitespace, normalize line endings, convert smart quotes). -- File URL handling: pasting a `file://` URL as the bare path (Finder/Explorer drag-as-text). -- Image paste: detecting image data on the clipboard and offering to paste it as a temp file path or inline base64. - Paste history. - Credential-shaped content detection and warnings. - Multi-line paste confirmation dialogs. - A "literal next keystroke" terminal-level shortcut (Ctrl+Alt+V or similar) for programs that don't support Ctrl+Q-style `quoted-insert`. - Middle-click paste / X11 PRIMARY selection integration on Linux. +- Drop-position-aware pane routing (currently drops always go to the focused pane). +- Drag-to-paste in the VSCode build. `WebviewView` is excluded from external-file drop routing by the workbench and there is no API to opt in (see [microsoft/vscode#111092](https://github.com/microsoft/vscode/issues/111092), closed as out-of-scope). Users paste via Ctrl+V / Cmd+V instead. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index e035f5c..b77e979 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -58,7 +58,7 @@ Frontend Library (lib/src/) - **Shell login args are shell-specific.** The shared `pty-core.js` launches POSIX shells with `-l` only for shells that accept it. `csh`/`tcsh` must be spawned without `-l` so both the standalone app and VS Code extension can open a usable terminal for users whose login shell is C shell-derived. - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **Scrollback trailing newline.** Restored scrollback must end with `\n` to avoid zsh printing a `%` artifact at the top of the terminal. -- **retainContextWhenHidden.** Set on `WebviewPanel` (editor tabs) but NOT on `WebviewView` (bottom panel). The view relies on reconnect/replay when it becomes visible again; the panel keeps its DOM alive. +- **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through the reconnect dance. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `mouseterm:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. ### Extension manifest (current) diff --git a/lib/clipboard-ops.cjs b/lib/clipboard-ops.cjs new file mode 100644 index 0000000..ca89906 --- /dev/null +++ b/lib/clipboard-ops.cjs @@ -0,0 +1,3 @@ +// The packaged Tauri sidecar must ship a local copy of the clipboard ops, so +// this CommonJS shim points other Node consumers at that shared implementation. +module.exports = require('../standalone/sidecar/clipboard-ops.js'); diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 6d70b22..b0f9b3d 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -26,7 +26,7 @@ import { setSelection as setMouseSelection, subscribeToMouseSelection, } from '../lib/mouse-selection'; -import { copyRaw, copyRewrapped, doPaste } from '../lib/clipboard'; +import { copyRaw, copyRewrapped, doPaste, pasteFilePaths } from '../lib/clipboard'; import { IS_MAC } from '../lib/platform'; import { type AlertButtonActionResult, @@ -1683,12 +1683,22 @@ export function Pond({ platform.onRequestSessionFlush(handleSessionFlushRequest); window.addEventListener('pagehide', handlePageHide); + const unsubFilesDropped = platform.onFilesDropped?.((paths) => { + if (paths.length === 0) return; + const sid = selectedTypeRef.current === 'pane' ? selectedIdRef.current : null; + if (!sid) return; + const api = apiRef.current; + if (!api || !api.panels.some((p) => p.id === sid)) return; + pasteFilePaths(sid, paths); + }); + return () => { if (sessionSaveTimerRef.current) { clearTimeout(sessionSaveTimerRef.current); sessionSaveTimerRef.current = null; } window.removeEventListener('pagehide', handlePageHide); + unsubFilesDropped?.(); platform.offRequestSessionFlush(handleSessionFlushRequest); platform.offPtyExit(handlePtyExit); layoutDisposable.dispose(); diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx index 7dbcd27..90c282b 100644 --- a/lib/src/components/SelectionOverlay.tsx +++ b/lib/src/components/SelectionOverlay.tsx @@ -80,9 +80,9 @@ function computeRects( for (let r = firstRow; r <= lastRow; r++) { let c0 = 0; let c1 = cols; - if (r === n.r0) c0 = n.c0; + if (r === n.r0) c0 = n.c0; if (r === n.r1) c1 = n.c1 + 1; - if (c1 <= c0) continue; + if (c1 <= c0) continue; rects.push({ top: (r - viewportStart) * cellHeight, left: c0 * cellWidth, diff --git a/lib/src/lib/clipboard.test.ts b/lib/src/lib/clipboard.test.ts new file mode 100644 index 0000000..f3c708e --- /dev/null +++ b/lib/src/lib/clipboard.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + readClipboardFilePaths: vi.fn<() => Promise>(), + readClipboardImageAsFilePath: vi.fn<() => Promise>(), + writePty: vi.fn<(id: string, data: string) => void>(), + readText: vi.fn<() => Promise>(), +})); + +vi.mock('./platform', () => ({ + IS_MAC: false, + getPlatform: () => ({ + readClipboardFilePaths: mocks.readClipboardFilePaths, + readClipboardImageAsFilePath: mocks.readClipboardImageAsFilePath, + writePty: mocks.writePty, + }), +})); + +vi.mock('./mouse-selection', () => ({ + getMouseSelectionState: () => ({ bracketedPaste: false }), +})); + +import { doPaste } from './clipboard'; + +describe('doPaste three-tier fallthrough', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(globalThis, 'navigator', { + value: { clipboard: { readText: mocks.readText } }, + configurable: true, + }); + }); + + it('uses file refs when present and never reads text or image', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(['/tmp/a.png', '/tmp/b file.png']); + mocks.readText.mockResolvedValue('should not be read'); + mocks.readClipboardImageAsFilePath.mockResolvedValue('/tmp/img.png'); + + await doPaste('t1'); + + expect(mocks.readText).not.toHaveBeenCalled(); + expect(mocks.readClipboardImageAsFilePath).not.toHaveBeenCalled(); + expect(mocks.writePty).toHaveBeenCalledTimes(1); + expect(mocks.writePty).toHaveBeenCalledWith('t1', '/tmp/a.png /tmp/b\\ file.png '); + }); + + it('falls through to text when no file refs', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(null); + mocks.readText.mockResolvedValue('hello world'); + mocks.readClipboardImageAsFilePath.mockResolvedValue('/tmp/img.png'); + + await doPaste('t1'); + + expect(mocks.readClipboardImageAsFilePath).not.toHaveBeenCalled(); + expect(mocks.writePty).toHaveBeenCalledWith('t1', 'hello world'); + }); + + it('falls through to image when no files and no text', async () => { + mocks.readClipboardFilePaths.mockResolvedValue([]); + mocks.readText.mockResolvedValue(''); + mocks.readClipboardImageAsFilePath.mockResolvedValue('/tmp/img.png'); + + await doPaste('t1'); + + expect(mocks.writePty).toHaveBeenCalledWith('t1', '/tmp/img.png '); + }); + + it('is a no-op when all tiers come back empty', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(null); + mocks.readText.mockResolvedValue(''); + mocks.readClipboardImageAsFilePath.mockResolvedValue(null); + + await doPaste('t1'); + + expect(mocks.writePty).not.toHaveBeenCalled(); + }); + + it('swallows file-ref adapter errors and falls through to text', async () => { + mocks.readClipboardFilePaths.mockRejectedValue(new Error('boom')); + mocks.readText.mockResolvedValue('fallback'); + + await doPaste('t1'); + + expect(mocks.writePty).toHaveBeenCalledWith('t1', 'fallback'); + }); + + it('swallows image adapter errors silently', async () => { + mocks.readClipboardFilePaths.mockResolvedValue(null); + mocks.readText.mockResolvedValue(''); + mocks.readClipboardImageAsFilePath.mockRejectedValue(new Error('boom')); + + await doPaste('t1'); + + expect(mocks.writePty).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/lib/clipboard.ts b/lib/src/lib/clipboard.ts index 02f6e49..29837d1 100644 --- a/lib/src/lib/clipboard.ts +++ b/lib/src/lib/clipboard.ts @@ -2,6 +2,7 @@ import { getMouseSelectionState } from './mouse-selection'; import { rewrap } from './rewrap'; import { extractSelectionText } from './selection-text'; import { getPlatform } from './platform'; +import { shellEscapePath } from './shell-escape'; import { getTerminalInstance } from './terminal-registry'; async function writeText(text: string): Promise { @@ -42,22 +43,60 @@ export async function copyRewrapped(terminalId: string): Promise { await writeText(out); } +function writePasteToPty(terminalId: string, text: string): void { + if (!text) return; + const bracketed = getMouseSelectionState(terminalId).bracketedPaste; + const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; + getPlatform().writePty(terminalId, payload); +} + /** - * Read text from the clipboard and write it to the PTY, honoring the - * inside program's bracketed-paste mode when enabled (spec §8.5). + * Shell-escape the given paths and type them at the terminal, joined by single + * spaces with a trailing space so the next prompt keystroke starts a fresh + * token. */ -export async function doPaste(terminalId: string): Promise { - let text: string; +export function pasteFilePaths(terminalId: string, paths: string[]): void { + if (paths.length === 0) return; + const text = paths.map(shellEscapePath).join(' ') + ' '; + writePasteToPty(terminalId, text); +} + +async function readTextFromClipboard(): Promise { try { - if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return; - text = await navigator.clipboard.readText(); + if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return ''; + return await navigator.clipboard.readText(); } catch { - // Clipboard read can fail when the document lacks focus or the - // Permissions API denied access. Silently ignore. + return ''; + } +} + +/** + * Read the clipboard and write its contents to the PTY, honoring the inside + * program's bracketed-paste mode when enabled (spec §8.5). Falls through from + * file references → plain text → raw image (saved to a temp file) so a + * Cmd+V of a Finder file or a screenshot both type a usable path. + * + * Files are checked before text so that a file-ref clipboard never reaches + * `navigator.clipboard.readText()` — on macOS WKWebView that call can trigger + * a native paste-permission popup when the clipboard came from another app. + */ +export async function doPaste(terminalId: string): Promise { + const platform = getPlatform(); + + const paths = await platform.readClipboardFilePaths().catch(() => null); + if (paths && paths.length > 0) { + pasteFilePaths(terminalId, paths); return; } - if (!text) return; - const bracketed = getMouseSelectionState(terminalId).bracketedPaste; - const payload = bracketed ? `\x1b[200~${text}\x1b[201~` : text; - getPlatform().writePty(terminalId, payload); + + const text = await readTextFromClipboard(); + if (text) { + writePasteToPty(terminalId, text); + return; + } + + const imagePath = await platform.readClipboardImageAsFilePath().catch(() => null); + if (imagePath) { + pasteFilePaths(terminalId, [imagePath]); + } } diff --git a/lib/src/lib/mouse-mode-observer.test.ts b/lib/src/lib/mouse-mode-observer.test.ts index 057f59b..b67112b 100644 --- a/lib/src/lib/mouse-mode-observer.test.ts +++ b/lib/src/lib/mouse-mode-observer.test.ts @@ -93,21 +93,8 @@ describe('attachMouseModeObserver', () => { }); it('dispose tears down both handlers', () => { - const { terminal } = buildMockTerminal(); - const mockDispose1 = vi.fn(); - const mockDispose2 = vi.fn(); - const realParser = terminal.parser; - (terminal as unknown as { parser: unknown }).parser = { - registerCsiHandler(_id: unknown, _cb: unknown) { - return realParser === terminal.parser - ? { dispose: mockDispose1 } - : { dispose: mockDispose2 }; - }, - }; - - // Simpler: build a fresh mock with explicit disposables const disposables: Array<{ dispose: ReturnType }> = []; - const term2 = { + const terminal = { parser: { registerCsiHandler() { const d = { dispose: vi.fn() }; @@ -118,7 +105,7 @@ describe('attachMouseModeObserver', () => { modes: { mouseTrackingMode: 'none', bracketedPasteMode: false }, } as unknown as Terminal; - const observer = attachMouseModeObserver('a', term2); + const observer = attachMouseModeObserver('a', terminal); observer.dispose(); expect(disposables).toHaveLength(2); diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts index 0e6703d..028b956 100644 --- a/lib/src/lib/mouse-selection.test.ts +++ b/lib/src/lib/mouse-selection.test.ts @@ -4,6 +4,7 @@ import { __resetMouseSelectionForTests, beginDrag, endDrag, + flashCopy, getMouseSelectionSnapshot, getMouseSelectionState, isDragging, @@ -272,6 +273,23 @@ describe('mouse-selection: drag lifecycle', () => { }); }); +describe('mouse-selection: flashCopy race', () => { + it('beginDrag during a flash clears copyFlash so the timer does not nuke the new selection', () => { + beginDrag('a', { row: 0, col: 0, altKey: false, startedInScrollback: false }); + updateDrag('a', { row: 3, col: 5, altKey: false }); + endDrag('a'); + + // Simulate flashCopy — but we call beginDrag before the timer fires. + flashCopy('a', 'raw', 500); + expect(getMouseSelectionState('a').copyFlash).toBe('raw'); + + // New drag starts before the 500ms timer. + beginDrag('a', { row: 10, col: 2, altKey: false, startedInScrollback: false }); + expect(getMouseSelectionState('a').copyFlash).toBeNull(); + expect(getMouseSelectionState('a').selection?.startRow).toBe(10); + }); +}); + describe('mouse-selection: snapshot caching', () => { it('returns the same snapshot reference between changes', () => { setMouseReporting('a', 'vt200'); diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index 1d1892f..cca81ee 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -149,6 +149,9 @@ export function beginDrag( args: { row: number; col: number; altKey: boolean; startedInScrollback: boolean }, ): void { const s = ensure(id); + // Clear any in-flight copy flash so its timer won't null out this new + // selection when it fires (the timer checks `copyFlash !== kind`). + s.copyFlash = null; s.selection = { startRow: args.row, startCol: args.col, diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 5c2f71c..7103eac 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -127,6 +127,9 @@ export class FakePtyAdapter implements PlatformAdapter { async getCwd(_id: string): Promise { return null; } async getScrollback(_id: string): Promise { return null; } + async readClipboardFilePaths(): Promise { return null; } + async readClipboardImageAsFilePath(): Promise { return null; } + requestInit(): void {} onPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {} offPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {} diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 6083817..65a2965 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -26,6 +26,12 @@ export interface PlatformAdapter { getCwd(id: string): Promise; getScrollback(id: string): Promise; + // Clipboard support for file references and raw images. + readClipboardFilePaths(): Promise; + readClipboardImageAsFilePath(): Promise; + // Only present on adapters with a native (non-DOM) drag-drop source. + onFilesDropped?(handler: (paths: string[]) => void): () => void; + // PTY event listeners onPtyData(handler: (detail: { id: string; data: string }) => void): void; offPtyData(handler: (detail: { id: string; data: string }) => void): void; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 6063316..87c60b9 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -130,6 +130,22 @@ export class VSCodeAdapter implements PlatformAdapter { return this.requestResponse('pty:getScrollback', 'pty:scrollback', { id }, (msg) => msg.data); } + readClipboardFilePaths(): Promise { + return this.requestResponse( + 'clipboard:readFiles', 'clipboard:files', {}, + (msg) => msg.paths, + 5000, + ); + } + + readClipboardImageAsFilePath(): Promise { + return this.requestResponse( + 'clipboard:readImage', 'clipboard:image', {}, + (msg) => msg.path, + 10000, + ); + } + onPtyData(handler: (detail: { id: string; data: string }) => void): void { this.dataHandlers.add(handler); } diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts index b6d2a50..d871c2d 100644 --- a/lib/src/lib/reconnect.test.ts +++ b/lib/src/lib/reconnect.test.ts @@ -28,6 +28,8 @@ function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): P killPty: vi.fn(), getCwd: vi.fn(async () => null), getScrollback: vi.fn(async () => null), + readClipboardFilePaths: vi.fn(async () => null), + readClipboardImageAsFilePath: vi.fn(async () => null), onPtyData: vi.fn(), offPtyData: vi.fn(), onPtyExit: vi.fn(), diff --git a/lib/src/lib/selection-text.ts b/lib/src/lib/selection-text.ts index 8b949ad..2840c8f 100644 --- a/lib/src/lib/selection-text.ts +++ b/lib/src/lib/selection-text.ts @@ -52,9 +52,9 @@ export function extractSelectionText(terminal: Terminal, sel: Selection): string for (let r = n.r0; r <= n.r1; r++) { const line = buf.getLine(r); if (!line) continue; - const c0 = r === n.r0 ? n.c0 : 0; + const c0 = r === n.r0 ? n.c0 : 0; const c1 = r === n.r1 ? n.c1 + 1 : terminal.cols; - lines.push(line.translateToString(false, c0, c1).replace(/\s+$/, '')); + lines.push(line.translateToString(false, c0, c1).replace(/\s+$/, '')); } return lines.join('\n'); } diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts index 4deb196..8ffba51 100644 --- a/lib/src/lib/session-restore.test.ts +++ b/lib/src/lib/session-restore.test.ts @@ -25,6 +25,8 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { killPty: vi.fn(), getCwd: vi.fn(async () => null), getScrollback: vi.fn(async () => null), + readClipboardFilePaths: vi.fn(async () => null), + readClipboardImageAsFilePath: vi.fn(async () => null), onPtyData: vi.fn(), offPtyData: vi.fn(), onPtyExit: vi.fn(), diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 5824f8d..3c36675 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -28,6 +28,8 @@ function createPlatform(savedState: PersistedSession | null): PlatformAdapter { getAvailableShells: vi.fn(async () => []), getCwd: vi.fn(async () => '/tmp/live'), getScrollback: vi.fn(async () => 'echo hello\n'), + readClipboardFilePaths: vi.fn(async () => null), + readClipboardImageAsFilePath: vi.fn(async () => null), onPtyData: () => {}, offPtyData: () => {}, onPtyExit: () => {}, diff --git a/lib/src/lib/shell-escape.test.ts b/lib/src/lib/shell-escape.test.ts new file mode 100644 index 0000000..77e17c7 --- /dev/null +++ b/lib/src/lib/shell-escape.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { shellEscapePosix, shellEscapeWindows } from './shell-escape'; + +describe('shellEscapePosix', () => { + it('leaves safe paths untouched', () => { + expect(shellEscapePosix('/tmp/a.png')).toBe('/tmp/a.png'); + }); + + it('backslash-escapes spaces', () => { + expect(shellEscapePosix('/tmp/a file.png')).toBe('/tmp/a\\ file.png'); + }); + + it('backslash-escapes multiple spaces', () => { + expect(shellEscapePosix('a b c')).toBe('a\\ b\\ c'); + }); + + it('backslash-escapes single quotes', () => { + expect(shellEscapePosix(`it's.png`)).toBe(`it\\'s.png`); + }); + + it('backslash-escapes double quotes', () => { + expect(shellEscapePosix('a"b.png')).toBe('a\\"b.png'); + }); + + it('backslash-escapes backslashes', () => { + expect(shellEscapePosix('a\\b.png')).toBe('a\\\\b.png'); + }); + + it('single-quote-wraps paths containing newlines (cannot backslash-escape: bash swallows `\\` as line continuation)', () => { + expect(shellEscapePosix('a\nb')).toBe("'a\nb'"); + }); + + it('single-quote-wraps paths containing carriage returns', () => { + expect(shellEscapePosix('a\rb')).toBe("'a\rb'"); + }); + + it("single-quote-wraps with the '\\'' idiom when input mixes newlines and single quotes", () => { + expect(shellEscapePosix("a'b\nc")).toBe("'a'\\''b\nc'"); + }); + + it('backslash-escapes shell metacharacters', () => { + expect(shellEscapePosix('a$b')).toBe('a\\$b'); + expect(shellEscapePosix('a`b')).toBe('a\\`b'); + expect(shellEscapePosix('a&b')).toBe('a\\&b'); + expect(shellEscapePosix('a|b')).toBe('a\\|b'); + expect(shellEscapePosix('a;b')).toBe('a\\;b'); + expect(shellEscapePosix('a(b)c')).toBe('a\\(b\\)c'); + expect(shellEscapePosix('ac')).toBe('a\\c'); + expect(shellEscapePosix('a[b]c')).toBe('a\\[b\\]c'); + expect(shellEscapePosix('a{b}c')).toBe('a\\{b\\}c'); + expect(shellEscapePosix('a*b')).toBe('a\\*b'); + expect(shellEscapePosix('a?b')).toBe('a\\?b'); + expect(shellEscapePosix('a#b')).toBe('a\\#b'); + expect(shellEscapePosix('a~b')).toBe('a\\~b'); + expect(shellEscapePosix('a!b')).toBe('a\\!b'); + }); + + it('handles empty string', () => { + expect(shellEscapePosix('')).toBe(`''`); + }); + + it('preserves unicode (narrow no-break space is not U+0020 — stays)', () => { + expect(shellEscapePosix('/tmp/café.png')).toBe('/tmp/café.png'); + expect(shellEscapePosix('a b')).toBe('a b'); + }); + + it('preserves safe punctuation', () => { + expect(shellEscapePosix('/a-b_c.d+e,f%g@h:i=j/k.png')).toBe('/a-b_c.d+e,f%g@h:i=j/k.png'); + }); +}); + +describe('shellEscapeWindows', () => { + it('wraps in double quotes', () => { + expect(shellEscapeWindows('C:\\Users\\a.png')).toBe(`"C:\\Users\\a.png"`); + }); + + it('doubles embedded double quotes', () => { + expect(shellEscapeWindows('a"b.png')).toBe(`"a""b.png"`); + }); + + it('handles spaces', () => { + expect(shellEscapeWindows('C:\\a file.png')).toBe(`"C:\\a file.png"`); + }); + + it('handles empty string', () => { + expect(shellEscapeWindows('')).toBe(`""`); + }); +}); + +describe('shellEscapePath OS dispatch', () => { + const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); + vi.resetModules(); + vi.doUnmock('./platform'); + }); + + async function importShellEscape(opts: { isMac: boolean; platform: string }) { + vi.doMock('./platform', () => ({ IS_MAC: opts.isMac })); + Object.defineProperty(globalThis, 'navigator', { + value: { platform: opts.platform, userAgent: opts.platform }, + configurable: true, + }); + return import('./shell-escape'); + } + + it('uses posix escape on macOS', async () => { + const { shellEscapePath } = await importShellEscape({ isMac: true, platform: 'MacIntel' }); + expect(shellEscapePath('a b.png')).toBe('a\\ b.png'); + }); + + it('uses posix escape on Linux', async () => { + const { shellEscapePath } = await importShellEscape({ isMac: false, platform: 'Linux x86_64' }); + expect(shellEscapePath('a b.png')).toBe('a\\ b.png'); + }); + + it('uses windows escape on Windows', async () => { + const { shellEscapePath } = await importShellEscape({ isMac: false, platform: 'Win32' }); + expect(shellEscapePath('a b.png')).toBe(`"a b.png"`); + }); +}); diff --git a/lib/src/lib/shell-escape.ts b/lib/src/lib/shell-escape.ts new file mode 100644 index 0000000..b2c5e3d --- /dev/null +++ b/lib/src/lib/shell-escape.ts @@ -0,0 +1,36 @@ +import { IS_MAC } from './platform'; + +const IS_WINDOWS: boolean = (() => { + if (typeof navigator === 'undefined') return false; + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const p = (nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? '').toLowerCase(); + return p.includes('win'); +})(); + +// Matches macOS Terminal's drag-and-drop format: backslash-escape each shell +// metacharacter instead of wrapping in quotes. TUIs like `claude` recognize +// backslash-escaped tokens as filesystem paths where a single-quoted whole +// path gets treated as opaque pasted text. +const POSIX_UNSAFE = /([ \t!"#$&'()*;<>?[\\\]`{|}~])/g; +const POSIX_NEEDS_QUOTES = /[\n\r]/; + +export function shellEscapePosix(input: string): string { + if (input === '') return "''"; + // Newline/CR cannot round-trip through backslash-escape: bash reads + // `\` as a line continuation and *swallows* both the backslash + // and the newline, corrupting filenames that legally contain them. Fall + // back to single-quote wrapping for these, using the '\'' idiom to + // embed literal single quotes. + if (POSIX_NEEDS_QUOTES.test(input)) { + return `'${input.replace(/'/g, `'\\''`)}'`; + } + return input.replace(POSIX_UNSAFE, '\\$1'); +} + +export function shellEscapeWindows(input: string): string { + return `"${input.replace(/"/g, '""')}"`; +} + +export function shellEscapePath(input: string): string { + return !IS_MAC && IS_WINDOWS ? shellEscapeWindows(input) : shellEscapePosix(input); +} diff --git a/lib/src/lib/smart-token.test.ts b/lib/src/lib/smart-token.test.ts index a8d1a88..42814de 100644 --- a/lib/src/lib/smart-token.test.ts +++ b/lib/src/lib/smart-token.test.ts @@ -87,6 +87,11 @@ describe('detectTokenAt: path', () => { expect(t?.text).toBe('src/foo.ts:42:7'); }); + it('error location with trailing period is detected after stripping', () => { + const t = at('Error at src/foo.ts:42. See docs.', 'src/'); + expect(t).toMatchObject({ kind: 'path', text: 'src/foo.ts:42' }); + }); + it('strips trailing period on absolute path', () => { const t = detectTokenAt('/tmp/a.', 0); expect(t?.text).toBe('/tmp/a'); diff --git a/lib/src/lib/smart-token.ts b/lib/src/lib/smart-token.ts index e521f40..c787dee 100644 --- a/lib/src/lib/smart-token.ts +++ b/lib/src/lib/smart-token.ts @@ -16,15 +16,12 @@ export interface DetectedToken { interface Pattern { kind: 'url' | 'path'; re: RegExp; - /** When true, trailing-punctuation stripping is skipped — the pattern's - * trailing characters are significant (e.g. error-location `:line:col`). */ - skipStrip?: boolean; } const PATTERNS: Pattern[] = [ { kind: 'url', re: /^https?:\/\/\S+$/ }, { kind: 'url', re: /^file:\/\/\S+$/ }, - { kind: 'path', re: /^\S+:\d+(:\d+)?$/, skipStrip: true }, // error-location first (so it beats generic path) + { kind: 'path', re: /^\S+:\d+(:\d+)?$/ }, // error-location first (so it beats generic path) { kind: 'path', re: /^~\/\S*$/ }, { kind: 'path', re: /^\/\S+$/ }, { kind: 'path', re: /^\.\.?\/\S*$/ }, @@ -88,11 +85,16 @@ export function detectTokenAt(line: string, col: number): DetectedToken | null { const raw = line.slice(start, end); if (!raw) return null; - for (const { kind, re, skipStrip } of PATTERNS) { - if (!re.test(raw)) continue; - const text = skipStrip ? raw : stripTrailing(raw); - if (!text) continue; - return { kind, start, end: start + text.length, text }; + // Strip trailing punctuation once, then test all patterns against the + // cleaned token. This ensures error-location patterns like `file:42` are + // found even when the original token had a trailing period (e.g. in + // compiler output "Error at src/foo.ts:42."). + const cleaned = stripTrailing(raw); + if (!cleaned) return null; + + for (const { kind, re } of PATTERNS) { + if (!re.test(cleaned)) continue; + return { kind, start, end: start + cleaned.length, text: cleaned }; } return null; } diff --git a/standalone/sidecar/clipboard-ops.js b/standalone/sidecar/clipboard-ops.js new file mode 100644 index 0000000..7362073 --- /dev/null +++ b/standalone/sidecar/clipboard-ops.js @@ -0,0 +1,219 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); + +const execFileP = promisify(execFile); + +const MAX_BUFFER = 16 * 1024 * 1024; + +const MAC_FILE_PATHS_SCRIPT = [ + 'use framework "AppKit"', + 'use framework "Foundation"', + 'use scripting additions', + 'try', + ' set pb to current application\'s NSPasteboard\'s generalPasteboard()', + ' set urls to pb\'s readObjectsForClasses:{current application\'s NSURL} options:(missing value)', + ' if urls is missing value then return ""', + ' set AppleScript\'s text item delimiters to linefeed', + ' set path_list to {}', + ' repeat with u in urls', + ' if (u\'s isFileURL()) as boolean then', + ' set end of path_list to (u\'s |path|() as text)', + ' end if', + ' end repeat', + ' if (count of path_list) > 0 then return path_list as text', + 'end try', + 'return ""', +].join('\n'); + +async function readFilePathsMac(runtime) { + const exec = runtime.exec || execFileP; + try { + const { stdout } = await exec('osascript', ['-e', MAC_FILE_PATHS_SCRIPT], { maxBuffer: MAX_BUFFER }); + return splitNonEmptyLines(stdout); + } catch { + return []; + } +} + +async function readFilePathsWindows(runtime) { + const exec = runtime.exec || execFileP; + const cmd = '$out = Get-Clipboard -Format FileDropList; if ($out) { $out | ForEach-Object { $_.FullName } }'; + try { + const { stdout } = await exec( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', cmd], + { maxBuffer: MAX_BUFFER }, + ); + return splitNonEmptyLines(stdout); + } catch { + return []; + } +} + +function parseUriList(stdout) { + return stdout + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith('#')) + .filter((s) => s.startsWith('file://')) + .map((uri) => { + try { return decodeURIComponent(uri.slice('file://'.length)); } + catch { return null; } + }) + .filter(Boolean); +} + +async function readFilePathsLinux(runtime) { + const env = runtime.env || process.env; + const exec = runtime.exec || execFileP; + const wayland = Boolean(env.WAYLAND_DISPLAY); + const attempts = wayland + ? [['wl-paste', ['--type', 'text/uri-list', '--no-newline']], ['xclip', ['-selection', 'clipboard', '-o', '-t', 'text/uri-list']]] + : [['xclip', ['-selection', 'clipboard', '-o', '-t', 'text/uri-list']], ['wl-paste', ['--type', 'text/uri-list', '--no-newline']]]; + + for (const [cmd, args] of attempts) { + try { + const { stdout } = await exec(cmd, args, { maxBuffer: MAX_BUFFER }); + const paths = parseUriList(stdout); + if (paths.length > 0) return paths; + } catch {} + } + return []; +} + +async function readClipboardFilePaths(runtime = {}) { + const platform = runtime.platform || process.platform; + if (platform === 'darwin') return readFilePathsMac(runtime); + if (platform === 'win32') return readFilePathsWindows(runtime); + return readFilePathsLinux(runtime); +} + +async function readImageMac(out, runtime) { + const exec = runtime.exec || execFileP; + const script = [ + 'try', + ' set info to clipboard info', + ' repeat with entry in info', + ' if (item 1 of entry) is «class furl» then return ""', + ' end repeat', + 'end try', + 'try', + ` set f to open for access POSIX file "${out.replace(/"/g, '\\"')}" with write permission`, + ' write (the clipboard as «class PNGf») to f', + ' close access f', + ' return "ok"', + 'on error', + ' try', + ' close access f', + ' end try', + ' return ""', + 'end try', + ].join('\n'); + try { + const { stdout } = await exec('osascript', ['-e', script], { maxBuffer: MAX_BUFFER }); + return stdout.trim() === 'ok'; + } catch { + return false; + } +} + +async function readImageWindows(out, runtime) { + const exec = runtime.exec || execFileP; + const cmd = [ + 'Add-Type -AssemblyName System.Windows.Forms;', + 'Add-Type -AssemblyName System.Drawing;', + '$img = [System.Windows.Forms.Clipboard]::GetImage();', + `if ($img) { $img.Save('${out.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png); 'ok' } else { '' }`, + ].join(' '); + try { + const { stdout } = await exec( + 'powershell', + ['-NoProfile', '-NonInteractive', '-Command', cmd], + { maxBuffer: MAX_BUFFER }, + ); + return stdout.trim() === 'ok'; + } catch { + return false; + } +} + +async function readImageLinux(out, runtime, fsp) { + const env = runtime.env || process.env; + const exec = runtime.exec || execFileP; + const wayland = Boolean(env.WAYLAND_DISPLAY); + const attempts = wayland + ? [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]] + : [['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']], ['wl-paste', ['--type', 'image/png']]]; + + for (const [cmd, args] of attempts) { + try { + const { stdout } = await exec(cmd, args, { encoding: 'buffer', maxBuffer: MAX_BUFFER }); + if (stdout && stdout.length > 0) { + await fsp.writeFile(out, stdout, { mode: 0o600 }); + return true; + } + } catch {} + } + return false; +} + +// Delete dropped images after this window so $TMPDIR doesn't accumulate one +// file per image paste across a long-lived session. Long enough that any +// command the user launched against the path (claude, file, open, ...) has +// had time to read it. +const DROP_TTL_MS = 5 * 60 * 1000; + +function scheduleDropCleanup(filePath, fsp, setTimeoutFn) { + const timer = setTimeoutFn(() => { + // Fire-and-forget — no awaiting inside the timer callback. + Promise.resolve() + .then(() => fsp.unlink(filePath).catch(() => {})) + .then(() => fsp.rmdir(path.dirname(filePath)).catch(() => {})); + }, DROP_TTL_MS); + if (timer && typeof timer.unref === 'function') timer.unref(); +} + +async function readClipboardImageAsFilePath(runtime = {}) { + const platform = runtime.platform || process.platform; + const osModule = runtime.osModule || os; + const cryptoModule = runtime.cryptoModule || crypto; + const fsp = (runtime.fsModule && runtime.fsModule.promises) || fs.promises; + const setTimeoutFn = runtime.setTimeoutFn || setTimeout; + + let dir = null; + let out = null; + try { + dir = await fsp.mkdtemp(path.join(osModule.tmpdir(), 'mouseterm-drops-')); + await fsp.chmod?.(dir, 0o700); + out = path.join(dir, `${cryptoModule.randomUUID()}-clipboard.png`); + const ok = platform === 'darwin' ? await readImageMac(out, runtime) + : platform === 'win32' ? await readImageWindows(out, runtime) + : await readImageLinux(out, runtime, fsp); + if (ok && (await fsp.stat(out)).size > 0) { + await fsp.chmod?.(out, 0o600); + scheduleDropCleanup(out, fsp, setTimeoutFn); + return out; + } + } catch {} + if (out) { + try { await fsp.unlink(out); } catch {} + } + if (dir) { + try { await fsp.rmdir(dir); } catch {} + } + return null; +} + +module.exports = { + readClipboardFilePaths, + readClipboardImageAsFilePath, + parseUriList, + splitNonEmptyLines, +}; +function splitNonEmptyLines(stdout) { + return stdout.split(/\r?\n/).filter((s) => s.length > 0); +} diff --git a/standalone/sidecar/clipboard-ops.test.js b/standalone/sidecar/clipboard-ops.test.js new file mode 100644 index 0000000..a270ada --- /dev/null +++ b/standalone/sidecar/clipboard-ops.test.js @@ -0,0 +1,209 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { + readClipboardFilePaths, + readClipboardImageAsFilePath, + parseUriList, + splitNonEmptyLines, +} = require('./clipboard-ops'); + +function fakeOs(tmp = '/tmp/test') { + return { tmpdir: () => tmp }; +} + +function fakeCrypto(uuid = 'uuid-0') { + return { randomUUID: () => uuid }; +} + +function fakeFs() { + const writes = []; + const files = new Map(); + const unlinks = []; + const chmods = []; + const rmdirs = []; + return { + writes, + files, + unlinks, + chmods, + rmdirs, + module: { + promises: { + async mkdtemp(prefix) { return `${prefix}dir-0`; }, + async chmod(p, mode) { chmods.push([p, mode]); }, + async writeFile(p, buf, opts) { writes.push([p, buf, opts]); files.set(p, buf); }, + async stat(p) { + const b = files.get(p); + if (!b) throw new Error('ENOENT'); + return { size: b.length }; + }, + async unlink(p) { unlinks.push(p); files.delete(p); }, + async rmdir(p) { rmdirs.push(p); }, + }, + }, + }; +} + +test('splitNonEmptyLines preserves leading and trailing path spaces', () => { + assert.deepEqual(splitNonEmptyLines(' /tmp/leading.png\n/tmp/trailing.png \n'), [ + ' /tmp/leading.png', + '/tmp/trailing.png ', + ]); +}); + +test('parseUriList decodes file URIs and ignores comments/non-file', () => { + const input = [ + '# comment', + 'file:///Users/me/a%20file.png', + 'file:///tmp/plain.txt', + 'https://example.com/nope', + '', + ].join('\n'); + assert.deepEqual(parseUriList(input), [ + '/Users/me/a file.png', + '/tmp/plain.txt', + ]); +}); + +test('readClipboardFilePaths on mac parses osascript linefeed-separated output', async () => { + const paths = await readClipboardFilePaths({ + platform: 'darwin', + exec: async (cmd, args) => { + assert.equal(cmd, 'osascript'); + assert.equal(args[0], '-e'); + return { stdout: '/Users/me/a.png\n/Users/me/b.jpg\n' }; + }, + }); + assert.deepEqual(paths, ['/Users/me/a.png', '/Users/me/b.jpg']); +}); + +test('readClipboardFilePaths on mac returns [] when osascript fails', async () => { + const paths = await readClipboardFilePaths({ + platform: 'darwin', + exec: async () => { throw new Error('boom'); }, + }); + assert.deepEqual(paths, []); +}); + +test('readClipboardFilePaths on windows parses FileDropList lines', async () => { + const paths = await readClipboardFilePaths({ + platform: 'win32', + exec: async (cmd) => { + assert.equal(cmd, 'powershell'); + return { stdout: 'C:\\a.png\r\nC:\\b.jpg\r\n' }; + }, + }); + assert.deepEqual(paths, ['C:\\a.png', 'C:\\b.jpg']); +}); + +test('readClipboardFilePaths on linux prefers xclip in X11 and parses file URIs', async () => { + const calls = []; + const paths = await readClipboardFilePaths({ + platform: 'linux', + env: {}, + exec: async (cmd, args) => { + calls.push([cmd, args]); + if (cmd === 'xclip') return { stdout: 'file:///tmp/one.png\nfile:///tmp/two.png\n' }; + throw new Error('should not reach'); + }, + }); + assert.deepEqual(paths, ['/tmp/one.png', '/tmp/two.png']); + assert.equal(calls[0][0], 'xclip'); +}); + +test('readClipboardFilePaths on linux prefers wl-paste under Wayland', async () => { + const calls = []; + const paths = await readClipboardFilePaths({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-0' }, + exec: async (cmd, args) => { + calls.push([cmd, args]); + if (cmd === 'wl-paste') return { stdout: 'file:///tmp/w.png\n' }; + throw new Error('should not reach'); + }, + }); + assert.deepEqual(paths, ['/tmp/w.png']); + assert.equal(calls[0][0], 'wl-paste'); +}); + +test('readClipboardFilePaths on linux falls back when first tool fails', async () => { + const paths = await readClipboardFilePaths({ + platform: 'linux', + env: {}, + exec: async (cmd) => { + if (cmd === 'xclip') throw new Error('no xclip'); + return { stdout: 'file:///tmp/fb.png\n' }; + }, + }); + assert.deepEqual(paths, ['/tmp/fb.png']); +}); + +test('readClipboardImageAsFilePath on mac returns temp path on success', async () => { + const fs = fakeFs(); + const timers = []; + const result = await readClipboardImageAsFilePath({ + platform: 'darwin', + osModule: fakeOs('/t'), + cryptoModule: fakeCrypto('uuid-I'), + fsModule: fs.module, + setTimeoutFn: (cb, ms) => { timers.push({ cb, ms }); return { unref() {} }; }, + exec: async (cmd, args) => { + assert.equal(cmd, 'osascript'); + const [, script] = args; + const match = script.match(/POSIX file "([^"]+)"/); + assert.ok(match, 'script should reference target path'); + fs.files.set(match[1], Buffer.from('fakepng')); + return { stdout: 'ok\n' }; + }, + }); + const expected = path.join('/t', 'mouseterm-drops-dir-0', 'uuid-I-clipboard.png'); + assert.equal(result, expected); + assert.deepEqual(fs.chmods, [ + [path.join('/t', 'mouseterm-drops-dir-0'), 0o700], + [expected, 0o600], + ]); + // Cleanup was scheduled, but not yet run: the temp file still exists. + assert.equal(timers.length, 1); + assert.equal(timers[0].ms, 5 * 60 * 1000); + assert.equal(fs.unlinks.length, 0); + + // Firing the scheduled cleanup unlinks the file and its parent dir. + timers[0].cb(); + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(fs.unlinks, [expected]); + assert.deepEqual(fs.rmdirs, [path.join('/t', 'mouseterm-drops-dir-0')]); +}); + +test('readClipboardImageAsFilePath returns null when osascript returns empty', async () => { + const fs = fakeFs(); + const result = await readClipboardImageAsFilePath({ + platform: 'darwin', + osModule: fakeOs('/t'), + cryptoModule: fakeCrypto('uuid-I'), + fsModule: fs.module, + exec: async () => ({ stdout: '' }), + }); + assert.equal(result, null); + assert.deepEqual(fs.rmdirs, [path.join('/t', 'mouseterm-drops-dir-0')]); +}); + +test('readClipboardImageAsFilePath on linux writes buffer from exec stdout', async () => { + const fs = fakeFs(); + const result = await readClipboardImageAsFilePath({ + platform: 'linux', + env: {}, + osModule: fakeOs('/t'), + cryptoModule: fakeCrypto('uuid-L'), + fsModule: fs.module, + exec: async (cmd) => { + if (cmd === 'xclip') return { stdout: Buffer.from([0x89, 0x50, 0x4E, 0x47]) }; + throw new Error('no tool'); + }, + }); + assert.equal(result, path.join('/t', 'mouseterm-drops-dir-0', 'uuid-L-clipboard.png')); + assert.equal(fs.writes.length, 1); + assert.deepEqual(fs.writes[0][2], { mode: 0o600 }); +}); diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index ae871dd..b247dd6 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -9,11 +9,25 @@ const readline = require('readline'); const nodePty = require('node-pty'); const { create } = require('./pty-core'); +const clipboard = require('./clipboard-ops'); + +function send(event, data) { + process.stdout.write(JSON.stringify({ event, data }) + '\n'); +} const mgr = create((event, data) => { - process.stdout.write(JSON.stringify({ event: `pty:${event}`, data }) + '\n'); + send(`pty:${event}`, data); }, nodePty); +async function respondAsync(event, requestId, run) { + try { + const data = await run(); + send(event, { ...data, requestId }); + } catch (err) { + send(event, { error: String(err && err.message || err), requestId }); + } +} + const rl = readline.createInterface({ input: process.stdin }); rl.on('line', (line) => { @@ -29,6 +43,16 @@ rl.on('line', (line) => { case 'pty:getScrollback': mgr.getScrollback(data.id, data.requestId); break; case 'pty:getShells': mgr.getShells(data.requestId); break; case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout); break; + case 'clipboard:readFiles': + respondAsync('clipboard:files', data.requestId, async () => ({ + paths: await clipboard.readClipboardFilePaths(), + })); + break; + case 'clipboard:readImage': + respondAsync('clipboard:image', data.requestId, async () => ({ + path: await clipboard.readClipboardImageAsFilePath(), + })); + break; default: console.error(`[sidecar] Unknown event: ${event}`); } } catch (err) { diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 99aad47..b8e04a9 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -11,7 +11,10 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tauri::{AppHandle, Emitter, Manager, RunEvent}; +use tauri::{ + menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}, + AppHandle, DragDropEvent, Emitter, Manager, RunEvent, WindowEvent, +}; use tauri_plugin_shell::{process::CommandEvent, ShellExt}; enum SidecarMsg { @@ -213,6 +216,29 @@ fn pty_get_scrollback( .and_then(|data| data.as_str().map(String::from))) } +#[tauri::command] +fn read_clipboard_file_paths( + state: tauri::State<'_, SidecarState>, +) -> Result, String> { + let response = + request_from_sidecar_timeout(&state, "clipboard:readFiles", serde_json::json!({}), Duration::from_secs(5))?; + Ok(response + .get("paths") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default()) +} + +#[tauri::command] +fn read_clipboard_image_as_file_path( + state: tauri::State<'_, SidecarState>, +) -> Result, String> { + let response = + request_from_sidecar_timeout(&state, "clipboard:readImage", serde_json::json!({}), Duration::from_secs(10))?; + Ok(response + .get("path") + .and_then(|path| path.as_str().map(String::from))) +} + #[tauri::command] fn shutdown_sidecar(state: tauri::State<'_, SidecarState>) { let _ = state.tx.send(SidecarMsg::Shutdown); @@ -420,6 +446,57 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + // Replace Tauri's default menu, which binds Cmd+V to a native Paste + // action that fights with the webview's DOM keydown handler. The + // terminal owns Cmd+C / Cmd+V / Cmd+X in JS (see `Pond.tsx`). + .menu(|handle| { + let pkg = handle.package_info(); + let about = AboutMetadata { + name: Some(pkg.name.clone()), + version: Some(pkg.version.to_string()), + ..Default::default() + }; + let mut items: Vec>> = Vec::new(); + #[cfg(target_os = "macos")] + items.push(Box::new(Submenu::with_items( + handle, + pkg.name.clone(), + true, + &[ + &PredefinedMenuItem::about(handle, None, Some(about))?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::services(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::hide(handle, None)?, + &PredefinedMenuItem::hide_others(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::quit(handle, None)?, + ], + )?)); + items.push(Box::new(Submenu::with_items( + handle, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(handle, None)?, + &PredefinedMenuItem::maximize(handle, None)?, + #[cfg(target_os = "macos")] + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::close_window(handle, None)?, + ], + )?)); + let refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = items.iter().map(|b| b.as_ref()).collect(); + Menu::with_items(handle, &refs) + }) + .on_window_event(|window, event| { + if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) = event { + let payload: Vec = paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let _ = window.emit("mouseterm://files-dropped", serde_json::json!({ "paths": payload })); + } + }) .setup(|app| { init_log(); append_log("[app] setup started"); @@ -453,6 +530,8 @@ pub fn run() { pty_request_init, shutdown_sidecar, get_available_shells, + read_clipboard_file_paths, + read_clipboard_image_as_file_path, ]) .build(tauri::generate_context!()) .expect("error while building MouseTerm") diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 4bd14c4..0cbc96f 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -26,6 +26,7 @@ export class TauriAdapter implements PlatformAdapter { private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>(); private listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>(); private replayHandlers = new Set<(detail: { id: string; data: string }) => void>(); + private filesDroppedHandlers = new Set<(paths: string[]) => void>(); private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>(); private unlistenFns: Array<() => void> = []; private alertManager = new AlertManager(); @@ -76,6 +77,14 @@ export class TauriAdapter implements PlatformAdapter { } }), ); + + this.unlistenFns.push( + await listen<{ paths: string[] }>("mouseterm://files-dropped", (event) => { + const paths = event.payload.paths ?? []; + if (paths.length === 0) return; + for (const handler of this.filesDroppedHandlers) handler(paths); + }), + ); } shutdown(): void { @@ -121,6 +130,23 @@ export class TauriAdapter implements PlatformAdapter { } catch { return null; } } + async readClipboardFilePaths(): Promise { + try { + return await rawInvoke("read_clipboard_file_paths"); + } catch { return null; } + } + + async readClipboardImageAsFilePath(): Promise { + try { + return await rawInvoke("read_clipboard_image_as_file_path"); + } catch { return null; } + } + + onFilesDropped(handler: (paths: string[]) => void): () => void { + this.filesDroppedHandlers.add(handler); + return () => { this.filesDroppedHandlers.delete(handler); }; + } + onPtyData(handler: (detail: { id: string; data: string }) => void): void { this.dataHandlers.add(handler); } diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index 6033b2a..17d1d81 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -78,7 +78,12 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push( - vscode.window.registerWebviewViewProvider('mouseterm.view', provider), + vscode.window.registerWebviewViewProvider('mouseterm.view', provider, { + // Keep the webview script + xterm DOM alive when the Panel is hidden + // (close/toggle), so PTYs and scrollback are preserved across re-show + // without going through the reconnect dance. + webviewOptions: { retainContextWhenHidden: true }, + }), vscode.window.registerWebviewPanelSerializer('mouseterm', { async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: unknown) { setupPanel(context, panel, state, () => provider.getSelectedShell()); diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 0742a71..3df645d 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -5,6 +5,11 @@ import type { PersistedSession } from '../../lib/src/lib/session-types'; import type { WebviewMessage, ExtensionMessage } from './message-types'; import { log } from './log'; +const clipboardOps = require('../../lib/clipboard-ops.cjs') as { + readClipboardFilePaths(): Promise; + readClipboardImageAsFilePath(): Promise; +}; + // Global set of PTY IDs claimed by any router instance. // Prevents reconnecting routers from stealing PTYs owned by other webviews. const globalOwnedPtyIds = new Set(); @@ -181,6 +186,26 @@ export function attachRouter( } satisfies ExtensionMessage); }); break; + case 'clipboard:readFiles': + clipboardOps.readClipboardFilePaths() + .then((paths) => webview.postMessage({ + type: 'clipboard:files', paths: paths.length ? paths : null, requestId: msg.requestId, + } satisfies ExtensionMessage)) + .catch((err) => { + log.info(`[clipboard] readFiles failed: ${err?.message ?? err}`); + webview.postMessage({ type: 'clipboard:files', paths: null, requestId: msg.requestId } satisfies ExtensionMessage); + }); + break; + case 'clipboard:readImage': + clipboardOps.readClipboardImageAsFilePath() + .then((path) => webview.postMessage({ + type: 'clipboard:image', path, requestId: msg.requestId, + } satisfies ExtensionMessage)) + .catch((err) => { + log.info(`[clipboard] readImage failed: ${err?.message ?? err}`); + webview.postMessage({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage); + }); + break; case 'mouseterm:init': { // Webview has (re-)initialized — subscribe to live events. // Tear down previous subscriptions first (webview was destroyed and recreated). diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index cacc5ec..fb8d8b3 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -9,6 +9,8 @@ export type WebviewMessage = | { type: 'pty:getCwd'; id: string; requestId?: string } | { type: 'pty:getScrollback'; id: string; requestId?: string } | { type: 'pty:getShells'; requestId?: string } + | { type: 'clipboard:readFiles'; requestId: string } + | { type: 'clipboard:readImage'; requestId: string } | { type: 'mouseterm:init' } | { type: 'mouseterm:saveState'; state: unknown } | { type: 'mouseterm:flushSessionSaveDone'; requestId: string } @@ -41,6 +43,8 @@ export type ExtensionMessage = | { type: 'pty:cwd'; id: string; cwd: string | null; requestId?: string } | { type: 'pty:scrollback'; id: string; data: string | null; requestId?: string } | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } + | { type: 'clipboard:files'; paths: string[] | null; requestId: string } + | { type: 'clipboard:image'; path: string | null; requestId: string } | { type: 'mouseterm:newTerminal'; shell?: string; args?: string[] } | { type: 'mouseterm:selectedShell'; shell?: string; args?: string[] } | { type: 'mouseterm:flushSessionSave'; requestId: string } diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 24cc96a..f41b1c1 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -6,6 +6,7 @@ import { getSavedSessionState, saveSessionState, mergeAlertStates } from './sess import type { ExtensionMessage } from './message-types'; import * as ptyManager from './pty-manager'; import { resolveSelectedShell } from './shell-selection'; +import { log } from './log'; export class MouseTermViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; @@ -79,6 +80,7 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { }); view.onDidDispose(() => { + log.info('[view] onDidDispose fired — releasing router (PTYs remain alive)'); this.routerDisposable?.dispose(); this.routerDisposable = undefined; this.view = undefined;