diffhub 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
- package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +17 -16
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +17 -16
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +6 -5
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/api/comments/route.js +2 -2
- package/.next/standalone/apps/web/.next/server/app/api/comments/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/diff/route.js +4 -3
- package/.next/standalone/apps/web/.next/server/app/api/diff/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/discard/route.js +3 -3
- package/.next/standalone/apps/web/.next/server/app/api/discard/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/file/route.js +3 -3
- package/.next/standalone/apps/web/.next/server/app/api/file/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/files/route.js +3 -3
- package/.next/standalone/apps/web/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/open/route.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/api/open/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/favicon.ico/route.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/favicon.ico/route.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.rsc +16 -15
- package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +16 -15
- package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +6 -5
- package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/page/react-loadable-manifest.json +1 -2
- package/.next/standalone/apps/web/.next/server/app/page.js +2 -2
- package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/[externals]_shiki_wasm_0~fgmgp._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__01.zj5h._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05ejtyr._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0e2dp4h._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0egk6ui._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0i6i-~n._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0sv4hr9._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0tbrp5x._.js +13 -0
- package/.next/standalone/apps/web/.next/server/chunks/_0r24f4c._.js +69 -0
- package/.next/standalone/apps/web/.next/server/chunks/node_modules_@pierre_theme_dist_pierre-dark_mjs_0ojo3_n._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/node_modules_@pierre_theme_dist_pierre-light_mjs_0pw9wwg._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/0fuv_@swc_helpers_cjs__interop_require_default_cjs_0ghzfn9._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0bit3~x._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__06b81~v._.js → [root-of-the-server]__0giwc4b._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{apps_web_0b_ykcu._.js → _0m1v4-9._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0oc3qg_._.js +3 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{apps_web_08kf15u._.js → apps_web_0758ax4._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
- package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/01f1ms~jsc5vh.js +138 -0
- package/.next/standalone/apps/web/.next/static/chunks/0c7yx0ttmhb4s.js +63 -0
- package/.next/standalone/apps/web/.next/static/chunks/0ogqv_xj2r0c6.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0qp8t.3t~v6um.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/{0syypqto3~pe_.js → 0y0o261rjun_2.js} +8 -8
- package/.next/standalone/apps/web/.next/static/chunks/0y5z3t-z1c8ks.js.map +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/130i667qy-j80.css +3 -0
- package/.next/standalone/apps/web/.next/static/chunks/15uwrard~z-l5.js +16 -0
- package/.next/standalone/apps/web/.next/static/chunks/17b.xoi.b6rcl.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/turbopack-0_n_4n~_4no2a.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/turbopack-worker-0sjn--fhq~1cg.js +1 -0
- package/.next/standalone/apps/web/.next/static/media/diffs.worker.09unk0quktc_5.ts +1 -0
- package/.next/standalone/apps/web/package.json +4 -4
- package/README.md +10 -3
- package/bin/diffhub.mjs +25 -1
- package/package.json +4 -4
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05vwx85._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__09vmjc2._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0etmu4u._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0g-_a7n._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0gf_xk6._.js +0 -13
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0mshgfw._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0qixima._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0f~hmsk._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0khyzju._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0l2mjim._.js +0 -70
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0kwaklj._.js +0 -3
- package/.next/standalone/apps/web/.next/static/chunks/0-ci0c9di0qo8.js +0 -16
- package/.next/standalone/apps/web/.next/static/chunks/0.ae0sud8tm7k.js +0 -204
- package/.next/standalone/apps/web/.next/static/chunks/07ndp5x2cvqo..css +0 -3
- package/.next/standalone/apps/web/.next/static/chunks/0di~ntk7iivm4.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0pklg0nmdvay8.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0up9_7hiwl_dt.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0~984a88e4rp9.js +0 -204
- package/.next/standalone/apps/web/.next/static/chunks/13y8355z8m13w.js +0 -1
- package/.next/standalone/apps/web/AGENTS.md +0 -60
- package/.next/standalone/apps/web/CHANGELOG.md +0 -7
- package/.next/standalone/apps/web/CLAUDE.md +0 -1
- package/.next/standalone/apps/web/README.md +0 -78
- package/.next/standalone/apps/web/app/api/comments/route.ts +0 -20
- package/.next/standalone/apps/web/app/api/diff/route.ts +0 -15
- package/.next/standalone/apps/web/app/api/discard/route.ts +0 -15
- package/.next/standalone/apps/web/app/api/file/route.ts +0 -17
- package/.next/standalone/apps/web/app/api/files/route.ts +0 -14
- package/.next/standalone/apps/web/app/api/open/route.ts +0 -64
- package/.next/standalone/apps/web/app/favicon.ico +0 -0
- package/.next/standalone/apps/web/app/globals.css +0 -214
- package/.next/standalone/apps/web/app/layout.tsx +0 -52
- package/.next/standalone/apps/web/app/page.tsx +0 -6
- package/.next/standalone/apps/web/bin/diffhub.mjs +0 -147
- package/.next/standalone/apps/web/components/ContextMenu.tsx +0 -161
- package/.next/standalone/apps/web/components/DiffApp.tsx +0 -419
- package/.next/standalone/apps/web/components/DiffViewer.tsx +0 -565
- package/.next/standalone/apps/web/components/FileDiffHeader.tsx +0 -119
- package/.next/standalone/apps/web/components/FileList.tsx +0 -455
- package/.next/standalone/apps/web/components/KeyboardShortcutsDialog.tsx +0 -79
- package/.next/standalone/apps/web/components/SidebarHelpMenu.tsx +0 -86
- package/.next/standalone/apps/web/components/StatusBar.tsx +0 -212
- package/.next/standalone/apps/web/components/icons/file-status-icons.tsx +0 -48
- package/.next/standalone/apps/web/components/theme-provider.tsx +0 -12
- package/.next/standalone/apps/web/components/ui/button.tsx +0 -90
- package/.next/standalone/apps/web/components/ui/empty.tsx +0 -82
- package/.next/standalone/apps/web/components/ui/input.tsx +0 -18
- package/.next/standalone/apps/web/components/ui/kbd.tsx +0 -14
- package/.next/standalone/apps/web/components/ui/separator.tsx +0 -23
- package/.next/standalone/apps/web/components/ui/sheet.tsx +0 -109
- package/.next/standalone/apps/web/components/ui/sidebar.tsx +0 -700
- package/.next/standalone/apps/web/components/ui/skeleton.tsx +0 -9
- package/.next/standalone/apps/web/components/ui/toggle.tsx +0 -35
- package/.next/standalone/apps/web/components/ui/tooltip.tsx +0 -52
- package/.next/standalone/apps/web/components.json +0 -27
- package/.next/standalone/apps/web/lib/comments.ts +0 -52
- package/.next/standalone/apps/web/lib/export-comments.ts +0 -13
- package/.next/standalone/apps/web/lib/git.ts +0 -201
- package/.next/standalone/apps/web/lib/use-mobile.ts +0 -19
- package/.next/standalone/apps/web/lib/utils.ts +0 -5
- package/.next/standalone/apps/web/next.config.ts +0 -19
- package/.next/standalone/apps/web/oxfmt.config.ts +0 -6
- package/.next/standalone/apps/web/oxlint.config.ts +0 -13
- package/.next/standalone/apps/web/postcss.config.mjs +0 -7
- package/.next/standalone/apps/web/public/file.svg +0 -1
- package/.next/standalone/apps/web/public/glide-variable-italic.woff2 +0 -0
- package/.next/standalone/apps/web/public/glide-variable.woff2 +0 -0
- package/.next/standalone/apps/web/public/globe.svg +0 -1
- package/.next/standalone/apps/web/public/next.svg +0 -1
- package/.next/standalone/apps/web/public/operator-mono-book-italic.woff2 +0 -0
- package/.next/standalone/apps/web/public/operator-mono-book.woff2 +0 -0
- package/.next/standalone/apps/web/public/operator-mono-medium-italic.woff2 +0 -0
- package/.next/standalone/apps/web/public/operator-mono-medium.woff2 +0 -0
- package/.next/standalone/apps/web/public/vercel.svg +0 -1
- package/.next/standalone/apps/web/public/window.svg +0 -1
- package/.next/standalone/apps/web/tsconfig.json +0 -34
- /package/.next/standalone/apps/web/.next/static/{ZhI_-YaFho-fQoajjgwSH → C1Ggd_ITpnb7yBHrIOODV}/_buildManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{ZhI_-YaFho-fQoajjgwSH → C1Ggd_ITpnb7yBHrIOODV}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{ZhI_-YaFho-fQoajjgwSH → C1Ggd_ITpnb7yBHrIOODV}/_ssgManifest.js +0 -0
|
@@ -1,565 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import dynamic from "next/dynamic";
|
|
4
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
-
import { useTheme } from "next-themes";
|
|
6
|
-
import type { DiffLineAnnotation, AnnotationSide, FileDiffMetadata } from "@pierre/diffs";
|
|
7
|
-
import type { Comment, CommentTag } from "@/lib/comments";
|
|
8
|
-
import type { DiffFileStat } from "@/lib/git";
|
|
9
|
-
import { FileDiffHeader } from "./FileDiffHeader";
|
|
10
|
-
import { cn } from "@/lib/utils";
|
|
11
|
-
import { BranchIcon, CopySimpleIcon, TrashIcon, CheckIcon } from "blode-icons-react";
|
|
12
|
-
import { Button } from "@/components/ui/button";
|
|
13
|
-
import {
|
|
14
|
-
Empty,
|
|
15
|
-
EmptyHeader,
|
|
16
|
-
EmptyMedia,
|
|
17
|
-
EmptyTitle,
|
|
18
|
-
EmptyDescription,
|
|
19
|
-
EmptyContent,
|
|
20
|
-
} from "@/components/ui/empty";
|
|
21
|
-
import { Kbd } from "@/components/ui/kbd";
|
|
22
|
-
|
|
23
|
-
// ── Shared constants ─────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
const TAG_META: Partial<Record<CommentTag, { text: string; border: string }>> = {
|
|
26
|
-
"[must-fix]": { border: "border-l-destructive", text: "text-destructive" },
|
|
27
|
-
"[nit]": { border: "border-l-muted-foreground/40", text: "text-muted-foreground" },
|
|
28
|
-
"[question]": { border: "border-l-diff-purple", text: "text-diff-purple" },
|
|
29
|
-
"[suggestion]": { border: "border-l-diff-green", text: "text-diff-green" },
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
const formatRelativeTime = (iso: string): string => {
|
|
35
|
-
const diffMs = Date.now() - new Date(iso).getTime();
|
|
36
|
-
const diffSec = Math.floor(diffMs / 1000);
|
|
37
|
-
if (diffSec < 60) {
|
|
38
|
-
return "just now";
|
|
39
|
-
}
|
|
40
|
-
const diffMin = Math.floor(diffSec / 60);
|
|
41
|
-
if (diffMin < 60) {
|
|
42
|
-
return `${diffMin}m ago`;
|
|
43
|
-
}
|
|
44
|
-
const diffHr = Math.floor(diffMin / 60);
|
|
45
|
-
if (diffHr < 24) {
|
|
46
|
-
return `${diffHr}h ago`;
|
|
47
|
-
}
|
|
48
|
-
const diffDay = Math.floor(diffHr / 24);
|
|
49
|
-
return `${diffDay}d ago`;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
type AnnotationData = { type: "comment"; comment: Comment } | { type: "input"; file: string };
|
|
53
|
-
|
|
54
|
-
const DiffSkeleton = () => (
|
|
55
|
-
<div className="animate-pulse">
|
|
56
|
-
{/* Simulated file header */}
|
|
57
|
-
<div className="h-9 border-b border-border bg-card" />
|
|
58
|
-
{/* Simulated diff lines */}
|
|
59
|
-
<div>
|
|
60
|
-
<div className="flex h-[22px] items-center gap-3 px-3 bg-diff-green/5">
|
|
61
|
-
<div className="h-2 w-6 shrink-0 rounded bg-diff-green/20" />
|
|
62
|
-
<div className="h-2 rounded bg-diff-green/15" style={{ width: "67%" }} />
|
|
63
|
-
</div>
|
|
64
|
-
<div className="flex h-[22px] items-center gap-3 px-3">
|
|
65
|
-
<div className="h-2 w-6 shrink-0 rounded bg-muted" />
|
|
66
|
-
<div className="h-2 rounded bg-muted" style={{ width: "82%" }} />
|
|
67
|
-
</div>
|
|
68
|
-
<div className="flex h-[22px] items-center gap-3 px-3 bg-destructive/5">
|
|
69
|
-
<div className="h-2 w-6 shrink-0 rounded bg-destructive/20" />
|
|
70
|
-
<div className="h-2 rounded bg-destructive/15" style={{ width: "54%" }} />
|
|
71
|
-
</div>
|
|
72
|
-
<div className="flex h-[22px] items-center gap-3 px-3">
|
|
73
|
-
<div className="h-2 w-6 shrink-0 rounded bg-muted" />
|
|
74
|
-
<div className="h-2 rounded bg-muted" style={{ width: "78%" }} />
|
|
75
|
-
</div>
|
|
76
|
-
<div className="flex h-[22px] items-center gap-3 px-3 bg-diff-green/5">
|
|
77
|
-
<div className="h-2 w-6 shrink-0 rounded bg-diff-green/20" />
|
|
78
|
-
<div className="h-2 rounded bg-diff-green/15" style={{ width: "91%" }} />
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
81
|
-
</div>
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
const PatchDiff = dynamic(
|
|
85
|
-
// oxlint-disable-next-line promise/prefer-await-to-then
|
|
86
|
-
() => import("@pierre/diffs/react").then((m) => ({ default: m.PatchDiff })),
|
|
87
|
-
{ loading: () => <DiffSkeleton />, ssr: false },
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
const FileDiffViewer = dynamic(
|
|
91
|
-
// oxlint-disable-next-line promise/prefer-await-to-then
|
|
92
|
-
() => import("@pierre/diffs/react").then((m) => ({ default: m.FileDiff })),
|
|
93
|
-
{ loading: () => <DiffSkeleton />, ssr: false },
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
interface InlineCommentInputProps {
|
|
97
|
-
onSubmit: (body: string, tag: CommentTag) => void;
|
|
98
|
-
onCancel: () => void;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const InlineCommentInput = ({ onSubmit, onCancel }: InlineCommentInputProps) => {
|
|
102
|
-
const [body, setBody] = useState("");
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<div className="my-1 mx-4 rounded-md border border-border bg-background p-3 shadow-lg dark:shadow-none">
|
|
106
|
-
<textarea
|
|
107
|
-
autoFocus
|
|
108
|
-
value={body}
|
|
109
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
110
|
-
onChange={(e) => setBody(e.target.value)}
|
|
111
|
-
placeholder="Add a comment for the AI"
|
|
112
|
-
rows={3}
|
|
113
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
114
|
-
onKeyDown={(e) => {
|
|
115
|
-
if (((e.key === "Enter" && e.metaKey) || e.key === "Return") && body.trim()) {
|
|
116
|
-
onSubmit(body.trim(), "");
|
|
117
|
-
}
|
|
118
|
-
if (e.key === "Escape") {
|
|
119
|
-
onCancel();
|
|
120
|
-
}
|
|
121
|
-
}}
|
|
122
|
-
className="w-full resize-none rounded border-0 bg-transparent px-0 py-0 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
|
123
|
-
/>
|
|
124
|
-
<div className="mt-2 flex justify-end gap-2">
|
|
125
|
-
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
126
|
-
Cancel
|
|
127
|
-
</Button>
|
|
128
|
-
<Button
|
|
129
|
-
size="sm"
|
|
130
|
-
disabled={!body.trim()}
|
|
131
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
132
|
-
onClick={() => body.trim() && onSubmit(body.trim(), "")}
|
|
133
|
-
>
|
|
134
|
-
Comment ↵
|
|
135
|
-
</Button>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
);
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const CommentDisplay = ({ comment, onDelete }: { comment: Comment; onDelete: () => void }) => {
|
|
142
|
-
const [copied, setCopied] = useState(false);
|
|
143
|
-
|
|
144
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
145
|
-
const handleCopy = () => {
|
|
146
|
-
const text = comment.tag ? `${comment.tag} ${comment.body}` : comment.body;
|
|
147
|
-
// oxlint-disable-next-line promise/prefer-await-to-then
|
|
148
|
-
navigator.clipboard.writeText(text).catch(() => {
|
|
149
|
-
// empty
|
|
150
|
-
});
|
|
151
|
-
setCopied(true);
|
|
152
|
-
setTimeout(() => setCopied(false), 1500);
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const borderAccent = comment.tag
|
|
156
|
-
? (TAG_META[comment.tag]?.border ?? "border-l-ring/40")
|
|
157
|
-
: "border-l-ring/40";
|
|
158
|
-
|
|
159
|
-
return (
|
|
160
|
-
<div
|
|
161
|
-
className={cn(
|
|
162
|
-
"group my-1 mx-4 rounded-md border border-border bg-card shadow-sm dark:shadow-none overflow-hidden border-l-2",
|
|
163
|
-
borderAccent,
|
|
164
|
-
)}
|
|
165
|
-
>
|
|
166
|
-
{/* Body row */}
|
|
167
|
-
<div className="flex items-start gap-2 px-3 py-2.5">
|
|
168
|
-
{comment.tag && (
|
|
169
|
-
<span
|
|
170
|
-
className={cn(
|
|
171
|
-
"shrink-0 mt-0.5 text-[11px]",
|
|
172
|
-
TAG_META[comment.tag]?.text ?? "text-muted-foreground",
|
|
173
|
-
)}
|
|
174
|
-
>
|
|
175
|
-
{comment.tag}
|
|
176
|
-
</span>
|
|
177
|
-
)}
|
|
178
|
-
<p className="flex-1 text-sm text-foreground leading-relaxed">{comment.body}</p>
|
|
179
|
-
{/* Action buttons — hover-revealed */}
|
|
180
|
-
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
|
181
|
-
<button
|
|
182
|
-
type="button"
|
|
183
|
-
onClick={handleCopy}
|
|
184
|
-
title={copied ? "Copied!" : "Copy comment"}
|
|
185
|
-
className={cn(
|
|
186
|
-
"rounded p-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50",
|
|
187
|
-
copied
|
|
188
|
-
? "text-diff-green"
|
|
189
|
-
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
|
190
|
-
)}
|
|
191
|
-
>
|
|
192
|
-
{copied ? <CheckIcon size={12} /> : <CopySimpleIcon size={12} />}
|
|
193
|
-
</button>
|
|
194
|
-
<button
|
|
195
|
-
type="button"
|
|
196
|
-
onClick={onDelete}
|
|
197
|
-
title="Delete comment"
|
|
198
|
-
aria-label="Delete comment"
|
|
199
|
-
className="rounded p-1 text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
|
200
|
-
>
|
|
201
|
-
<TrashIcon size={12} />
|
|
202
|
-
</button>
|
|
203
|
-
</div>
|
|
204
|
-
</div>
|
|
205
|
-
{/* Footer strip */}
|
|
206
|
-
<div className="border-t border-border/40 px-3 py-1 flex items-center gap-2 text-[10px] text-muted-foreground/60">
|
|
207
|
-
<span>L{comment.lineNumber}</span>
|
|
208
|
-
{comment.createdAt && (
|
|
209
|
-
<>
|
|
210
|
-
<span>·</span>
|
|
211
|
-
<span>{formatRelativeTime(comment.createdAt)}</span>
|
|
212
|
-
</>
|
|
213
|
-
)}
|
|
214
|
-
</div>
|
|
215
|
-
</div>
|
|
216
|
-
);
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
// PatchDiff only handles single-file patches. Split the full multi-file patch
|
|
220
|
-
// on "diff --git" boundaries and render one PatchDiff per file.
|
|
221
|
-
const splitPatch = (patch: string): { file: string; patch: string }[] =>
|
|
222
|
-
patch
|
|
223
|
-
.split(/(?=^diff --git )/gm)
|
|
224
|
-
.filter((s) => s.trimStart().startsWith("diff --git "))
|
|
225
|
-
.map((filePatch) => {
|
|
226
|
-
const match = filePatch.match(/^diff --git a\/(.+?) b\//m);
|
|
227
|
-
return { file: match?.[1] ?? "", patch: filePatch };
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
interface SingleFileDiffProps {
|
|
231
|
-
file: string;
|
|
232
|
-
filePatch: string;
|
|
233
|
-
layout: "split" | "stacked";
|
|
234
|
-
comments: Comment[];
|
|
235
|
-
fileStat: DiffFileStat | undefined;
|
|
236
|
-
viewed: boolean;
|
|
237
|
-
onToggleViewed: () => void;
|
|
238
|
-
repoPath: string;
|
|
239
|
-
mergeBase: string;
|
|
240
|
-
onAddComment: (
|
|
241
|
-
file: string,
|
|
242
|
-
lineNumber: number,
|
|
243
|
-
side: string,
|
|
244
|
-
body: string,
|
|
245
|
-
tag: CommentTag,
|
|
246
|
-
) => Promise<void>;
|
|
247
|
-
onDeleteComment: (id: string) => Promise<void>;
|
|
248
|
-
onDiscard?: (file: string) => Promise<void>;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const SingleFileDiff = ({
|
|
252
|
-
file,
|
|
253
|
-
filePatch,
|
|
254
|
-
layout,
|
|
255
|
-
comments,
|
|
256
|
-
fileStat,
|
|
257
|
-
viewed,
|
|
258
|
-
onToggleViewed,
|
|
259
|
-
repoPath,
|
|
260
|
-
mergeBase,
|
|
261
|
-
onAddComment,
|
|
262
|
-
onDeleteComment,
|
|
263
|
-
onDiscard,
|
|
264
|
-
}: SingleFileDiffProps) => {
|
|
265
|
-
const { resolvedTheme } = useTheme();
|
|
266
|
-
const [commentTarget, setCommentTarget] = useState<{
|
|
267
|
-
lineNumber: number;
|
|
268
|
-
side: AnnotationSide;
|
|
269
|
-
} | null>(null);
|
|
270
|
-
|
|
271
|
-
// Fetch both file versions and build a FileDiffMetadata so the library's
|
|
272
|
-
// isPartial = false, enabling the built-in collapse/expand feature.
|
|
273
|
-
const [fileDiffMetadata, setFileDiffMetadata] = useState<FileDiffMetadata | null>(null);
|
|
274
|
-
useEffect(() => {
|
|
275
|
-
if (fileStat?.binary) {
|
|
276
|
-
// keep PatchDiff for binary files
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
setFileDiffMetadata(null);
|
|
280
|
-
let cancelled = false;
|
|
281
|
-
const oldRef = mergeBase;
|
|
282
|
-
// uncommitted mode has mergeBase="HEAD"; new content is the working tree
|
|
283
|
-
const newRef = mergeBase === "HEAD" ? "WORKING_TREE" : "HEAD";
|
|
284
|
-
const load = async () => {
|
|
285
|
-
try {
|
|
286
|
-
const [oldRes, newRes] = await Promise.all([
|
|
287
|
-
fetch(`/api/file?path=${encodeURIComponent(file)}&ref=${encodeURIComponent(oldRef)}`),
|
|
288
|
-
fetch(`/api/file?path=${encodeURIComponent(file)}&ref=${encodeURIComponent(newRef)}`),
|
|
289
|
-
]);
|
|
290
|
-
if (cancelled) {
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
const [oldJson, newJson] = await Promise.all([
|
|
294
|
-
oldRes.json() as Promise<{ content: string }>,
|
|
295
|
-
newRes.json() as Promise<{ content: string }>,
|
|
296
|
-
]);
|
|
297
|
-
const oldContent = oldJson.content;
|
|
298
|
-
const newContent = newJson.content;
|
|
299
|
-
if (cancelled) {
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
// Lazy-import to keep parseDiffFromFile out of the initial bundle
|
|
303
|
-
const { parseDiffFromFile } = await import("@pierre/diffs");
|
|
304
|
-
// context: 3 matches GitHub's default — keeps hunks separate so
|
|
305
|
-
// gaps produce collapsedBefore > 0, which triggers the expand chevrons.
|
|
306
|
-
// additionLines/deletionLines still hold the full file (isPartial=false)
|
|
307
|
-
// so expanding can reveal any line regardless of context size.
|
|
308
|
-
const metadata = parseDiffFromFile(
|
|
309
|
-
{ contents: oldContent, name: file },
|
|
310
|
-
{ contents: newContent, name: file },
|
|
311
|
-
{ context: 3 },
|
|
312
|
-
);
|
|
313
|
-
if (!cancelled) {
|
|
314
|
-
setFileDiffMetadata(metadata);
|
|
315
|
-
}
|
|
316
|
-
} catch {
|
|
317
|
-
// silently fall back to PatchDiff
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
void load();
|
|
321
|
-
return () => {
|
|
322
|
-
cancelled = true;
|
|
323
|
-
};
|
|
324
|
-
}, [file, mergeBase, fileStat?.binary]);
|
|
325
|
-
|
|
326
|
-
const fileComments = useMemo(() => comments.filter((c) => c.file === file), [comments, file]);
|
|
327
|
-
|
|
328
|
-
const lineAnnotations = useMemo((): DiffLineAnnotation<AnnotationData>[] => {
|
|
329
|
-
const annotations: DiffLineAnnotation<AnnotationData>[] = fileComments.map((c) => ({
|
|
330
|
-
lineNumber: c.lineNumber,
|
|
331
|
-
metadata: { comment: c, type: "comment" as const },
|
|
332
|
-
side: (c.side ?? "right") as AnnotationSide,
|
|
333
|
-
}));
|
|
334
|
-
|
|
335
|
-
if (commentTarget) {
|
|
336
|
-
annotations.push({
|
|
337
|
-
lineNumber: commentTarget.lineNumber,
|
|
338
|
-
metadata: { file, type: "input" as const },
|
|
339
|
-
side: commentTarget.side,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
return annotations;
|
|
344
|
-
}, [fileComments, commentTarget, file]);
|
|
345
|
-
|
|
346
|
-
const renderAnnotation = useCallback(
|
|
347
|
-
(annotation: DiffLineAnnotation<AnnotationData>) => {
|
|
348
|
-
const d = annotation.metadata;
|
|
349
|
-
if (!d) {
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
if (d.type === "input") {
|
|
354
|
-
return (
|
|
355
|
-
<InlineCommentInput
|
|
356
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
357
|
-
onSubmit={async (body, tag) => {
|
|
358
|
-
await onAddComment(file, annotation.lineNumber, annotation.side, body, tag);
|
|
359
|
-
setCommentTarget(null);
|
|
360
|
-
}}
|
|
361
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
362
|
-
onCancel={() => setCommentTarget(null)}
|
|
363
|
-
/>
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (d.type === "comment") {
|
|
368
|
-
return (
|
|
369
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
370
|
-
<CommentDisplay comment={d.comment} onDelete={() => onDeleteComment(d.comment.id)} />
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return null;
|
|
375
|
-
},
|
|
376
|
-
[file, onAddComment, onDeleteComment],
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
const renderGutterUtility = useCallback(
|
|
380
|
-
(getHoveredLine: () => { lineNumber: number; side: AnnotationSide } | undefined) => (
|
|
381
|
-
<button
|
|
382
|
-
type="button"
|
|
383
|
-
className="diffhub-gutter-btn"
|
|
384
|
-
title="Add comment for AI"
|
|
385
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
386
|
-
onClick={() => {
|
|
387
|
-
const line = getHoveredLine();
|
|
388
|
-
if (line) {
|
|
389
|
-
setCommentTarget({ lineNumber: line.lineNumber, side: line.side });
|
|
390
|
-
}
|
|
391
|
-
}}
|
|
392
|
-
>
|
|
393
|
-
+
|
|
394
|
-
</button>
|
|
395
|
-
),
|
|
396
|
-
[],
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
return (
|
|
400
|
-
<div data-filename={file} className="border-b border-border font-sans">
|
|
401
|
-
<FileDiffHeader
|
|
402
|
-
file={file}
|
|
403
|
-
insertions={fileStat?.insertions ?? 0}
|
|
404
|
-
deletions={fileStat?.deletions ?? 0}
|
|
405
|
-
commentCount={fileComments.length}
|
|
406
|
-
repoPath={repoPath}
|
|
407
|
-
viewed={viewed}
|
|
408
|
-
onToggleViewed={onToggleViewed}
|
|
409
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
410
|
-
onDiscard={onDiscard ? () => onDiscard(file) : undefined}
|
|
411
|
-
/>
|
|
412
|
-
<div className={cn("transition-opacity duration-200", viewed && "opacity-60")}>
|
|
413
|
-
{fileDiffMetadata ? (
|
|
414
|
-
<FileDiffViewer
|
|
415
|
-
fileDiff={fileDiffMetadata}
|
|
416
|
-
style={
|
|
417
|
-
{ "--diffs-addition-color-override": "var(--diff-green)" } as React.CSSProperties
|
|
418
|
-
}
|
|
419
|
-
options={{
|
|
420
|
-
collapsedContextThreshold: 3,
|
|
421
|
-
diffStyle: layout === "split" ? "split" : "unified",
|
|
422
|
-
disableFileHeader: true,
|
|
423
|
-
disableLineNumbers: false,
|
|
424
|
-
enableGutterUtility: true,
|
|
425
|
-
expansionLineCount: 20,
|
|
426
|
-
hunkSeparators: "line-info",
|
|
427
|
-
lineDiffType: "char",
|
|
428
|
-
lineHoverHighlight: "line",
|
|
429
|
-
maxLineDiffLength: 500,
|
|
430
|
-
overflow: "scroll",
|
|
431
|
-
theme: { dark: "github-dark", light: "github-light" },
|
|
432
|
-
themeType: resolvedTheme === "light" ? "light" : "dark",
|
|
433
|
-
unsafeCSS: `[data-diff-span] { border-radius: 0; }`,
|
|
434
|
-
}}
|
|
435
|
-
lineAnnotations={lineAnnotations}
|
|
436
|
-
renderAnnotation={renderAnnotation}
|
|
437
|
-
renderGutterUtility={renderGutterUtility}
|
|
438
|
-
/>
|
|
439
|
-
) : (
|
|
440
|
-
<PatchDiff
|
|
441
|
-
patch={filePatch}
|
|
442
|
-
style={
|
|
443
|
-
{ "--diffs-addition-color-override": "var(--diff-green)" } as React.CSSProperties
|
|
444
|
-
}
|
|
445
|
-
options={{
|
|
446
|
-
diffStyle: layout === "split" ? "split" : "unified",
|
|
447
|
-
disableFileHeader: true,
|
|
448
|
-
disableLineNumbers: false,
|
|
449
|
-
enableGutterUtility: true,
|
|
450
|
-
expansionLineCount: 20,
|
|
451
|
-
hunkSeparators: "line-info",
|
|
452
|
-
lineDiffType: "char",
|
|
453
|
-
lineHoverHighlight: "line",
|
|
454
|
-
maxLineDiffLength: 500,
|
|
455
|
-
overflow: "scroll",
|
|
456
|
-
theme: { dark: "github-dark", light: "github-light" },
|
|
457
|
-
themeType: resolvedTheme === "light" ? "light" : "dark",
|
|
458
|
-
unsafeCSS: `[data-diff-span] { border-radius: 0; }`,
|
|
459
|
-
}}
|
|
460
|
-
lineAnnotations={lineAnnotations}
|
|
461
|
-
renderAnnotation={renderAnnotation}
|
|
462
|
-
renderGutterUtility={renderGutterUtility}
|
|
463
|
-
/>
|
|
464
|
-
)}
|
|
465
|
-
</div>
|
|
466
|
-
</div>
|
|
467
|
-
);
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
interface DiffViewerProps {
|
|
471
|
-
patch: string;
|
|
472
|
-
mergeBase: string;
|
|
473
|
-
layout: "split" | "stacked";
|
|
474
|
-
comments: Comment[];
|
|
475
|
-
onAddComment: (
|
|
476
|
-
file: string,
|
|
477
|
-
lineNumber: number,
|
|
478
|
-
side: string,
|
|
479
|
-
body: string,
|
|
480
|
-
tag: CommentTag,
|
|
481
|
-
) => Promise<void>;
|
|
482
|
-
onDeleteComment: (id: string) => Promise<void>;
|
|
483
|
-
selectedFileId: string | null;
|
|
484
|
-
fileStats: DiffFileStat[];
|
|
485
|
-
viewedFiles: Set<string>;
|
|
486
|
-
onToggleViewed: (file: string) => void;
|
|
487
|
-
repoPath: string;
|
|
488
|
-
onDiscard?: (file: string) => Promise<void>;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export const DiffViewer = ({
|
|
492
|
-
patch,
|
|
493
|
-
mergeBase,
|
|
494
|
-
layout,
|
|
495
|
-
comments,
|
|
496
|
-
onAddComment,
|
|
497
|
-
onDeleteComment,
|
|
498
|
-
selectedFileId,
|
|
499
|
-
fileStats,
|
|
500
|
-
viewedFiles,
|
|
501
|
-
onToggleViewed,
|
|
502
|
-
repoPath,
|
|
503
|
-
onDiscard,
|
|
504
|
-
}: DiffViewerProps) => {
|
|
505
|
-
const filePatches = useMemo(() => splitPatch(patch), [patch]);
|
|
506
|
-
|
|
507
|
-
// Must be computed before any conditional return (rules of hooks)
|
|
508
|
-
const visible = useMemo(() => {
|
|
509
|
-
if (!selectedFileId) {
|
|
510
|
-
return filePatches.slice(0, 1);
|
|
511
|
-
}
|
|
512
|
-
const match = filePatches.filter((f) => f.file === selectedFileId);
|
|
513
|
-
return match.length > 0 ? match : filePatches.slice(0, 1);
|
|
514
|
-
}, [filePatches, selectedFileId]);
|
|
515
|
-
|
|
516
|
-
const fileStatMap = useMemo(() => {
|
|
517
|
-
const map = new Map<string, DiffFileStat>();
|
|
518
|
-
for (const s of fileStats) {
|
|
519
|
-
map.set(s.file, s);
|
|
520
|
-
}
|
|
521
|
-
return map;
|
|
522
|
-
}, [fileStats]);
|
|
523
|
-
|
|
524
|
-
if (!patch || filePatches.length === 0) {
|
|
525
|
-
return (
|
|
526
|
-
<Empty className="h-full">
|
|
527
|
-
<EmptyHeader>
|
|
528
|
-
<EmptyMedia variant="icon">
|
|
529
|
-
<BranchIcon />
|
|
530
|
-
</EmptyMedia>
|
|
531
|
-
<EmptyTitle>No changes</EmptyTitle>
|
|
532
|
-
<EmptyDescription>The working tree is clean relative to the base branch</EmptyDescription>
|
|
533
|
-
</EmptyHeader>
|
|
534
|
-
<EmptyContent>
|
|
535
|
-
<p className="text-xs text-muted-foreground/60">
|
|
536
|
-
Press <Kbd>r</Kbd> to refresh
|
|
537
|
-
</p>
|
|
538
|
-
</EmptyContent>
|
|
539
|
-
</Empty>
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
return (
|
|
544
|
-
<div className="h-full overflow-auto" id="diff-container">
|
|
545
|
-
{visible.map(({ file, patch: filePatch }) => (
|
|
546
|
-
<SingleFileDiff
|
|
547
|
-
key={file}
|
|
548
|
-
file={file}
|
|
549
|
-
filePatch={filePatch}
|
|
550
|
-
layout={layout}
|
|
551
|
-
comments={comments}
|
|
552
|
-
fileStat={fileStatMap.get(file)}
|
|
553
|
-
viewed={viewedFiles.has(file)}
|
|
554
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
555
|
-
onToggleViewed={() => onToggleViewed(file)}
|
|
556
|
-
repoPath={repoPath}
|
|
557
|
-
mergeBase={mergeBase}
|
|
558
|
-
onAddComment={onAddComment}
|
|
559
|
-
onDeleteComment={onDeleteComment}
|
|
560
|
-
onDiscard={onDiscard}
|
|
561
|
-
/>
|
|
562
|
-
))}
|
|
563
|
-
</div>
|
|
564
|
-
);
|
|
565
|
-
};
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useRef, useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
DotGrid1x3HorizontalIcon,
|
|
6
|
-
EyeOpenIcon,
|
|
7
|
-
EyeSlashIcon,
|
|
8
|
-
BubbleDotsIcon,
|
|
9
|
-
} from "blode-icons-react";
|
|
10
|
-
import { ContextMenu } from "./ContextMenu";
|
|
11
|
-
import { Button } from "@/components/ui/button";
|
|
12
|
-
import { Toggle } from "@/components/ui/toggle";
|
|
13
|
-
import { cn } from "@/lib/utils";
|
|
14
|
-
|
|
15
|
-
interface FileDiffHeaderProps {
|
|
16
|
-
file: string;
|
|
17
|
-
insertions: number;
|
|
18
|
-
deletions: number;
|
|
19
|
-
commentCount: number;
|
|
20
|
-
repoPath: string;
|
|
21
|
-
viewed: boolean;
|
|
22
|
-
onToggleViewed: () => void;
|
|
23
|
-
onDiscard?: () => Promise<void>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const FileDiffHeader = ({
|
|
27
|
-
file,
|
|
28
|
-
insertions,
|
|
29
|
-
deletions,
|
|
30
|
-
commentCount,
|
|
31
|
-
repoPath,
|
|
32
|
-
viewed,
|
|
33
|
-
onToggleViewed,
|
|
34
|
-
onDiscard,
|
|
35
|
-
}: FileDiffHeaderProps) => {
|
|
36
|
-
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
37
|
-
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
|
38
|
-
|
|
39
|
-
const lastSlash = file.lastIndexOf("/");
|
|
40
|
-
const dir = lastSlash === -1 ? "" : file.slice(0, lastSlash);
|
|
41
|
-
const filename = lastSlash === -1 ? file : file.slice(lastSlash + 1);
|
|
42
|
-
|
|
43
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
44
|
-
const handleOpenMenu = () => {
|
|
45
|
-
const rect = menuButtonRef.current?.getBoundingClientRect();
|
|
46
|
-
if (!rect) {
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
setContextMenu({ x: rect.left, y: rect.bottom + 4 });
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<div className="flex items-center gap-2 px-3 h-9 border-b border-border bg-card sticky top-0 z-10">
|
|
54
|
-
{/* File path + stats (left group) */}
|
|
55
|
-
<div className="flex items-center gap-2 min-w-0 flex-1 text-[13px]">
|
|
56
|
-
<div className="flex items-baseline gap-0 min-w-0">
|
|
57
|
-
{dir && <span className="text-muted-foreground truncate shrink">{dir}/</span>}
|
|
58
|
-
<span className="text-foreground font-medium shrink-0">{filename}</span>
|
|
59
|
-
</div>
|
|
60
|
-
|
|
61
|
-
{/* Stats inline after filename */}
|
|
62
|
-
<div className="flex items-center gap-1 shrink-0">
|
|
63
|
-
{insertions > 0 && (
|
|
64
|
-
<span className="font-mono text-[12px] text-diff-green">+{insertions}</span>
|
|
65
|
-
)}
|
|
66
|
-
{deletions > 0 && (
|
|
67
|
-
<span className="font-mono text-[12px] text-destructive">−{deletions}</span>
|
|
68
|
-
)}
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
{/* Comment badge */}
|
|
73
|
-
{commentCount > 0 && (
|
|
74
|
-
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-diff-purple/10 text-diff-purple text-[10px] shrink-0">
|
|
75
|
-
<BubbleDotsIcon size={10} />
|
|
76
|
-
{commentCount}
|
|
77
|
-
</div>
|
|
78
|
-
)}
|
|
79
|
-
|
|
80
|
-
{/* Open in… button */}
|
|
81
|
-
<Button
|
|
82
|
-
ref={menuButtonRef}
|
|
83
|
-
variant="ghost"
|
|
84
|
-
size="icon-xs"
|
|
85
|
-
onClick={handleOpenMenu}
|
|
86
|
-
className="text-muted-foreground hover:text-foreground hover:bg-secondary"
|
|
87
|
-
title="Open in…"
|
|
88
|
-
>
|
|
89
|
-
<DotGrid1x3HorizontalIcon />
|
|
90
|
-
</Button>
|
|
91
|
-
|
|
92
|
-
{/* Viewed toggle */}
|
|
93
|
-
<Toggle
|
|
94
|
-
pressed={viewed}
|
|
95
|
-
onPressedChange={onToggleViewed}
|
|
96
|
-
className={cn(
|
|
97
|
-
viewed ? "text-ring hover:text-ring/80" : "text-muted-foreground hover:text-foreground",
|
|
98
|
-
)}
|
|
99
|
-
title={viewed ? "Mark as not viewed" : "Mark as viewed"}
|
|
100
|
-
aria-label={viewed ? "Mark as not viewed" : "Mark as viewed"}
|
|
101
|
-
>
|
|
102
|
-
{viewed ? <EyeOpenIcon /> : <EyeSlashIcon />}
|
|
103
|
-
</Toggle>
|
|
104
|
-
|
|
105
|
-
{/* Context menu */}
|
|
106
|
-
{contextMenu && (
|
|
107
|
-
<ContextMenu
|
|
108
|
-
x={contextMenu.x}
|
|
109
|
-
y={contextMenu.y}
|
|
110
|
-
filePath={file}
|
|
111
|
-
repoPath={repoPath}
|
|
112
|
-
// oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
|
|
113
|
-
onClose={() => setContextMenu(null)}
|
|
114
|
-
onDiscard={onDiscard}
|
|
115
|
-
/>
|
|
116
|
-
)}
|
|
117
|
-
</div>
|
|
118
|
-
);
|
|
119
|
-
};
|