Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
83 changes: 83 additions & 0 deletions apps/web/src/terminal/ghostty/runtimeAbi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,89 @@ describe("vendored libghostty-vt WebAssembly", () => {
call("ghostty_wasm_free_u8_array", options, 8);
});

it("formats a cell-drag selection installed from screen grid refs", async () => {
const result = await WebAssembly.instantiate(
decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
{ env: { log: () => {} } },
);
const instance = result instanceof WebAssembly.Instance ? result : result.instance;
const memory = instance.exports.memory as WebAssembly.Memory;
const call = (name: string, ...args: number[]) =>
(instance.exports[name] as WasmFunction)(...args);
const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
const free = (pointer: number, size: number) =>
call("ghostty_wasm_free_u8_array", pointer, size);
const options = alloc(8);
new DataView(memory.buffer, options, 8).setUint16(0, 80, true);
new DataView(memory.buffer, options, 8).setUint16(2, 24, true);
const terminalSlot = call("ghostty_wasm_alloc_opaque");
expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
const input = new TextEncoder().encode("hello\r\nworld");
const inputPointer = alloc(input.length);
new Uint8Array(memory.buffer, inputPointer, input.length).set(input);
call("ghostty_terminal_vt_write", terminal, inputPointer, input.length);

const gridRefAt = (x: number, y: number) => {
const point = alloc(24);
const pointView = new DataView(memory.buffer, point, 24);
pointView.setUint32(0, 2, true);
pointView.setUint16(8, x, true);
pointView.setUint32(12, y, true);
const ref = alloc(12);
new DataView(memory.buffer, ref, 12).setUint32(0, 12, true);
expect(call("ghostty_terminal_grid_ref", terminal, point, ref)).toBe(0);
free(point, 24);
return ref;
};
const start = gridRefAt(0, 0);
const end = gridRefAt(4, 1);
const selection = alloc(32);
const selectionBytes = new Uint8Array(memory.buffer, selection, 32);
selectionBytes.fill(0);
new DataView(memory.buffer, selection, 32).setUint32(0, 32, true);
selectionBytes.set(new Uint8Array(memory.buffer, start, 12), 4);
selectionBytes.set(new Uint8Array(memory.buffer, end, 12), 16);
expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0);

const formatOptions = alloc(16);
new Uint8Array(memory.buffer, formatOptions, 16).fill(0);
const formatView = new DataView(memory.buffer, formatOptions, 16);
formatView.setUint32(0, 16, true);
formatView.setUint8(8, 1);
formatView.setUint8(9, 1);
const written = call("ghostty_wasm_alloc_usize");
expect(
call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written),
).toBe(-3);
const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
const output = alloc(outputSize);
expect(
call(
"ghostty_terminal_selection_format_buf",
terminal,
formatOptions,
output,
outputSize,
written,
),
).toBe(0);
expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe(
"hello\nworld",
);

free(output, outputSize);
call("ghostty_wasm_free_usize", written);
free(formatOptions, 16);
free(selection, 32);
free(end, 12);
free(start, 12);
free(inputPointer, input.length);
call("ghostty_terminal_free", terminal);
call("ghostty_wasm_free_opaque", terminalSlot);
free(options, 8);
});

it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => {
const result = await WebAssembly.instantiate(
decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import {
DEFAULT_TERMINAL_FONT_FAMILY,
DEFAULT_TERMINAL_FONT_SIZE,
advanceTerminalSelectionClickSequence,
applyTerminalCopyEvent,
clearPrimedTerminalCopyInput,
ghosttyMouseButton,
isTerminalAltGraphText,
isTerminalCompositionCommitInput,
isTerminalCopyShortcut,
isTerminalLinkPointerGesture,
isTerminalPasteShortcut,
loadTerminalFontFamily,
primeTerminalCopyInput,
shouldBlinkTerminalCursor,
shouldReportTerminalMouse,
shouldShowTerminalLinkHover,
Expand Down Expand Up @@ -233,6 +236,51 @@ describe("isTerminalCopyShortcut", () => {
});
});

describe("applyTerminalCopyEvent", () => {
it("writes the selection and claims the fallback when clipboardData is present", () => {
const setData = vi.fn();
expect(applyTerminalCopyEvent("ls -la", { setData })).toEqual({
preventDefault: true,
claimWriteFallback: true,
});
expect(setData).toHaveBeenCalledWith("text/plain", "ls -la");
});

it("leaves the writeText fallback alive when clipboardData is missing", () => {
// Electron's edit-menu Copy often delivers a copy event with no
// clipboardData. Claiming that event used to skip writeText and copy the
// empty IME textarea, which is the blank clipboard users paste.
expect(applyTerminalCopyEvent("ls -la", null)).toEqual({
preventDefault: false,
claimWriteFallback: false,
});
expect(applyTerminalCopyEvent("", { setData: vi.fn() })).toEqual({
preventDefault: false,
claimWriteFallback: false,
});
});
});

describe("primeTerminalCopyInput", () => {
it("selects the Ghostty selection in the hidden textarea so native copy has text", () => {
const input = {
value: "",
selectionStart: 0,
selectionEnd: 0,
select() {
this.selectionStart = 0;
this.selectionEnd = this.value.length;
},
};
primeTerminalCopyInput(input, "git status");
expect(input.value).toBe("git status");
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(10);
clearPrimedTerminalCopyInput(input);
expect(input.value).toBe("");
});
});

describe("isTerminalPasteShortcut", () => {
const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({
ctrlKey: false,
Expand Down
68 changes: 55 additions & 13 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,42 @@ export function isTerminalCopyShortcut(
return isMacPlatform(platform) ? event.metaKey : event.ctrlKey;
}

/**
* Canvas terminals have no DOM selection. Native copy and Electron's Edit
* menu `role: "copy"` both read the focused textarea, so an empty IME field
* writes blankness to the clipboard. Park the Ghostty selection there first.
*/
export function primeTerminalCopyInput(
input: Pick<HTMLTextAreaElement, "value" | "select">,
selection: string,
): void {
input.value = selection;
if (selection.length === 0) return;
input.select();
}

export function clearPrimedTerminalCopyInput(input: Pick<HTMLTextAreaElement, "value">): void {
if (input.value.length === 0) return;
input.value = "";
}

/**
* Only a copy event that actually received the selection may cancel the
* clipboard.writeText fallback. Claiming without clipboardData (Electron's
* menu Copy) used to preventDefault an empty write and skip the fallback,
* which is how Cmd+C copied blankness.
*/
export function applyTerminalCopyEvent(
selection: string,
clipboardData: { setData: (type: string, data: string) => void } | null | undefined,
): { preventDefault: boolean; claimWriteFallback: boolean } {
if (selection.length === 0 || !clipboardData) {
return { preventDefault: false, claimWriteFallback: false };
}
clipboardData.setData("text/plain", selection);
return { preventDefault: true, claimWriteFallback: true };
}

export function isTerminalPasteShortcut(
event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">,
platform = navigator.platform,
Expand Down Expand Up @@ -866,6 +902,7 @@ export class GhosttyTerminalSurface {
}

clearSelection(): void {
clearPrimedTerminalCopyInput(this.input);
this.core.clearSelection();
this.selectionEnd = null;
this.selectionAnchorScreen = null;
Expand Down Expand Up @@ -941,6 +978,8 @@ export class GhosttyTerminalSurface {
// clipboard write against it the same way paste races its read. The
// Shift variant has no native event (Chrome binds Ctrl+Shift+C to
// inspect), so synthesize one with execCommand("copy").
const selection = this.getSelection();
primeTerminalCopyInput(this.input, selection);
if (event.shiftKey) {
event.preventDefault();
document.execCommand("copy");
Expand All @@ -955,12 +994,10 @@ export class GhosttyTerminalSurface {
if (typeof clipboard?.writeText === "function") {
// Defer the write past the default action: the native copy event
// (dispatched synchronously with the default action) claims the
// token first when it fires, and the write covers browsers whose
// shortcut produces no copy event. Skipping a write the native
// event already handled stops a stale resolution from clobbering a
// clipboard the user filled after this copy.
// token first when it actually writes, and the write covers browsers
// whose shortcut produces no copy event. The primed textarea is what
// Electron's edit-menu Copy reads if it runs after this handler.
const token = ++this.copyShortcutToken;
const selection = this.getSelection();
void Promise.resolve().then(() => {
if (this.disposed || this.copyShortcutToken !== token) return;
void clipboard.writeText(selection).then(
Expand Down Expand Up @@ -989,6 +1026,7 @@ export class GhosttyTerminalSurface {
this.suppressedKeyCodes.add(event.code);
return;
}
clearPrimedTerminalCopyInput(this.input);
Comment thread
sethwebster marked this conversation as resolved.
Outdated
if (isTerminalPasteShortcut(event)) {
this.suppressedKeyCodes.add(event.code);
const clipboard = navigator.clipboard;
Expand Down Expand Up @@ -1073,14 +1111,17 @@ export class GhosttyTerminalSurface {
}

private readonly onCopyEvent = (event: ClipboardEvent) => {
if (!this.hasSelection()) return;
event.preventDefault();
event.clipboardData?.setData("text/plain", this.getSelection());
// The native event beat any deferred write; drop the in-flight fallback.
this.copyShortcutToken += 1;
if (this.clearSelectionAfterCopy) {
this.clearSelectionAfterCopy = false;
this.clearSelection();
const selection = this.hasSelection() ? this.getSelection() : this.input.value;
Comment thread
sethwebster marked this conversation as resolved.
const result = applyTerminalCopyEvent(selection, event.clipboardData);
if (result.preventDefault) event.preventDefault();
if (result.claimWriteFallback) {
// The native event actually wrote the selection; drop the in-flight
// writeText so a late resolution cannot clobber a later user copy.
this.copyShortcutToken += 1;
if (this.clearSelectionAfterCopy) {
this.clearSelectionAfterCopy = false;
this.clearSelection();
}
}
};

Expand All @@ -1098,6 +1139,7 @@ export class GhosttyTerminalSurface {
};

private readonly onCompositionStart = () => {
clearPrimedTerminalCopyInput(this.input);
this.clearCompositionInputSuppression();
this.composing = true;
};
Expand Down
Loading