diffhub 0.1.1 → 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 (154) 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/server/app/_global-error.html +1 -1
  5. package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js +1 -1
  12. package/.next/standalone/apps/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  13. package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
  15. package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +16 -15
  16. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +16 -15
  17. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  18. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +5 -4
  19. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  21. package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  22. package/.next/standalone/apps/web/.next/server/app/api/comments/route.js +1 -1
  23. package/.next/standalone/apps/web/.next/server/app/api/comments/route.js.nft.json +1 -1
  24. package/.next/standalone/apps/web/.next/server/app/api/diff/route.js +2 -2
  25. package/.next/standalone/apps/web/.next/server/app/api/diff/route.js.nft.json +1 -1
  26. package/.next/standalone/apps/web/.next/server/app/api/discard/route.js +2 -2
  27. package/.next/standalone/apps/web/.next/server/app/api/discard/route.js.nft.json +1 -1
  28. package/.next/standalone/apps/web/.next/server/app/api/file/route.js +2 -2
  29. package/.next/standalone/apps/web/.next/server/app/api/file/route.js.nft.json +1 -1
  30. package/.next/standalone/apps/web/.next/server/app/api/files/route.js +2 -2
  31. package/.next/standalone/apps/web/.next/server/app/api/files/route.js.nft.json +1 -1
  32. package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
  33. package/.next/standalone/apps/web/.next/server/app/index.rsc +15 -14
  34. package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  35. package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +15 -14
  36. package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +5 -4
  38. package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/.next/standalone/apps/web/.next/server/app/page/react-loadable-manifest.json +2 -2
  40. package/.next/standalone/apps/web/.next/server/app/page.js +2 -2
  41. package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
  42. package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05ejtyr._.js +3 -0
  44. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0e2dp4h._.js +3 -0
  45. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0egk6ui._.js +3 -0
  46. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0i6i-~n._.js +3 -0
  47. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0sv4hr9._.js +3 -0
  48. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0t.tl18._.js +3 -0
  49. package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__06b81~v._.js → [root-of-the-server]__0giwc4b._.js} +2 -2
  50. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0jit913._.js +3 -0
  51. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0v19a7g._.js +3 -0
  52. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0f40lcw._.js +3 -0
  53. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0oc3qg_._.js +3 -3
  54. package/.next/standalone/apps/web/.next/server/chunks/ssr/{apps_web_0b_ykcu._.js → _0qo42r0._.js} +2 -2
  55. package/.next/standalone/apps/web/.next/server/chunks/ssr/{apps_web_08kf15u._.js → apps_web_0758ax4._.js} +2 -2
  56. package/.next/standalone/apps/web/.next/server/chunks/ssr/node_modules_0v8w2j~._.js +70 -0
  57. package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
  58. package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
  59. package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
  60. package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
  61. package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
  62. package/.next/standalone/apps/web/.next/static/chunks/{0-ci0c9di0qo8.js → 00zp83w4g1v1r.js} +3 -3
  63. package/.next/standalone/apps/web/.next/static/chunks/042ip11u2i2z6.js +138 -0
  64. package/.next/standalone/apps/web/.next/static/chunks/085s9eg867ha-.js +1 -0
  65. package/.next/standalone/apps/web/.next/static/chunks/0_vmme_k_ff_u.js +67 -0
  66. package/.next/standalone/apps/web/.next/static/chunks/0ap~_hc7r17_6.js +1 -0
  67. package/.next/standalone/apps/web/.next/static/chunks/0nt5-rwas5thn.js +67 -0
  68. package/.next/standalone/apps/web/.next/static/chunks/0p~3-4_.v0cft.js +1 -0
  69. package/.next/standalone/apps/web/.next/static/chunks/0wx-2dr44ql-g.js +1 -0
  70. package/.next/standalone/apps/web/.next/static/chunks/{0syypqto3~pe_.js → 0y0o261rjun_2.js} +8 -8
  71. package/.next/standalone/apps/web/.next/static/chunks/0y5z3t-z1c8ks.js.map +5 -0
  72. package/.next/standalone/apps/web/.next/static/chunks/17b.xoi.b6rcl.js +1 -0
  73. package/.next/standalone/apps/web/.next/static/chunks/turbopack-0_n_4n~_4no2a.js +1 -0
  74. package/.next/standalone/apps/web/.next/static/chunks/turbopack-worker-0sjn--fhq~1cg.js +1 -0
  75. package/.next/standalone/apps/web/.next/static/media/diffs.worker.09unk0quktc_5.ts +1 -0
  76. package/.next/standalone/apps/web/package.json +4 -4
  77. package/README.md +10 -3
  78. package/package.json +4 -4
  79. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__05vwx85._.js +0 -3
  80. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__09vmjc2._.js +0 -3
  81. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0etmu4u._.js +0 -3
  82. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0g-_a7n._.js +0 -3
  83. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0mshgfw._.js +0 -3
  84. package/.next/standalone/apps/web/.next/server/chunks/[root-of-the-server]__0qixima._.js +0 -3
  85. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0f~hmsk._.js +0 -3
  86. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0khyzju._.js +0 -3
  87. package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0l2mjim._.js +0 -70
  88. package/.next/standalone/apps/web/.next/server/chunks/ssr/_0kwaklj._.js +0 -3
  89. package/.next/standalone/apps/web/.next/static/chunks/0.ae0sud8tm7k.js +0 -204
  90. package/.next/standalone/apps/web/.next/static/chunks/0di~ntk7iivm4.js +0 -1
  91. package/.next/standalone/apps/web/.next/static/chunks/0pklg0nmdvay8.js +0 -1
  92. package/.next/standalone/apps/web/.next/static/chunks/0up9_7hiwl_dt.js +0 -1
  93. package/.next/standalone/apps/web/.next/static/chunks/0~984a88e4rp9.js +0 -204
  94. package/.next/standalone/apps/web/.next/static/chunks/13y8355z8m13w.js +0 -1
  95. package/.next/standalone/apps/web/AGENTS.md +0 -60
  96. package/.next/standalone/apps/web/CHANGELOG.md +0 -7
  97. package/.next/standalone/apps/web/CLAUDE.md +0 -1
  98. package/.next/standalone/apps/web/README.md +0 -78
  99. package/.next/standalone/apps/web/app/api/comments/route.ts +0 -20
  100. package/.next/standalone/apps/web/app/api/diff/route.ts +0 -15
  101. package/.next/standalone/apps/web/app/api/discard/route.ts +0 -15
  102. package/.next/standalone/apps/web/app/api/file/route.ts +0 -17
  103. package/.next/standalone/apps/web/app/api/files/route.ts +0 -14
  104. package/.next/standalone/apps/web/app/api/open/route.ts +0 -64
  105. package/.next/standalone/apps/web/app/favicon.ico +0 -0
  106. package/.next/standalone/apps/web/app/globals.css +0 -214
  107. package/.next/standalone/apps/web/app/layout.tsx +0 -52
  108. package/.next/standalone/apps/web/app/page.tsx +0 -6
  109. package/.next/standalone/apps/web/bin/diffhub.mjs +0 -147
  110. package/.next/standalone/apps/web/components/ContextMenu.tsx +0 -161
  111. package/.next/standalone/apps/web/components/DiffApp.tsx +0 -419
  112. package/.next/standalone/apps/web/components/DiffViewer.tsx +0 -565
  113. package/.next/standalone/apps/web/components/FileDiffHeader.tsx +0 -119
  114. package/.next/standalone/apps/web/components/FileList.tsx +0 -455
  115. package/.next/standalone/apps/web/components/KeyboardShortcutsDialog.tsx +0 -79
  116. package/.next/standalone/apps/web/components/SidebarHelpMenu.tsx +0 -86
  117. package/.next/standalone/apps/web/components/StatusBar.tsx +0 -212
  118. package/.next/standalone/apps/web/components/icons/file-status-icons.tsx +0 -48
  119. package/.next/standalone/apps/web/components/theme-provider.tsx +0 -12
  120. package/.next/standalone/apps/web/components/ui/button.tsx +0 -90
  121. package/.next/standalone/apps/web/components/ui/empty.tsx +0 -82
  122. package/.next/standalone/apps/web/components/ui/input.tsx +0 -18
  123. package/.next/standalone/apps/web/components/ui/kbd.tsx +0 -14
  124. package/.next/standalone/apps/web/components/ui/separator.tsx +0 -23
  125. package/.next/standalone/apps/web/components/ui/sheet.tsx +0 -109
  126. package/.next/standalone/apps/web/components/ui/sidebar.tsx +0 -700
  127. package/.next/standalone/apps/web/components/ui/skeleton.tsx +0 -9
  128. package/.next/standalone/apps/web/components/ui/toggle.tsx +0 -35
  129. package/.next/standalone/apps/web/components/ui/tooltip.tsx +0 -52
  130. package/.next/standalone/apps/web/components.json +0 -27
  131. package/.next/standalone/apps/web/lib/comments.ts +0 -52
  132. package/.next/standalone/apps/web/lib/export-comments.ts +0 -13
  133. package/.next/standalone/apps/web/lib/git.ts +0 -201
  134. package/.next/standalone/apps/web/lib/use-mobile.ts +0 -19
  135. package/.next/standalone/apps/web/lib/utils.ts +0 -5
  136. package/.next/standalone/apps/web/next.config.ts +0 -19
  137. package/.next/standalone/apps/web/oxfmt.config.ts +0 -6
  138. package/.next/standalone/apps/web/oxlint.config.ts +0 -13
  139. package/.next/standalone/apps/web/postcss.config.mjs +0 -7
  140. package/.next/standalone/apps/web/public/file.svg +0 -1
  141. package/.next/standalone/apps/web/public/glide-variable-italic.woff2 +0 -0
  142. package/.next/standalone/apps/web/public/glide-variable.woff2 +0 -0
  143. package/.next/standalone/apps/web/public/globe.svg +0 -1
  144. package/.next/standalone/apps/web/public/next.svg +0 -1
  145. package/.next/standalone/apps/web/public/operator-mono-book-italic.woff2 +0 -0
  146. package/.next/standalone/apps/web/public/operator-mono-book.woff2 +0 -0
  147. package/.next/standalone/apps/web/public/operator-mono-medium-italic.woff2 +0 -0
  148. package/.next/standalone/apps/web/public/operator-mono-medium.woff2 +0 -0
  149. package/.next/standalone/apps/web/public/vercel.svg +0 -1
  150. package/.next/standalone/apps/web/public/window.svg +0 -1
  151. package/.next/standalone/apps/web/tsconfig.json +0 -34
  152. /package/.next/standalone/apps/web/.next/static/{ZhI_-YaFho-fQoajjgwSH → mAVCL4HCmtJwT35feBPdK}/_buildManifest.js +0 -0
  153. /package/.next/standalone/apps/web/.next/static/{ZhI_-YaFho-fQoajjgwSH → mAVCL4HCmtJwT35feBPdK}/_clientMiddlewareManifest.js +0 -0
  154. /package/.next/standalone/apps/web/.next/static/{ZhI_-YaFho-fQoajjgwSH → mAVCL4HCmtJwT35feBPdK}/_ssgManifest.js +0 -0
@@ -1,455 +0,0 @@
1
- "use client";
2
-
3
- import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { BubbleDotsIcon, FolderIcon, FolderOpenIcon, MagnifyingGlassIcon } from "blode-icons-react";
5
- import { FileAddedIcon, FileDiffIcon, FileRemovedIcon } from "./icons/file-status-icons";
6
- import type { DiffFileStat } from "@/lib/git";
7
- import type { Comment } from "@/lib/comments";
8
- import { ContextMenu } from "./ContextMenu";
9
- import { cn } from "@/lib/utils";
10
- import { Sidebar, SidebarContent, SidebarHeader } from "@/components/ui/sidebar";
11
-
12
- interface FileListProps {
13
- files: DiffFileStat[];
14
- selectedFile: string | null;
15
- onSelectFile: (file: string) => void;
16
- comments: Comment[];
17
- repoPath: string;
18
- filterQuery: string;
19
- onFilterChange: (q: string) => void;
20
- viewedFiles: Set<string>;
21
- }
22
-
23
- // ── Tree types ──────────────────────────────────────────────────────────────
24
-
25
- interface FileNode {
26
- type: "file";
27
- name: string;
28
- path: string;
29
- fileStat: DiffFileStat;
30
- }
31
-
32
- interface FolderNode {
33
- type: "folder";
34
- /** May be "a/b/c" after compaction of single-child chains. */
35
- name: string;
36
- /** Path of the deepest folder in the (possibly compacted) chain. */
37
- path: string;
38
- children: TreeNode[];
39
- }
40
-
41
- type TreeNode = FileNode | FolderNode;
42
-
43
- // ── Phase 1: Build hierarchical tree from flat file list ────────────────────
44
-
45
- const buildTree = (files: DiffFileStat[]): TreeNode[] => {
46
- interface RawNode {
47
- files: DiffFileStat[];
48
- folders: Record<string, RawNode>;
49
- }
50
- const root: RawNode = { files: [], folders: {} };
51
-
52
- for (const fileStat of files) {
53
- const parts = fileStat.file.split("/");
54
- let current = root;
55
- for (let i = 0; i < parts.length - 1; i += 1) {
56
- const part = parts[i];
57
- if (!current.folders[part]) {
58
- current.folders[part] = { files: [], folders: {} };
59
- }
60
- current = current.folders[part];
61
- }
62
- current.files.push(fileStat);
63
- }
64
-
65
- const convertFolder = (name: string, node: RawNode, parentPath: string): FolderNode => {
66
- const path = parentPath ? `${parentPath}/${name}` : name;
67
- const children: TreeNode[] = [];
68
-
69
- for (const [fn, child] of Object.entries(node.folders).toSorted()) {
70
- children.push(convertFolder(fn, child, path));
71
- }
72
- for (const fileStat of node.files) {
73
- const parts = fileStat.file.split("/");
74
- children.push({
75
- fileStat,
76
- name: parts.at(-1) ?? fileStat.file,
77
- path: fileStat.file,
78
- type: "file",
79
- });
80
- }
81
- return { children, name, path, type: "folder" };
82
- };
83
-
84
- const result: TreeNode[] = [];
85
- for (const [fn, node] of Object.entries(root.folders).toSorted()) {
86
- result.push(convertFolder(fn, node, ""));
87
- }
88
- for (const fileStat of root.files) {
89
- result.push({ fileStat, name: fileStat.file, path: fileStat.file, type: "file" });
90
- }
91
- return result;
92
- };
93
-
94
- // ── Phase 2: Compact single-child-folder chains (VS Code / Zed style) ───────
95
- //
96
- // When a folder has exactly 1 child that is itself a folder (and no files),
97
- // merge them into one display node: "parent/child" (recursive).
98
-
99
- const compactTree = (nodes: TreeNode[]): TreeNode[] =>
100
- nodes.map((node) => {
101
- if (node.type === "file") {
102
- return node;
103
- }
104
-
105
- const kids = compactTree(node.children);
106
-
107
- if (kids.length === 1 && kids[0].type === "folder") {
108
- const only = kids[0] as FolderNode;
109
- return { ...only, name: `${node.name}/${only.name}` } satisfies FolderNode;
110
- }
111
-
112
- return { ...node, children: kids };
113
- });
114
-
115
- // ── Sub-components ──────────────────────────────────────────────────────────
116
-
117
- interface FileRowProps {
118
- node: FileNode;
119
- depth: number;
120
- isSelected: boolean;
121
- isViewed: boolean;
122
- commentCount: number;
123
- onSelect: (path: string) => void;
124
- onContextMenu: (x: number, y: number, file: string) => void;
125
- }
126
-
127
- const FileRow = memo(function FileRow({
128
- node,
129
- depth,
130
- isSelected,
131
- isViewed,
132
- commentCount,
133
- onSelect,
134
- onContextMenu,
135
- }: FileRowProps) {
136
- const indent = depth * 16 + 8;
137
- const { insertions, deletions } = node.fileStat;
138
-
139
- let FileStatusIcon = FileDiffIcon;
140
- if (insertions > 0 && deletions === 0) {
141
- FileStatusIcon = FileAddedIcon;
142
- } else if (deletions > 0 && insertions === 0) {
143
- FileStatusIcon = FileRemovedIcon;
144
- }
145
-
146
- let iconClass = "shrink-0 text-sidebar-foreground/40";
147
- if (insertions > 0 && deletions === 0) {
148
- iconClass = "shrink-0 text-diff-green";
149
- } else if (deletions > 0 && insertions === 0) {
150
- iconClass = "shrink-0 text-destructive";
151
- }
152
-
153
- return (
154
- <button
155
- type="button"
156
- className={cn(
157
- "flex w-full cursor-pointer items-center gap-1.5 py-1 text-left transition-colors",
158
- isSelected
159
- ? "bg-sidebar-accent text-sidebar-accent-foreground"
160
- : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
161
- isViewed && "opacity-50",
162
- )}
163
- style={{
164
- containIntrinsicBlockSize: "26px",
165
- contentVisibility: "auto",
166
- paddingLeft: indent,
167
- paddingRight: 8,
168
- }}
169
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
170
- onClick={() => onSelect(node.path)}
171
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
172
- onContextMenu={(e) => {
173
- e.preventDefault();
174
- onContextMenu(e.clientX, e.clientY, node.path);
175
- }}
176
- >
177
- <FileStatusIcon size={14} className={iconClass} />
178
- <span className="flex-1 truncate text-[12px] leading-tight">{node.name}</span>
179
- {commentCount > 0 && (
180
- <span className="flex shrink-0 items-center gap-0.5 text-[10px] text-sidebar-foreground/50">
181
- <BubbleDotsIcon size={10} />
182
- {commentCount}
183
- </span>
184
- )}
185
- </button>
186
- );
187
- });
188
-
189
- interface FolderRowProps {
190
- node: FolderNode;
191
- depth: number;
192
- isCollapsed: boolean;
193
- onToggle: (path: string) => void;
194
- children: React.ReactNode;
195
- }
196
-
197
- const FolderRow = memo(function FolderRow({
198
- node,
199
- depth,
200
- isCollapsed,
201
- onToggle,
202
- children,
203
- }: FolderRowProps) {
204
- const indent = depth * 16 + 8;
205
- const segments = node.name.split("/");
206
-
207
- return (
208
- <>
209
- <button
210
- type="button"
211
- aria-expanded={!isCollapsed}
212
- className="flex w-full items-center gap-1.5 py-1 text-left transition-colors hover:bg-sidebar-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
213
- style={{ paddingLeft: indent, paddingRight: 8 }}
214
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
215
- onClick={() => onToggle(node.path)}
216
- >
217
- {isCollapsed ? (
218
- <FolderIcon size={12} className="shrink-0 text-sidebar-foreground/50" />
219
- ) : (
220
- <FolderOpenIcon size={12} className="shrink-0 text-sidebar-foreground/50" />
221
- )}
222
- <span className="truncate text-[12px] text-sidebar-foreground/70">
223
- {segments.map((seg, i) => (
224
- // oxlint-disable-next-line react/no-array-index-key
225
- <Fragment key={i}>
226
- {seg}
227
- {i < segments.length - 1 && <span className="text-sidebar-foreground/30">/</span>}
228
- </Fragment>
229
- ))}
230
- </span>
231
- </button>
232
- {!isCollapsed && (
233
- <div className="relative">
234
- {/* Indent guide line — centred on the folder icon (icon is 12px wide, offset 6px) */}
235
- <div
236
- className="pointer-events-none absolute inset-y-0 border-l border-sidebar-border/60"
237
- style={{ left: indent + 6 }}
238
- />
239
- {children}
240
- </div>
241
- )}
242
- </>
243
- );
244
- });
245
-
246
- // ── Main component ──────────────────────────────────────────────────────────
247
-
248
- const DEFAULT_SIDEBAR_WIDTH = 256;
249
- const MIN_SIDEBAR_WIDTH = 8;
250
-
251
- export const FileList = ({
252
- files,
253
- selectedFile,
254
- onSelectFile,
255
- comments,
256
- repoPath,
257
- filterQuery,
258
- onFilterChange,
259
- viewedFiles,
260
- }: FileListProps) => {
261
- const [contextMenu, setContextMenu] = useState<{
262
- x: number;
263
- y: number;
264
- file: string;
265
- } | null>(null);
266
- const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
267
-
268
- const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH);
269
- const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
270
-
271
- useEffect(() => {
272
- const handleMouseMove = (e: MouseEvent) => {
273
- if (!dragRef.current) {
274
- return;
275
- }
276
- const newWidth = Math.max(
277
- MIN_SIDEBAR_WIDTH,
278
- dragRef.current.startWidth + e.clientX - dragRef.current.startX,
279
- );
280
- setSidebarWidth(newWidth);
281
- };
282
- const handleMouseUp = () => {
283
- if (!dragRef.current) {
284
- return;
285
- }
286
- dragRef.current = null;
287
- document.body.style.cursor = "";
288
- document.body.style.userSelect = "";
289
- };
290
- document.addEventListener("mousemove", handleMouseMove);
291
- document.addEventListener("mouseup", handleMouseUp);
292
- return () => {
293
- document.removeEventListener("mousemove", handleMouseMove);
294
- document.removeEventListener("mouseup", handleMouseUp);
295
- };
296
- }, []);
297
-
298
- const handleRailMouseDown = useCallback(
299
- (e: React.MouseEvent) => {
300
- e.preventDefault();
301
- dragRef.current = { startWidth: sidebarWidth, startX: e.clientX };
302
- document.body.style.cursor = "col-resize";
303
- document.body.style.userSelect = "none";
304
- },
305
- [sidebarWidth],
306
- );
307
-
308
- const filtered = useMemo(
309
- () =>
310
- filterQuery
311
- ? files.filter((f) => f.file.toLowerCase().includes(filterQuery.toLowerCase()))
312
- : files,
313
- [files, filterQuery],
314
- );
315
-
316
- const MAX_FILES = 500;
317
- const cappedFiles = useMemo(
318
- () => (filtered.length > MAX_FILES ? filtered.slice(0, MAX_FILES) : filtered),
319
- [filtered],
320
- );
321
- const tree = useMemo(() => compactTree(buildTree(cappedFiles)), [cappedFiles]);
322
-
323
- const commentsByFile = useMemo(() => {
324
- const map = new Map<string, number>();
325
- for (const c of comments) {
326
- map.set(c.file, (map.get(c.file) ?? 0) + 1);
327
- }
328
- return map;
329
- }, [comments]);
330
-
331
- const toggleFolder = useCallback((path: string) => {
332
- setCollapsedFolders((prev) => {
333
- const next = new Set(prev);
334
- if (next.has(path)) {
335
- next.delete(path);
336
- } else {
337
- next.add(path);
338
- }
339
- return next;
340
- });
341
- }, []);
342
-
343
- const handleContextMenu = useCallback((x: number, y: number, file: string) => {
344
- setContextMenu({ file, x, y });
345
- }, []);
346
-
347
- const renderTree = (nodes: TreeNode[], depth: number): React.ReactNode =>
348
- nodes.map((node) => {
349
- if (node.type === "folder") {
350
- return (
351
- <FolderRow
352
- key={node.path}
353
- node={node}
354
- depth={depth}
355
- isCollapsed={collapsedFolders.has(node.path)}
356
- onToggle={toggleFolder}
357
- >
358
- {renderTree(node.children, depth + 1)}
359
- </FolderRow>
360
- );
361
- }
362
-
363
- return (
364
- <FileRow
365
- key={node.path}
366
- node={node}
367
- depth={depth}
368
- isSelected={selectedFile === node.path}
369
- isViewed={viewedFiles.has(node.path)}
370
- commentCount={commentsByFile.get(node.path) ?? 0}
371
- onSelect={onSelectFile}
372
- onContextMenu={handleContextMenu}
373
- />
374
- );
375
- });
376
-
377
- return (
378
- <Sidebar
379
- collapsible="none"
380
- className="relative overflow-hidden border-r border-sidebar-border"
381
- style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
382
- >
383
- {/* Filter */}
384
- <SidebarHeader className="border-b border-sidebar-border h-[53px] flex-row items-center py-0 px-2">
385
- <div className="relative flex w-full items-center">
386
- <MagnifyingGlassIcon
387
- size={12}
388
- className="pointer-events-none absolute left-2.5 text-sidebar-foreground/40"
389
- />
390
- <input
391
- type="text"
392
- value={filterQuery}
393
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
394
- onChange={(e) => onFilterChange(e.target.value)}
395
- placeholder="Filter files…"
396
- aria-label="Filter files"
397
- className="w-full rounded-md border border-sidebar-border bg-sidebar-accent py-1.5 pl-7 pr-7 text-xs text-sidebar-foreground placeholder:text-sidebar-foreground/40 transition-colors focus:border-sidebar-ring/50 focus:outline-none"
398
- />
399
- {filterQuery && (
400
- <button
401
- type="button"
402
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
403
- onClick={() => onFilterChange("")}
404
- aria-label="Clear filter"
405
- className="absolute right-2 text-sm leading-none text-sidebar-foreground/40 transition-colors hover:text-sidebar-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring"
406
- >
407
- ×
408
- </button>
409
- )}
410
- </div>
411
- </SidebarHeader>
412
-
413
- {/* Tree */}
414
- <SidebarContent className="gap-0 py-1">
415
- {filtered.length === 0 ? (
416
- <div className="flex flex-col items-center gap-2 px-4 py-8 text-center">
417
- <p className="text-xs text-sidebar-foreground/50">No changes</p>
418
- {filterQuery && (
419
- <p className="text-[10px] text-sidebar-foreground/30">
420
- No files match &ldquo;{filterQuery}&rdquo;
421
- </p>
422
- )}
423
- </div>
424
- ) : (
425
- <>
426
- {renderTree(tree, 0)}
427
- {filtered.length > MAX_FILES && (
428
- <p className="px-3 py-2 text-[10px] text-sidebar-foreground/40">
429
- Showing {MAX_FILES} of {filtered.length} files
430
- </p>
431
- )}
432
- </>
433
- )}
434
- </SidebarContent>
435
-
436
- {/* Context menu */}
437
- {contextMenu && (
438
- <ContextMenu
439
- x={contextMenu.x}
440
- y={contextMenu.y}
441
- filePath={contextMenu.file}
442
- repoPath={repoPath}
443
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
444
- onClose={() => setContextMenu(null)}
445
- />
446
- )}
447
- {/* Resize rail */}
448
- <div
449
- aria-hidden
450
- className="absolute inset-y-0 right-0 z-20 w-[5px] cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:-translate-x-1/2 after:w-px after:transition-colors hover:after:bg-sidebar-border"
451
- onMouseDown={handleRailMouseDown}
452
- />
453
- </Sidebar>
454
- );
455
- };
@@ -1,79 +0,0 @@
1
- "use client";
2
-
3
- import { Dialog } from "@base-ui/react/dialog";
4
- import { Kbd } from "@/components/ui/kbd";
5
-
6
- interface ShortcutRowProps {
7
- keys: string[];
8
- description: string;
9
- }
10
-
11
- const ShortcutRow = ({ keys, description }: ShortcutRowProps) => (
12
- <div className="flex items-center justify-between py-2.5 border-b border-border last:border-0">
13
- <span className="text-sm text-foreground">{description}</span>
14
- <div className="flex items-center gap-1">
15
- {keys.map((k) => (
16
- <Kbd key={k}>{k}</Kbd>
17
- ))}
18
- </div>
19
- </div>
20
- );
21
-
22
- const SHORTCUTS: ShortcutRowProps[] = [
23
- { description: "Next file", keys: ["j"] },
24
- { description: "Previous file", keys: ["k"] },
25
- { description: "Toggle viewed", keys: ["v"] },
26
- { description: "Toggle split / unified", keys: ["s"] },
27
- { description: "Refresh diff", keys: ["r"] },
28
- { description: "Focus file filter", keys: ["/"] },
29
- { description: "Open keyboard shortcuts", keys: ["?"] },
30
- ];
31
-
32
- interface KeyboardShortcutsDialogProps {
33
- open: boolean;
34
- onClose: () => void;
35
- }
36
-
37
- export const KeyboardShortcutsDialog = ({ open, onClose }: KeyboardShortcutsDialogProps) => (
38
- <Dialog.Root
39
- open={open}
40
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
41
- onOpenChange={(o) => {
42
- if (!o) {
43
- onClose();
44
- }
45
- }}
46
- >
47
- <Dialog.Portal>
48
- <Dialog.Backdrop className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm transition-opacity duration-200 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0" />
49
- <Dialog.Popup className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card shadow-2xl dark:shadow-none outline-none transition-[opacity,transform] duration-200 data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95">
50
- {/* Header */}
51
- <div className="flex items-center justify-between border-b border-border px-5 py-4">
52
- <Dialog.Title className="text-sm font-semibold text-foreground">
53
- Keyboard shortcuts
54
- </Dialog.Title>
55
- <Dialog.Close
56
- aria-label="Close"
57
- className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
58
- >
59
- ×
60
- </Dialog.Close>
61
- </div>
62
-
63
- {/* Shortcuts list */}
64
- <div className="px-5 py-1">
65
- {SHORTCUTS.map((s) => (
66
- <ShortcutRow key={s.description} {...s} />
67
- ))}
68
- </div>
69
-
70
- {/* Footer */}
71
- <div className="border-t border-border px-5 py-3">
72
- <p className="text-xs text-muted-foreground">
73
- Press <Kbd>?</Kbd> anywhere to open this dialog
74
- </p>
75
- </div>
76
- </Dialog.Popup>
77
- </Dialog.Portal>
78
- </Dialog.Root>
79
- );
@@ -1,86 +0,0 @@
1
- "use client";
2
-
3
- import { KeyboardCableIcon } from "blode-icons-react";
4
- import { useEffect, useRef, useState } from "react";
5
- import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
6
- import { Kbd } from "@/components/ui/kbd";
7
-
8
- export const SidebarHelpMenu = () => {
9
- const [menuOpen, setMenuOpen] = useState(false);
10
- const [shortcutsOpen, setShortcutsOpen] = useState(false);
11
- const menuRef = useRef<HTMLDivElement>(null);
12
-
13
- // Close on outside click
14
- useEffect(() => {
15
- if (!menuOpen) {
16
- return;
17
- }
18
- const handleClick = (e: MouseEvent) => {
19
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
20
- setMenuOpen(false);
21
- }
22
- };
23
- document.addEventListener("mousedown", handleClick);
24
- return () => document.removeEventListener("mousedown", handleClick);
25
- }, [menuOpen]);
26
-
27
- // ? shortcut opens keyboard shortcuts dialog
28
- useEffect(() => {
29
- const handleKey = (e: KeyboardEvent) => {
30
- if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) {
31
- return;
32
- }
33
- if (e.key === "?") {
34
- setShortcutsOpen(true);
35
- }
36
- };
37
- window.addEventListener("keydown", handleKey);
38
- return () => window.removeEventListener("keydown", handleKey);
39
- }, []);
40
-
41
- return (
42
- <div className="relative" ref={menuRef}>
43
- <button
44
- type="button"
45
- aria-label="Help and settings"
46
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
47
- onClick={() => setMenuOpen((o) => !o)}
48
- className="flex size-6 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
49
- >
50
- <svg
51
- aria-hidden="true"
52
- className="size-3.5"
53
- fill="currentColor"
54
- viewBox="0 0 16 16"
55
- xmlns="http://www.w3.org/2000/svg"
56
- >
57
- <path d="M7.569 9.75c-.332 0-.614-.27-.578-.6.021-.188.061-.372.136-.62q.158-.51.447-.82a3.4 3.4 0 0 1 .703-.577q.284-.182.507-.396.229-.219.358-.486a1.4 1.4 0 0 0 .13-.606 1.2 1.2 0 0 0-.171-.653 1.2 1.2 0 0 0-.466-.429 1.36 1.36 0 0 0-.647-.152q-.33 0-.628.148a1.23 1.23 0 0 0-.587.622c-.123.295-.367.555-.686.555h-.472c-.337 0-.616-.28-.55-.611q.103-.513.363-.905a2.55 2.55 0 0 1 1.08-.915A3.6 3.6 0 0 1 7.998 3q.888 0 1.563.32.68.319 1.057.91.382.586.382 1.392 0 .543-.172.972a2.4 2.4 0 0 1-.48.763 3.5 3.5 0 0 1-.74.595 3.2 3.2 0 0 0-.62.496 1.7 1.7 0 0 0-.353.605l-.034.106c-.1.316-.35.591-.682.591zM8.75 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0" />
58
- </svg>
59
- </button>
60
-
61
- {menuOpen && (
62
- <div className="diffhub-menu-animate absolute bottom-full left-0 mb-1.5 z-50 min-w-[180px] rounded-lg border border-border bg-card shadow-lg dark:shadow-none py-1">
63
- <button
64
- type="button"
65
- className="flex w-full items-center gap-2.5 px-3 py-2 text-sm text-foreground hover:bg-secondary transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
66
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
67
- onClick={() => {
68
- setShortcutsOpen(true);
69
- setMenuOpen(false);
70
- }}
71
- >
72
- <KeyboardCableIcon size={14} className="text-muted-foreground shrink-0" />
73
- Keyboard shortcuts
74
- <Kbd className="ml-auto">?</Kbd>
75
- </button>
76
- </div>
77
- )}
78
-
79
- <KeyboardShortcutsDialog
80
- open={shortcutsOpen}
81
- // oxlint-disable-next-line react-perf/jsx-no-new-function-as-prop
82
- onClose={() => setShortcutsOpen(false)}
83
- />
84
- </div>
85
- );
86
- };