From c347c755ac4ad1b93a3560e6bdf7ac91b0beaa6c Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 9 Jun 2026 16:33:16 +0000 Subject: [PATCH] Make file viewer copy actions explicit Co-Authored-By: Paperclip --- .../components/FileViewerSheet.copy.test.tsx | 146 ++++++++++++++++++ ui/src/components/FileViewerSheet.tsx | 110 ++++++++++--- 2 files changed, 236 insertions(+), 20 deletions(-) create mode 100644 ui/src/components/FileViewerSheet.copy.test.tsx diff --git a/ui/src/components/FileViewerSheet.copy.test.tsx b/ui/src/components/FileViewerSheet.copy.test.tsx new file mode 100644 index 00000000000..3ef5f8e6e94 --- /dev/null +++ b/ui/src/components/FileViewerSheet.copy.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedWorkspaceResource, WorkspaceFileContent } from "@paperclipai/shared"; +import { FileViewerSheet } from "./FileViewerSheet"; + +const useQueryMock = vi.fn(); +const viewerMock = { + state: null, + browse: false, + query: null, + folderPath: null, + browseProjectId: null, + browseWorkspaceId: null, + open: vi.fn(), + close: vi.fn(), + backToFiles: vi.fn(), +}; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (options: unknown) => useQueryMock(options), + }; +}); + +vi.mock("@/context/FileViewerContext", () => ({ + useRequiredFileViewer: () => viewerMock, +})); + +vi.mock("@/components/WorkspaceFileBrowser", () => ({ + WorkspaceFileBrowser: () => null, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function ok(data: T) { + return { data, isFetching: false, isError: false, error: null, refetch: vi.fn(async () => ({ data })) }; +} + +const resolvedResource: ResolvedWorkspaceResource = { + kind: "file", + provider: "git_worktree", + title: "tweet.md", + displayPath: "videos/90-days-paperclip/tweet.md", + workspaceLabel: "Isolated workspace", + workspaceKind: "execution_workspace", + workspaceId: "ws-1", + contentType: "text/markdown; charset=utf-8", + byteSize: 42, + previewKind: "text", + capabilities: { preview: true, download: false, listChildren: false }, +}; + +const content: WorkspaceFileContent = { + resource: resolvedResource, + content: { + encoding: "utf8", + data: "hello from the file", + }, +}; + +describe("FileViewerSheet copy actions", () => { + let container: HTMLDivElement; + let root: Root; + let writeText: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + useQueryMock.mockImplementation((options: { queryKey?: readonly unknown[] }) => { + const key = JSON.stringify(options.queryKey ?? []); + if (key.includes('"content"')) return ok(content); + return ok(resolvedResource); + }); + writeText = vi.fn(async () => undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: true, + }); + window.history.pushState({}, "", "/PAP/issues/PAP-10629?file=videos%2F90-days-paperclip%2Ftweet.md"); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + function renderSheet() { + flushSync(() => { + root.render( + , + ); + }); + } + + async function click(label: string) { + const button = document.body.querySelector(`button[aria-label="${label}"]`) as HTMLButtonElement | null; + expect(button).not.toBeNull(); + flushSync(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + it("copies file contents and shows confirmation", async () => { + renderSheet(); + + await click("Copy file contents"); + + expect(writeText).toHaveBeenCalledWith("hello from the file"); + expect(document.body.textContent).toContain("Copied contents"); + }); + + it("copies the current file view link and shows confirmation", async () => { + renderSheet(); + + await click("Copy link to this file view"); + + expect(writeText).toHaveBeenCalledWith(window.location.href); + expect(document.body.textContent).toContain("Copied link"); + }); +}); diff --git a/ui/src/components/FileViewerSheet.tsx b/ui/src/components/FileViewerSheet.tsx index 583a25f3e7c..d5eb97a2faa 100644 --- a/ui/src/components/FileViewerSheet.tsx +++ b/ui/src/components/FileViewerSheet.tsx @@ -101,6 +101,27 @@ function middleTruncatePath(path: string, maxLen = 80): string { return `${head}…${tail}`; } +async function copyTextWithFallback(text: string) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + + try { + textarea.select(); + const success = document.execCommand("copy"); + if (!success) throw new Error("execCommand copy failed"); + } finally { + document.body.removeChild(textarea); + } +} + export function describeDenial(code: string, fallback: string): { title: string; body: string; icon: ReactNode } { const lower = code.toLowerCase(); if (lower.includes("policy") || lower.includes("denied") || lower.includes("sensitive")) { @@ -405,8 +426,11 @@ export function FileViewerSheet({ typeof openProp === "boolean" ? openProp : state !== null || showPromptWhenEmpty || viewer.browse; const [elapsedMs, setElapsedMs] = useState(0); - const [copiedField, setCopiedField] = useState<"path" | "link" | null>(null); + const [copiedField, setCopiedField] = useState<"content" | "link" | null>(null); + const [copyingField, setCopyingField] = useState<"content" | "link" | null>(null); + const [copyFeedback, setCopyFeedback] = useState(""); const [announcement, setAnnouncement] = useState(""); + const copyFeedbackTimerRef = useRef(null); useEffect(() => { if (!state) { @@ -459,6 +483,10 @@ export function FileViewerSheet({ } }, [contentQuery.isError, contentQuery.error]); + useEffect(() => () => { + if (copyFeedbackTimerRef.current) window.clearTimeout(copyFeedbackTimerRef.current); + }, []); + const handleOpenChange = useCallback( (next: boolean) => { if (onOpenChange) { @@ -494,23 +522,50 @@ export function FileViewerSheet({ [viewer], ); - const copyToClipboard = useCallback(async (value: string, field: "path" | "link") => { - try { - await navigator.clipboard.writeText(value); - setCopiedField(field); - window.setTimeout(() => setCopiedField((current) => (current === field ? null : current)), 1500); - } catch { - setAnnouncement("Unable to copy to clipboard."); - } + const showCopyFeedback = useCallback((field: "content" | "link" | null, message: string) => { + setCopiedField(field); + setCopyFeedback(message); + setAnnouncement(message); + if (copyFeedbackTimerRef.current) window.clearTimeout(copyFeedbackTimerRef.current); + copyFeedbackTimerRef.current = window.setTimeout(() => { + setCopiedField((current) => (current === field ? null : current)); + setCopyFeedback(""); + copyFeedbackTimerRef.current = null; + }, 1800); }, []); - const handleCopyPath = useCallback(() => { - if (state) void copyToClipboard(state.path, "path"); - }, [copyToClipboard, state]); + const copyToClipboard = useCallback(async (value: string, field: "content" | "link", message: string) => { + try { + setCopyingField(field); + await copyTextWithFallback(value); + showCopyFeedback(field, message); + } catch { + showCopyFeedback(null, "Copy failed"); + } finally { + setCopyingField((current) => (current === field ? null : current)); + } + }, [showCopyFeedback]); + + const handleCopyContent = useCallback(() => { + if (!state) return; + void (async () => { + let content = contentQuery.data; + if (!content && canPreview) { + const result = await contentQuery.refetch(); + content = result.data; + } + if (!content) { + showCopyFeedback(null, "File contents unavailable"); + return; + } + const message = content.content.encoding === "base64" ? "Copied file data" : "Copied contents"; + await copyToClipboard(content.content.data, "content", message); + })(); + }, [canPreview, contentQuery, copyToClipboard, showCopyFeedback, state]); const handleCopyLink = useCallback(() => { if (typeof window === "undefined") return; - void copyToClipboard(window.location.href, "link"); + void copyToClipboard(window.location.href, "link", "Copied link"); }, [copyToClipboard]); const handleRetry = useCallback(() => { @@ -562,6 +617,9 @@ export function FileViewerSheet({
+ + {copyFeedback} + {cameFromBrowse ? ( ) : null} {state ? ( @@ -594,11 +658,17 @@ export function FileViewerSheet({ variant="ghost" size="icon-sm" onClick={handleCopyLink} - aria-label="Copy link to this file view" - title="Copy link" + aria-label={copiedField === "link" ? "Copied file view link" : "Copy link to this file view"} + title={copiedField === "link" ? "Copied link" : "Copy link"} className="h-7 w-7" > - {copiedField === "link" ? : } + {copyingField === "link" ? ( + + ) : copiedField === "link" ? ( + + ) : ( + + )} ) : null}