mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
Make workspace metadata copyable
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d716343c25
commit
dace1bc58e
3 files changed
+116
-12
No files matched your search
@@ -1,21 +1,34 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CopyTextProps {
|
||||
text: string;
|
||||
/** What to display. Defaults to `text`. */
|
||||
children?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
title?: string;
|
||||
/** Tooltip message shown after copying. Default: "Copied!" */
|
||||
copiedLabel?: string;
|
||||
}
|
||||
|
||||
export function CopyText({ text, children, className, copiedLabel = "Copied!" }: CopyTextProps) {
|
||||
export function CopyText({
|
||||
text,
|
||||
children,
|
||||
containerClassName,
|
||||
className,
|
||||
ariaLabel,
|
||||
title,
|
||||
copiedLabel = "Copied!",
|
||||
}: CopyTextProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [label, setLabel] = useState(copiedLabel);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -45,10 +58,12 @@ export function CopyText({ text, children, className, copiedLabel = "Copied!" }:
|
||||
}, [copiedLabel, text]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<span className={cn("relative inline-flex", containerClassName)}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
className={cn(
|
||||
"cursor-copy hover:text-foreground transition-colors",
|
||||
className,
|
||||
|
||||
@@ -16,10 +16,6 @@ vi.mock("./IssuesQuicklook", () => ({
|
||||
IssuesQuicklook: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("./CopyText", () => ({
|
||||
CopyText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -89,10 +85,20 @@ function createSummary(overrides: Partial<ProjectWorkspaceSummary> = {}): Projec
|
||||
|
||||
describe("ProjectWorkspaceSummaryCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
let writeClipboard: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
writeClipboard = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: writeClipboard },
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -194,6 +200,61 @@ describe("ProjectWorkspaceSummaryCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("copies branch and path from both text and icon controls with feedback", async () => {
|
||||
const root = createRoot(container);
|
||||
const summary = createSummary({
|
||||
branchName: "PAP-1552-workspace-polish",
|
||||
cwd: "/Users/dotta/paperclip/.worktrees/PAP-1552-workspace-polish",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ProjectWorkspaceSummaryCard
|
||||
projectRef="paperclip-app"
|
||||
summary={summary}
|
||||
runtimeActionKey={null}
|
||||
runtimeActionPending={false}
|
||||
onRuntimeAction={() => {}}
|
||||
onCloseWorkspace={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const branchTextButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent === summary.branchName);
|
||||
const pathTextButton = container.querySelector(`button[title="${summary.cwd}"]`);
|
||||
const branchIconButton = container.querySelector('button[aria-label="Copy branch"]');
|
||||
const pathIconButton = container.querySelector('button[aria-label="Copy path"]');
|
||||
|
||||
expect(branchTextButton).not.toBeNull();
|
||||
expect(pathTextButton).not.toBeNull();
|
||||
expect(branchIconButton).not.toBeNull();
|
||||
expect(pathIconButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
branchTextButton!.click();
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenLastCalledWith(summary.branchName);
|
||||
expect(branchTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||
|
||||
await act(async () => {
|
||||
pathTextButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenLastCalledWith(summary.cwd);
|
||||
expect(pathTextButton?.nextElementSibling?.className).toContain("opacity-100");
|
||||
|
||||
await act(async () => {
|
||||
branchIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
pathIconButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(writeClipboard).toHaveBeenCalledWith(summary.branchName);
|
||||
expect(writeClipboard).toHaveBeenCalledWith(summary.cwd);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("colors live service urls green", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -148,9 +148,26 @@ export function ProjectWorkspaceSummaryCard({
|
||||
{summary.branchName ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Branch</div>
|
||||
<div className="break-all font-mono text-xs text-foreground">{summary.branchName}</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CopyText
|
||||
text={summary.branchName}
|
||||
containerClassName="min-w-0"
|
||||
className="min-w-0 break-all text-left font-mono text-xs text-foreground"
|
||||
copiedLabel="Branch copied"
|
||||
>
|
||||
{summary.branchName}
|
||||
</CopyText>
|
||||
<CopyText
|
||||
text={summary.branchName}
|
||||
ariaLabel="Copy branch"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
copiedLabel="Branch copied"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -161,10 +178,21 @@ export function ProjectWorkspaceSummaryCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Path</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="min-w-0 break-all font-mono text-xs text-foreground" title={summary.cwd}>
|
||||
<CopyText
|
||||
text={summary.cwd}
|
||||
title={summary.cwd}
|
||||
containerClassName="min-w-0"
|
||||
className="min-w-0 break-all text-left font-mono text-xs text-foreground"
|
||||
copiedLabel="Path copied"
|
||||
>
|
||||
{truncatePath(summary.cwd)}
|
||||
</span>
|
||||
<CopyText text={summary.cwd} className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Path copied">
|
||||
</CopyText>
|
||||
<CopyText
|
||||
text={summary.cwd}
|
||||
ariaLabel="Copy path"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
copiedLabel="Path copied"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</CopyText>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user