Make file viewer copy actions explicit

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 21:35:48 +00:00
1 parent b130e42d08
commit c347c755ac
2 files changed
+236 -20

No files matched your search

@@ -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<typeof import("@tanstack/react-query")>("@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<T>(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<typeof vi.fn>;
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(
<FileViewerSheet
issueId="issue-1"
state={{
path: "videos/90-days-paperclip/tweet.md",
workspace: "auto",
line: null,
column: null,
projectId: null,
workspaceId: null,
}}
open
/>,
);
});
}
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");
});
});
+90 -20
View File
@@ -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<string>("");
const copyFeedbackTimerRef = useRef<number | null>(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({
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-1 self-start">
<span className="hidden min-w-28 text-right text-xs text-muted-foreground sm:inline" role="status" aria-live="polite">
{copyFeedback}
</span>
{cameFromBrowse ? (
<Button
type="button"
@@ -580,12 +638,18 @@ export function FileViewerSheet({
type="button"
variant="ghost"
size="icon-sm"
onClick={handleCopyPath}
aria-label="Copy path"
title="Copy path"
onClick={handleCopyContent}
aria-label={copiedField === "content" ? "Copied file contents" : "Copy file contents"}
title={copiedField === "content" ? "Copied contents" : "Copy file contents"}
className="h-7 w-7"
>
{copiedField === "path" ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
{copyingField === "content" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : copiedField === "content" ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
) : 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" ? <Check className="h-4 w-4 text-green-500" /> : <Link2 className="h-4 w-4" />}
{copyingField === "link" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : copiedField === "link" ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Link2 className="h-4 w-4" />
)}
</Button>
) : null}
<Button