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";...@@ -46,6 +46,7 @@ import { useProject, useThread } from "../state/entities";
46import { resolveThreadRouteRef } from "../threadRoutes";46import { resolveThreadRouteRef } from "../threadRoutes";
47import { useClientSettings } from "../hooks/useSettings";47import { useClientSettings } from "../hooks/useSettings";
48import { formatShortTimestamp } from "../timestampFormat";48import { formatShortTimestamp } from "../timestampFormat";
49import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton";
49import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";50import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
50import { DiffStatLabel } from "./chat/DiffStatLabel";51import { DiffStatLabel } from "./chat/DiffStatLabel";
51import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";52import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";
...@@ -920,6 +921,9 @@ export default function DiffPanel({...@@ -920,6 +921,9 @@ export default function DiffPanel({
920 sectionId={reviewSectionId}921 sectionId={reviewSectionId}
921 sectionTitle={reviewSectionTitle}922 sectionTitle={reviewSectionTitle}
922 composerDraftTarget={composerDraftTarget}923 composerDraftTarget={composerDraftTarget}
924 renderHeaderFilenameSuffix={(fileDiff) => (
925 <DiffFilePathCopyButton filePath={resolveFileDiffPath(fileDiff)} />
926 )}
923 renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {927 renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
924 const filePath = resolveFileDiffPath(fileDiff);928 const filePath = resolveFileDiffPath(fileDiff);
925 return (929 return (
apps/web/src/components/chat/MessageCopyButton.tsx+8-36
...@@ -3,41 +3,13 @@ import { CopyIcon, CheckIcon } from "lucide-react";...@@ -3,41 +3,13 @@ import { CopyIcon, CheckIcon } from "lucide-react";
3import { Button } from "../ui/button";3import { Button } from "../ui/button";
4import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";4import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
6import { anchoredToastManager } from "../ui/toast";6import {
7 ANCHORED_COPY_TOAST_TIMEOUT_MS,
8 showAnchoredCopyErrorToast,
9 showAnchoredCopySuccessToast,
10} from "../ui/anchoredCopyToast";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";11import { 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
41export const MessageCopyButton = memo(function MessageCopyButton({13export const MessageCopyButton = memo(function MessageCopyButton({
42 text,14 text,
43 size = "xs",15 size = "xs",
...@@ -51,9 +23,9 @@ export const MessageCopyButton = memo(function MessageCopyButton({...@@ -51,9 +23,9 @@ export const MessageCopyButton = memo(function MessageCopyButton({
51}) {23}) {
52 const ref = useRef<HTMLButtonElement>(null);24 const ref = useRef<HTMLButtonElement>(null);
53 const { copyToClipboard, isCopied } = useCopyToClipboard<void>({25 const { copyToClipboard, isCopied } = useCopyToClipboard<void>({
54 onCopy: () => onCopy(ref),26 onCopy: () => showAnchoredCopySuccessToast(ref),
55 onError: (error: Error) => onCopyError(ref, error),27 onError: (error: Error) => showAnchoredCopyErrorToast(ref, error),
56 timeout: ANCHORED_TOAST_TIMEOUT_MS,28 timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS,
57 });29 });
5830
59 return (31 return (
apps/web/src/components/diffs/AnnotatableCodeView.test.tsx+1
...@@ -46,6 +46,7 @@ describe("AnnotatableCodeView", () => {...@@ -46,6 +46,7 @@ describe("AnnotatableCodeView", () => {
46 composerDraftTarget={"draft-test" as never}46 composerDraftTarget={"draft-test" as never}
47 options={{}}47 options={{}}
48 renderHeaderPrefix={() => null}48 renderHeaderPrefix={() => null}
49 renderHeaderFilenameSuffix={() => null}
49 />,50 />,
50 );51 );
5152
apps/web/src/components/diffs/AnnotatableCodeView.tsx+5
...@@ -86,6 +86,7 @@ interface AnnotatableCodeViewProps {...@@ -86,6 +86,7 @@ interface AnnotatableCodeViewProps {
86 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;86 options: StyledDiffCodeViewOptions<DiffCommentAnnotationGroup>;
87 viewerRef?: Ref<AnnotatableCodeViewHandle>;87 viewerRef?: Ref<AnnotatableCodeViewHandle>;
88 className?: string;88 className?: string;
89 renderHeaderFilenameSuffix: (fileDiff: FileDiffMetadata) => ReactNode;
89 renderHeaderPrefix: (90 renderHeaderPrefix: (
90 fileDiff: FileDiffMetadata,91 fileDiff: FileDiffMetadata,
91 fileKey: string,92 fileKey: string,
...@@ -106,6 +107,7 @@ export function AnnotatableCodeView({...@@ -106,6 +107,7 @@ export function AnnotatableCodeView({
106 options,107 options,
107 viewerRef,108 viewerRef,
108 className,109 className,
110 renderHeaderFilenameSuffix,
109 renderHeaderPrefix,111 renderHeaderPrefix,
110}: AnnotatableCodeViewProps) {112}: AnnotatableCodeViewProps) {
111 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);113 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
...@@ -252,6 +254,9 @@ export function AnnotatableCodeView({...@@ -252,6 +254,9 @@ export function AnnotatableCodeView({
252 enableLineSelection: !hasOpenComment,254 enableLineSelection: !hasOpenComment,
253 onGutterUtilityClick: beginComment,255 onGutterUtilityClick: beginComment,
254 }}256 }}
257 renderHeaderFilenameSuffix={(item) =>
258 item.type === "diff" ? renderHeaderFilenameSuffix(item.fileDiff) : null
259 }
255 renderHeaderPrefix={(item) =>260 renderHeaderPrefix={(item) =>
256 item.type === "diff"261 item.type === "diff"
257 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)262 ? 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}