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
101 changes: 94 additions & 7 deletions apps/web/src/hooks/useCopyToClipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => {
it("reports unavailable clipboard support with structural context", async () => {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
vi.stubGlobal("document", undefined);

const error = await writeTextToClipboard("plan contents", "plan").then(
() => undefined,
Expand All @@ -27,6 +28,86 @@ describe("writeTextToClipboard", () => {
expect((error as Error).message).not.toContain("plan contents");
});

it.each(["success", "denied", "throws"] as const)(
"cleans up the Clipboard API fallback when copying %s",
async (result) => {
const focus = vi.fn();
const restoreFocus = vi.fn();
const appendChild = vi.fn();
const execCommand = vi.fn(() => {
if (result === "throws") throw new Error("copy command failed");
return result === "success";
});
const remove = vi.fn();
const select = vi.fn();
const setAttribute = vi.fn();
const setSelectionRange = vi.fn();
const textarea = {
focus,
remove,
select,
setAttribute,
setSelectionRange,
style: {},
value: "",
};

vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
vi.stubGlobal("document", {
activeElement: { focus: restoreFocus },
body: { appendChild },
createElement: vi.fn(() => textarea),
execCommand,
});

const pendingCopy = writeTextToClipboard("remote command", "command");
// The fallback must run during the original user gesture, before any await.
expect(execCommand).toHaveBeenCalledWith("copy");
if (result === "success") {
await expect(pendingCopy).resolves.toBe(true);
} else {
await expect(pendingCopy).rejects.toBeInstanceOf(ClipboardApiUnavailableError);
}

expect(textarea.value).toBe("remote command");
expect(textarea.style).toMatchObject({ fontSize: "16px" });
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(select).toHaveBeenCalledOnce();
expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length);
expect(remove).toHaveBeenCalledOnce();
expect(restoreFocus).toHaveBeenCalledOnce();
},
);

it("reports unavailable clipboard support when document has no body", async () => {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
vi.stubGlobal("document", { execCommand: vi.fn(), body: null });

const error = await writeTextToClipboard("remote command", "command").then(
() => undefined,
(cause: unknown) => cause,
);

expect(error).toBeInstanceOf(ClipboardApiUnavailableError);
expect(error).toMatchObject({ target: "command" });
});

it("uses the Clipboard API without touching the fallback when it is available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const execCommand = vi.fn();
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });
vi.stubGlobal("document", { execCommand });

await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true);

expect(writeText).toHaveBeenCalledWith("remote command");
expect(execCommand).not.toHaveBeenCalled();
});

it("preserves the exact clipboard failure without exposing copied contents", async () => {
const cause = new Error("browser clipboard failure");
const writeText = vi.fn().mockRejectedValue(cause);
Expand All @@ -47,12 +128,18 @@ describe("writeTextToClipboard", () => {
expect((error as Error).message).not.toContain("secret clipboard contents");
});

it("keeps empty values as a no-op when clipboard support is available", async () => {
const writeText = vi.fn();
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });
it.each([true, false])(
"keeps empty values as a no-op with Clipboard API support: %s",
async (available) => {
const writeText = vi.fn();
vi.stubGlobal("window", {});
const execCommand = vi.fn();
vi.stubGlobal("navigator", available ? { clipboard: { writeText } } : {});
vi.stubGlobal("document", { execCommand });

await expect(writeTextToClipboard("", "plan")).resolves.toBe(false);
expect(writeText).not.toHaveBeenCalled();
});
await expect(writeTextToClipboard("", "plan")).resolves.toBe(false);
expect(writeText).not.toHaveBeenCalled();
expect(execCommand).not.toHaveBeenCalled();
},
);
});
49 changes: 45 additions & 4 deletions apps/web/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,60 @@ export class ClipboardReadError extends Schema.TaggedErrorClass<ClipboardReadErr
}
}

export async function writeTextToClipboard(value: string, target = "text") {
/** Copy fallback for remote web pages served over plain HTTP. */
function writeTextWithExecCommand(value: string): boolean {
if (
typeof window === "undefined" ||
typeof navigator === "undefined" ||
!navigator.clipboard?.writeText
typeof document === "undefined" ||
typeof document.execCommand !== "function" ||
document.body == null
) {
return false;
}

const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.setAttribute("aria-hidden", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
textarea.style.fontSize = "16px";

const previouslyFocused = document.activeElement;
document.body.appendChild(textarea);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
try {
textarea.focus({ preventScroll: true });
textarea.select();
textarea.setSelectionRange(0, value.length);
return document.execCommand("copy");
} catch {
return false;
} finally {
textarea.remove();
const restoreFocus = (previouslyFocused as { focus?: unknown } | null)?.focus;
if (typeof restoreFocus === "function") {
restoreFocus.call(previouslyFocused);
}
}
}

export async function writeTextToClipboard(value: string, target = "text") {
if (typeof window === "undefined") {
throw new ClipboardApiUnavailableError({
target,
});
}

if (!value) return false;

if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
if (writeTextWithExecCommand(value)) return true;
throw new ClipboardApiUnavailableError({
target,
});
}

try {
await navigator.clipboard.writeText(value);
return true;
Expand Down
Loading