authorgravatar for hello@ipanasenko.meIllia Panasenko <hello@ipanasenko.me> 2026-09-02 22:52:50+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 16:52:50-04:00
logb57726ca842624b713572529109ac49b17c93fb3
treec00462bfe2f0cd8e8acce8be960fb5f22b17699c
parent8d5b712de3cbd84118327808c403756e8894014a
signature Signed by PGP key B5690EEEBB952194

feat(web): add copy path button to diff headers (#2403)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

6 files changed, 92 insertions(+), 36 deletions(-)

apps/web/src/components/DiffFilePathCopyButton.tsx created+41
......@@ -0,0 +1,41 @@
1import { CheckIcon, CopyIcon } from "lucide-react";
2import { useRef } from "react";
3import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
4import {
5 ANCHORED_COPY_TOAST_TIMEOUT_MS,
6 showAnchoredCopyErrorToast,
7 showAnchoredCopySuccessToast,
8} from "./ui/anchoredCopyToast";
9import { Button } from "./ui/button";
10import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
11
12export function DiffFilePathCopyButton({ filePath }: { filePath: string }) {
13 const ref = useRef<HTMLButtonElement>(null);
14 const { copyToClipboard, isCopied } = useCopyToClipboard<void>({
15 onCopy: () => showAnchoredCopySuccessToast(ref),
16 onError: (error) => showAnchoredCopyErrorToast(ref, error),
17 timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS,
18 });
19
20 return (
21 <Tooltip>
22 <TooltipTrigger
23 render={
24 <Button
25 ref={ref}
26 size="icon-micro"
27 variant="ghost"
28 className="text-muted-foreground [:hover,[data-pressed]]:bg-transparent"
29 aria-label="Copy file path"
30 onClick={() => copyToClipboard(filePath, undefined)}
31 />
32 }
33 >
34 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
35 </TooltipTrigger>
36 <TooltipPopup>
37 <p>{isCopied ? "Copied" : "Copy path"}</p>
38 </TooltipPopup>
39 </Tooltip>
40 );
41}
apps/web/src/components/DiffPanel.tsx+4
......@@ -46,6 +46,7 @@ import { useProject, useThread } from "../state/entities";
4646import { resolveThreadRouteRef } from "../threadRoutes";
4747import { useClientSettings } from "../hooks/useSettings";
4848import { formatShortTimestamp } from "../timestampFormat";
49import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton";
4950import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
5051import { DiffStatLabel } from "./chat/DiffStatLabel";
5152import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";
......@@ -920,6 +921,9 @@ export default function DiffPanel({
920921 sectionId={reviewSectionId}
921922 sectionTitle={reviewSectionTitle}
922923 composerDraftTarget={composerDraftTarget}
924 renderHeaderFilenameSuffix={(fileDiff) => (
925 <DiffFilePathCopyButton filePath={resolveFileDiffPath(fileDiff)} />
926 )}
923927 renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
924928 const filePath = resolveFileDiffPath(fileDiff);
925929 return (
apps/web/src/components/chat/MessageCopyButton.tsx+8-36
......@@ -3,41 +3,13 @@ import { CopyIcon, CheckIcon } from "lucide-react";
33import { Button } from "../ui/button";
44import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
55import { cn } from "~/lib/utils";
6import { anchoredToastManager } from "../ui/toast";
6import {
7 ANCHORED_COPY_TOAST_TIMEOUT_MS,
8 showAnchoredCopyErrorToast,
9 showAnchoredCopySuccessToast,
10} from "../ui/anchoredCopyToast";
711import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
812
9const ANCHORED_TOAST_TIMEOUT_MS = 1000;
10const onCopy = (ref: React.RefObject<HTMLButtonElement | null>) => {
11 if (ref.current) {
12 anchoredToastManager.add({
13 data: {
14 tooltipStyle: true,
15 },
16 positionerProps: {
17 anchor: ref.current,
18 },
19 timeout: ANCHORED_TOAST_TIMEOUT_MS,
20 title: "Copied!",
21 });
22 }
23};
24
25const onCopyError = (ref: React.RefObject<HTMLButtonElement | null>, error: Error) => {
26 if (ref.current) {
27 anchoredToastManager.add({
28 data: {
29 tooltipStyle: true,
30 },
31 positionerProps: {
32 anchor: ref.current,
33 },
34 timeout: ANCHORED_TOAST_TIMEOUT_MS,
35 title: "Failed to copy",
36 description: error.message,
37 });
38 }
39};
40
4113export const MessageCopyButton = memo(function MessageCopyButton({
4214 text,
4315 size = "xs",
......@@ -51,9 +23,9 @@ export const MessageCopyButton = memo(function MessageCopyButton({
5123}) {
5224 const ref = useRef<HTMLButtonElement>(null);
5325 const { copyToClipboard, isCopied } = useCopyToClipboard<void>({
54 onCopy: () => onCopy(ref),
55 onError: (error: Error) => onCopyError(ref, error),
56 timeout: ANCHORED_TOAST_TIMEOUT_MS,
26 onCopy: () => showAnchoredCopySuccessToast(ref),
27 onError: (error: Error) => showAnchoredCopyErrorToast(ref, error),
28 timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS,
5729 });
5830
5931 return (
apps/web/src/components/diffs/AnnotatableCodeView.test.tsx+1
......@@ -46,6 +46,7 @@ describe("AnnotatableCodeView", () => {
4646 composerDraftTarget={"draft-test" as never}
4747 options={{}}
4848 renderHeaderPrefix={() => null}
49 renderHeaderFilenameSuffix={() => null}
4950 />,
5051 );
5152
apps/web/src/components/diffs/AnnotatableCodeView.tsx+5
......@@ -86,6 +86,7 @@ interface AnnotatableCodeViewProps {
8686 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;
8787 viewerRef?: Ref<AnnotatableCodeViewHandle>;
8888 className?: string;
89 renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode;
8990 renderHeaderPrefix: (
9091 fileDiff: FileDiffMetadata,
9192 fileKey: string,
......@@ -106,6 +107,7 @@ export function AnnotatableCodeView({
106107 options,
107108 viewerRef,
108109 className,
110 renderHeaderFilenameSuffix,
109111 renderHeaderPrefix,
110112}: AnnotatableCodeViewProps) {
111113 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
......@@ -252,6 +254,9 @@ export function AnnotatableCodeView({
252254 enableLineSelection: !hasOpenComment,
253255 onGutterUtilityClick: beginComment,
254256 }}
257 renderHeaderFilenameSuffix={(item) =>
258 item.type === "diff" ? renderHeaderFilenameSuffix(item.fileDiff) : null
259 }
255260 renderHeaderPrefix={(item) =>
256261 item.type === "diff"
257262 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)
apps/web/src/components/ui/anchoredCopyToast.ts created+33
......@@ -0,0 +1,33 @@
1import type { RefObject } from "react";
2import { anchoredToastManager } from "./toast";
3
4export const ANCHORED_COPY_TOAST_TIMEOUT_MS = 1000;
5
6export function showAnchoredCopySuccessToast(ref: RefObject<HTMLButtonElement | null>) {
7 if (!ref.current) return;
8 anchoredToastManager.add({
9 data: {
10 tooltipStyle: true,
11 },
12 positionerProps: {
13 anchor: ref.current,
14 },
15 timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS,
16 title: "Copied!",
17 });
18}
19
20export function showAnchoredCopyErrorToast(ref: RefObject<HTMLButtonElement | null>, error: Error) {
21 if (!ref.current) return;
22 anchoredToastManager.add({
23 data: {
24 tooltipStyle: true,
25 },
26 positionerProps: {
27 anchor: ref.current,
28 },
29 timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS,
30 title: "Failed to copy",
31 description: error.message,
32 });
33}