diffhub 0.1.0 → 0.1.2

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.
Files changed (189) hide show
  1. package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
  2. package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
  3. package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/apps/web/.next/required-server-files.json +4 -4
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  14. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +17 -16
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +17 -16
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +6 -5
  20. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  21. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  22. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/apps/web/.next/server/app/api/comments/route.js +1 -1
  24. package/.next/standalone/apps/web/.next/server/app/api/comments/route.js.nft.json +1 -1
  25. package/.next/standalone/apps/web/.next/server/app/api/diff/route.js +2 -2
  26. package/.next/standalone/apps/web/.next/server/app/api/diff/route.js.nft.json +1 -1
  27. package/.next/standalone/apps/web/.next/server/app/api/discard/route.js +2 -2
  28. package/.next/standalone/apps/web/.next/server/app/api/discard/route.js.nft.json +1 -1
  29. package/.next/standalone/apps/web/.next/server/app/api/file/route.js +2 -2
  30. package/.next/standalone/apps/web/.next/server/app/api/file/route.js.nft.json +1 -1
  31. package/.next/standalone/apps/web/.next/server/app/api/files/route.js +2 -2
  32. package/.next/standalone/apps/web/.next/server/app/api/files/route.js.nft.json +1 -1
  33. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  34. package/.next/standalone/apps/web/.next/server/app/index.rsc +16 -15
  35. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  36. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +16 -15
  37. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
  38. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +6 -5
  39. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  40. package/.next/standalone/apps/web/.next/server/app/page/react-loadable-manifest.json +2 -2
  41. package/.next/standalone/apps/web/.next/server/app/page.js +2 -2
  42. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  43. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05ejtyr._.js +3 -0
  45. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0e2dp4h._.js +3 -0
  46. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0egk6ui._.js +3 -0
  47. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0i6i-~n._.js +3 -0
  48. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0sv4hr9._.js +3 -0
  49. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0t.tl18._.js +3 -0
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__06b81~v._.js → [root-of-the-server]__0giwc4b._.js} +2 -2
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0jit913._.js +3 -0
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0v19a7g._.js +3 -0
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0f40lcw._.js +3 -0
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0oc3qg_._.js +3 -3
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/{apps_web_0b_ykcu._.js → _0qo42r0._.js} +2 -2
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/{apps_web_08kf15u._.js → apps_web_0758ax4._.js} +2 -2
  57. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_0v8w2j~._.js +70 -0
  58. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  59. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  60. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  61. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  62. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  63. package/.next/standalone/apps/web/.next/static/chunks/00zp83w4g1v1r.js +16 -0
  64. package/.next/standalone/apps/web/.next/static/chunks/042ip11u2i2z6.js +138 -0
  65. package/.next/standalone/apps/web/.next/static/chunks/07ndp5x2cvqo..css +3 -0
  66. package/.next/standalone/apps/web/.next/static/chunks/085s9eg867ha-.js +1 -0
  67. package/.next/standalone/apps/web/.next/static/chunks/0_vmme_k_ff_u.js +67 -0
  68. package/.next/standalone/apps/web/.next/static/chunks/0ap~_hc7r17_6.js +1 -0
  69. package/.next/standalone/apps/web/.next/static/chunks/0nt5-rwas5thn.js +67 -0
  70. package/.next/standalone/apps/web/.next/static/chunks/0p~3-4_.v0cft.js +1 -0
  71. package/.next/standalone/apps/web/.next/static/chunks/0wx-2dr44ql-g.js +1 -0
  72. package/.next/standalone/apps/web/.next/static/chunks/{0syypqto3~pe_.js → 0y0o261rjun_2.js} +8 -8
  73. package/.next/standalone/apps/web/.next/static/chunks/0y5z3t-z1c8ks.js.map +5 -0
  74. package/.next/standalone/apps/web/.next/static/chunks/17b.xoi.b6rcl.js +1 -0
  75. package/.next/standalone/apps/web/.next/static/chunks/turbopack-0_n_4n~_4no2a.js +1 -0
  76. package/.next/standalone/apps/web/.next/static/chunks/turbopack-worker-0sjn--fhq~1cg.js +1 -0
  77. package/.next/standalone/apps/web/.next/static/media/diffs.worker.09unk0quktc_5.ts +1 -0
  78. package/.next/standalone/apps/web/package.json +12 -2
  79. package/.next/standalone/apps/web/server.js +1 -1
  80. package/README.md +10 -3
  81. package/bin/diffhub.mjs +1 -1
  82. package/package.json +12 -2
  83. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05vwx85._.js +0 -3
  84. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__09vmjc2._.js +0 -3
  85. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0etmu4u._.js +0 -3
  86. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0g-_a7n._.js +0 -3
  87. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0mshgfw._.js +0 -3
  88. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0qixima._.js +0 -3
  89. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0khyzju._.js +0 -3
  90. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0l2mjim._.js +0 -70
  91. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0q2lon0._.js +0 -3
  92. package/.next/standalone/apps/web/.next/server/chunks/ssr/_059a1m-._.js +0 -3
  93. package/.next/standalone/apps/web/.next/static/chunks/0.ae0sud8tm7k.js +0 -204
  94. package/.next/standalone/apps/web/.next/static/chunks/0di~ntk7iivm4.js +0 -1
  95. package/.next/standalone/apps/web/.next/static/chunks/0pklg0nmdvay8.js +0 -1
  96. package/.next/standalone/apps/web/.next/static/chunks/0up9_7hiwl_dt.js +0 -1
  97. package/.next/standalone/apps/web/.next/static/chunks/0~984a88e4rp9.js +0 -204
  98. package/.next/standalone/apps/web/.next/static/chunks/13y8355z8m13w.js +0 -1
  99. package/.next/standalone/apps/web/.next/static/chunks/14gfmthe5zcf..js +0 -16
  100. package/.next/standalone/apps/web/.next/static/chunks/15--1d_raviej.css +0 -3
  101. package/.next/standalone/apps/web/AGENTS.md +0 -60
  102. package/.next/standalone/apps/web/CLAUDE.md +0 -1
  103. package/.next/standalone/apps/web/README.md +0 -78
  104. package/.next/standalone/apps/web/app/api/comments/route.ts +0 -20
  105. package/.next/standalone/apps/web/app/api/diff/route.ts +0 -15
  106. package/.next/standalone/apps/web/app/api/discard/route.ts +0 -15
  107. package/.next/standalone/apps/web/app/api/file/route.ts +0 -17
  108. package/.next/standalone/apps/web/app/api/files/route.ts +0 -14
  109. package/.next/standalone/apps/web/app/api/open/route.ts +0 -64
  110. package/.next/standalone/apps/web/app/favicon.ico +0 -0
  111. package/.next/standalone/apps/web/app/globals.css +0 -214
  112. package/.next/standalone/apps/web/app/layout.tsx +0 -52
  113. package/.next/standalone/apps/web/app/page.tsx +0 -6
  114. package/.next/standalone/apps/web/bin/diffhub.mjs +0 -147
  115. package/.next/standalone/apps/web/components/ContextMenu.tsx +0 -161
  116. package/.next/standalone/apps/web/components/DiffApp.tsx +0 -363
  117. package/.next/standalone/apps/web/components/DiffViewer.tsx +0 -565
  118. package/.next/standalone/apps/web/components/FileDiffHeader.tsx +0 -119
  119. package/.next/standalone/apps/web/components/FileList.tsx +0 -438
  120. package/.next/standalone/apps/web/components/KeyboardShortcutsDialog.tsx +0 -79
  121. package/.next/standalone/apps/web/components/SidebarHelpMenu.tsx +0 -86
  122. package/.next/standalone/apps/web/components/StatusBar.tsx +0 -212
  123. package/.next/standalone/apps/web/components/icons/file-status-icons.tsx +0 -48
  124. package/.next/standalone/apps/web/components/theme-provider.tsx +0 -12
  125. package/.next/standalone/apps/web/components/ui/button.tsx +0 -90
  126. package/.next/standalone/apps/web/components/ui/empty.tsx +0 -82
  127. package/.next/standalone/apps/web/components/ui/input.tsx +0 -18
  128. package/.next/standalone/apps/web/components/ui/kbd.tsx +0 -14
  129. package/.next/standalone/apps/web/components/ui/separator.tsx +0 -23
  130. package/.next/standalone/apps/web/components/ui/sheet.tsx +0 -109
  131. package/.next/standalone/apps/web/components/ui/sidebar.tsx +0 -700
  132. package/.next/standalone/apps/web/components/ui/skeleton.tsx +0 -9
  133. package/.next/standalone/apps/web/components/ui/toggle.tsx +0 -35
  134. package/.next/standalone/apps/web/components/ui/tooltip.tsx +0 -52
  135. package/.next/standalone/apps/web/components.json +0 -27
  136. package/.next/standalone/apps/web/lib/comments.ts +0 -52
  137. package/.next/standalone/apps/web/lib/export-comments.ts +0 -13
  138. package/.next/standalone/apps/web/lib/git.ts +0 -201
  139. package/.next/standalone/apps/web/lib/use-mobile.ts +0 -19
  140. package/.next/standalone/apps/web/lib/utils.ts +0 -5
  141. package/.next/standalone/apps/web/next.config.ts +0 -19
  142. package/.next/standalone/apps/web/node_modules/@img/sharp-darwin-arm64/lib/sharp-darwin-arm64.node +0 -0
  143. package/.next/standalone/apps/web/node_modules/@img/sharp-darwin-arm64/package.json +0 -40
  144. package/.next/standalone/apps/web/node_modules/@img/sharp-libvips-darwin-arm64/README.md +0 -46
  145. package/.next/standalone/apps/web/node_modules/@img/sharp-libvips-darwin-arm64/lib/glib-2.0/include/glibconfig.h +0 -220
  146. package/.next/standalone/apps/web/node_modules/@img/sharp-libvips-darwin-arm64/lib/index.js +0 -1
  147. package/.next/standalone/apps/web/node_modules/@img/sharp-libvips-darwin-arm64/lib/libvips-cpp.8.17.3.dylib +0 -0
  148. package/.next/standalone/apps/web/node_modules/@img/sharp-libvips-darwin-arm64/package.json +0 -36
  149. package/.next/standalone/apps/web/node_modules/@img/sharp-libvips-darwin-arm64/versions.json +0 -30
  150. package/.next/standalone/apps/web/oxfmt.config.ts +0 -6
  151. package/.next/standalone/apps/web/oxlint.config.ts +0 -13
  152. package/.next/standalone/apps/web/postcss.config.mjs +0 -7
  153. package/.next/standalone/apps/web/public/file.svg +0 -1
  154. package/.next/standalone/apps/web/public/glide-variable-italic.woff2 +0 -0
  155. package/.next/standalone/apps/web/public/glide-variable.woff2 +0 -0
  156. package/.next/standalone/apps/web/public/globe.svg +0 -1
  157. package/.next/standalone/apps/web/public/next.svg +0 -1
  158. package/.next/standalone/apps/web/public/operator-mono-book-italic.woff2 +0 -0
  159. package/.next/standalone/apps/web/public/operator-mono-book.woff2 +0 -0
  160. package/.next/standalone/apps/web/public/operator-mono-medium-italic.woff2 +0 -0
  161. package/.next/standalone/apps/web/public/operator-mono-medium.woff2 +0 -0
  162. package/.next/standalone/apps/web/public/vercel.svg +0 -1
  163. package/.next/standalone/apps/web/public/window.svg +0 -1
  164. package/.next/standalone/apps/web/tsconfig.json +0 -34
  165. package/.next/standalone/apps/web/tsconfig.tsbuildinfo +0 -1
  166. /package/.next/standalone/apps/web/.next/static/{x0qhb6ReQoZ53hlmgg2-_ → mAVCL4HCmtJwT35feBPdK}/_buildManifest.js +0 -0
  167. /package/.next/standalone/apps/web/.next/static/{x0qhb6ReQoZ53hlmgg2-_ → mAVCL4HCmtJwT35feBPdK}/_clientMiddlewareManifest.js +0 -0
  168. /package/.next/standalone/apps/web/.next/static/{x0qhb6ReQoZ53hlmgg2-_ → mAVCL4HCmtJwT35feBPdK}/_ssgManifest.js +0 -0
  169. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/classes/comparator.js +0 -0
  170. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/classes/range.js +0 -0
  171. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/classes/semver.js +0 -0
  172. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/cmp.js +0 -0
  173. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/coerce.js +0 -0
  174. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/compare.js +0 -0
  175. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/eq.js +0 -0
  176. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/gt.js +0 -0
  177. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/gte.js +0 -0
  178. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/lt.js +0 -0
  179. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/lte.js +0 -0
  180. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/neq.js +0 -0
  181. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/parse.js +0 -0
  182. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/functions/satisfies.js +0 -0
  183. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/internal/constants.js +0 -0
  184. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/internal/debug.js +0 -0
  185. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/internal/identifiers.js +0 -0
  186. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/internal/lrucache.js +0 -0
  187. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/internal/parse-options.js +0 -0
  188. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/internal/re.js +0 -0
  189. /package/.next/standalone/{apps/web/node_modules → node_modules}/semver/package.json +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
- };