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
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ export function handleMouseSelectionKeys(e: KeyboardEvent, ctx: WallKeyboardCtx)
});
return true;
}
if (mod && keyLower === 'v') {
// Accept either Ctrl+V or Cmd+V for paste on all platforms — matches VSCode's
// terminal paste behavior and the muscle memory of users coming from Linux/Windows.
// Trade-off: shadows readline's ^V verbatim-insert; not worth surfacing as a
// setting until someone asks for it.
if ((e.metaKey || e.ctrlKey) && keyLower === 'v') {
e.preventDefault();
e.stopImmediatePropagation();
void doPaste(sid);
Expand Down
5 changes: 2 additions & 3 deletions lib/src/lib/clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,13 @@ describe('doPaste three-tier fallthrough', () => {
});
});

it('uses file refs when present and never reads text or image', async () => {
it('prefers file refs over text and skips the image read', async () => {
mocks.readClipboardFilePaths.mockResolvedValue(['/tmp/a.png', '/tmp/b file.png']);
mocks.readText.mockResolvedValue('should not be read');
mocks.readText.mockResolvedValue('coexisting text payload');
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 ');
Expand Down
29 changes: 20 additions & 9 deletions lib/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ export function pasteFilePaths(terminalId: string, paths: string[]): void {
}

async function readTextFromClipboard(): Promise<string> {
// Prefer the platform's native text read when available — navigator.clipboard.readText()
// on macOS WKWebView pops a "Paste from <App>" confirmation menu at the cursor every
// time it's invoked from JS, which defeats the point of a paste shortcut.
const platform = getPlatform();
if (platform.readClipboardText) {
try {
return (await platform.readClipboardText()) ?? '';
} catch {
return '';
}
}
try {
if (typeof navigator === 'undefined' || !navigator.clipboard?.readText) return '';
return await navigator.clipboard.readText();
Expand All @@ -73,24 +84,24 @@ async function readTextFromClipboard(): Promise<string> {

/**
* 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.
* program's bracketed-paste mode when enabled (spec §8.5). Prefers file
* references over plain text (a Finder Cmd+V types the path, not "Document.pdf"
* as a name string), with raw images saved to a temp file as a last resort.
*
* 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.
* File-path and text reads run in parallel since they're independent IPC
* roundtrips; the image read is sequential because it allocates a temp file.
*/
export async function doPaste(terminalId: string): Promise<void> {
const platform = getPlatform();

const paths = await platform.readClipboardFilePaths().catch(() => null);
const [paths, text] = await Promise.all([
platform.readClipboardFilePaths().catch(() => null),
readTextFromClipboard(),
]);
if (paths && paths.length > 0) {
pasteFilePaths(terminalId, paths);
return;
}

const text = await readTextFromClipboard();
if (text) {
writePasteToPty(terminalId, text);
return;
Expand Down
4 changes: 4 additions & 0 deletions lib/src/lib/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export interface PlatformAdapter {
// Clipboard support for file references and raw images.
readClipboardFilePaths(): Promise<string[] | null>;
readClipboardImageAsFilePath(): Promise<string | null>;
// Optional native clipboard text read. When present, doPaste uses this
// instead of navigator.clipboard.readText() so adapters whose webview pops
// a "Paste from <App>" confirmation (notably Tauri's WKWebView) can bypass it.
readClipboardText?(): Promise<string | null>;
// Only present on adapters with a native (non-DOM) drag-drop source. Currently inert in Tauri; see diffplug/mouseterm#38 and tauri-apps/tauri#14373.
onFilesDropped?(handler: (paths: string[]) => void): () => void;

Expand Down
53 changes: 53 additions & 0 deletions standalone/sidecar/clipboard-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,58 @@ async function readClipboardFilePaths(runtime = {}) {
return readFilePathsLinux(runtime);
}

async function readTextMac(runtime) {
const exec = runtime.exec || execFileP;
try {
const { stdout } = await exec('pbpaste', [], { maxBuffer: MAX_BUFFER });
return stdout;
} catch {
return '';
}
}

async function readTextWindows(runtime) {
const exec = runtime.exec || execFileP;
try {
const { stdout } = await exec(
'powershell',
['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'],
{ maxBuffer: MAX_BUFFER },
);
// Get-Clipboard -Raw appends a trailing newline that wasn't on the clipboard.
return stdout.replace(/\r?\n$/, '');
} catch {
return '';
}
}

async function readTextLinux(runtime) {
const env = runtime.env || process.env;
const exec = runtime.exec || execFileP;
const wayland = Boolean(env.WAYLAND_DISPLAY);
const attempts = wayland
? [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']]]
: [['xclip', ['-selection', 'clipboard', '-o']], ['wl-paste', ['--no-newline']]];

for (const [cmd, args] of attempts) {
try {
const { stdout } = await exec(cmd, args, { maxBuffer: MAX_BUFFER });
if (stdout) return stdout;
} catch {}
}
return '';
}

// Native clipboard text read — bypasses navigator.clipboard.readText(), whose
// WKWebView implementation pops a "Paste from <App>" confirmation menu at the
// cursor every time it's called from JS.
async function readClipboardText(runtime = {}) {
const platform = runtime.platform || process.platform;
if (platform === 'darwin') return readTextMac(runtime);
if (platform === 'win32') return readTextWindows(runtime);
return readTextLinux(runtime);
}

async function readImageMac(out, runtime) {
const exec = runtime.exec || execFileP;
const script = [
Expand Down Expand Up @@ -211,6 +263,7 @@ async function readClipboardImageAsFilePath(runtime = {}) {
module.exports = {
readClipboardFilePaths,
readClipboardImageAsFilePath,
readClipboardText,
parseUriList,
splitNonEmptyLines,
};
Expand Down
76 changes: 76 additions & 0 deletions standalone/sidecar/clipboard-ops.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const path = require('node:path');
const {
readClipboardFilePaths,
readClipboardImageAsFilePath,
readClipboardText,
parseUriList,
splitNonEmptyLines,
} = require('./clipboard-ops');
Expand Down Expand Up @@ -190,6 +191,81 @@ test('readClipboardImageAsFilePath returns null when osascript returns empty', a
assert.deepEqual(fs.rmdirs, [path.join('/t', 'mouseterm-drops-dir-0')]);
});

test('readClipboardText on mac shells out to pbpaste', async () => {
const calls = [];
const text = await readClipboardText({
platform: 'darwin',
exec: async (cmd, args) => {
calls.push([cmd, args]);
return { stdout: 'hello clipboard' };
},
});
assert.equal(text, 'hello clipboard');
assert.deepEqual(calls, [['pbpaste', []]]);
});

test('readClipboardText on mac returns empty string when pbpaste fails', async () => {
const text = await readClipboardText({
platform: 'darwin',
exec: async () => { throw new Error('no pbpaste'); },
});
assert.equal(text, '');
});

test('readClipboardText on windows strips Get-Clipboard trailing newline', async () => {
const text = await readClipboardText({
platform: 'win32',
exec: async (cmd, args) => {
assert.equal(cmd, 'powershell');
assert.ok(args.includes('Get-Clipboard -Raw'));
return { stdout: 'line1\r\nline2\r\n' };
},
});
assert.equal(text, 'line1\r\nline2');
});

test('readClipboardText on linux prefers xclip in X11', async () => {
const calls = [];
const text = await readClipboardText({
platform: 'linux',
env: {},
exec: async (cmd, args) => {
calls.push([cmd, args]);
if (cmd === 'xclip') return { stdout: 'x11 text' };
throw new Error('should not reach');
},
});
assert.equal(text, 'x11 text');
assert.equal(calls[0][0], 'xclip');
});

test('readClipboardText on linux prefers wl-paste under Wayland', async () => {
const calls = [];
const text = await readClipboardText({
platform: 'linux',
env: { WAYLAND_DISPLAY: 'wayland-0' },
exec: async (cmd, args) => {
calls.push([cmd, args]);
if (cmd === 'wl-paste') return { stdout: 'wayland text' };
throw new Error('should not reach');
},
});
assert.equal(text, 'wayland text');
assert.equal(calls[0][0], 'wl-paste');
});

test('readClipboardText on linux falls back when first tool fails', async () => {
const text = await readClipboardText({
platform: 'linux',
env: {},
exec: async (cmd) => {
if (cmd === 'xclip') throw new Error('no xclip');
return { stdout: 'fallback text' };
},
});
assert.equal(text, 'fallback text');
});

test('readClipboardImageAsFilePath on linux writes buffer from exec stdout', async () => {
const fs = fakeFs();
const result = await readClipboardImageAsFilePath({
Expand Down
5 changes: 5 additions & 0 deletions standalone/sidecar/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ rl.on('line', (line) => {
path: await clipboard.readClipboardImageAsFilePath(),
}));
break;
case 'clipboard:readText':
respondAsync('clipboard:text', data.requestId, async () => ({
text: await clipboard.readClipboardText(),
}));
break;
default: console.error(`[sidecar] Unknown event: ${event}`);
}
} catch (err) {
Expand Down
13 changes: 13 additions & 0 deletions standalone/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,18 @@ fn read_clipboard_image_as_file_path(
.and_then(|path| path.as_str().map(String::from)))
}

#[tauri::command]
fn read_clipboard_text(
state: tauri::State<'_, SidecarState>,
) -> Result<String, String> {
let response =
request_from_sidecar_timeout(&state, "clipboard:readText", serde_json::json!({}), Duration::from_secs(5))?;
Ok(response
.get("text")
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default())
}

#[tauri::command]
fn read_update_log() -> Result<String, String> {
read_log_tail(10_000)
Expand Down Expand Up @@ -632,6 +644,7 @@ pub fn run() {
get_available_shells,
read_clipboard_file_paths,
read_clipboard_image_as_file_path,
read_clipboard_text,
read_update_log,
])
.build(tauri::generate_context!())
Expand Down
6 changes: 6 additions & 0 deletions standalone/src/tauri-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ export class TauriAdapter implements PlatformAdapter {
} catch { return null; }
}

async readClipboardText(): Promise<string | null> {
try {
return await rawInvoke<string>("read_clipboard_text");
} catch { return null; }
}

onFilesDropped(handler: (paths: string[]) => void): () => void {
this.filesDroppedHandlers.add(handler);
return () => { this.filesDroppedHandlers.delete(handler); };
Expand Down
Loading