diffity 0.1.0

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 (174) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -0
  4. package/development.md +156 -0
  5. package/package.json +32 -0
  6. package/packages/cli/build.js +38 -0
  7. package/packages/cli/package.json +51 -0
  8. package/packages/cli/src/agent.ts +187 -0
  9. package/packages/cli/src/db.ts +58 -0
  10. package/packages/cli/src/index.ts +196 -0
  11. package/packages/cli/src/review-routes.ts +150 -0
  12. package/packages/cli/src/server.ts +370 -0
  13. package/packages/cli/src/session.ts +48 -0
  14. package/packages/cli/src/threads.ts +238 -0
  15. package/packages/cli/tsconfig.json +13 -0
  16. package/packages/git/package.json +24 -0
  17. package/packages/git/src/commits.ts +28 -0
  18. package/packages/git/src/diff.ts +97 -0
  19. package/packages/git/src/exec.ts +35 -0
  20. package/packages/git/src/index.ts +5 -0
  21. package/packages/git/src/repo.ts +63 -0
  22. package/packages/git/src/status.ts +9 -0
  23. package/packages/git/src/types.ts +12 -0
  24. package/packages/git/tsconfig.json +9 -0
  25. package/packages/parser/package.json +26 -0
  26. package/packages/parser/src/index.ts +12 -0
  27. package/packages/parser/src/parse.ts +299 -0
  28. package/packages/parser/src/types.ts +52 -0
  29. package/packages/parser/src/word-diff.ts +155 -0
  30. package/packages/parser/tests/fixtures/binary-deleted.diff +4 -0
  31. package/packages/parser/tests/fixtures/binary-file.diff +4 -0
  32. package/packages/parser/tests/fixtures/binary-modified.diff +3 -0
  33. package/packages/parser/tests/fixtures/copied-file.diff +12 -0
  34. package/packages/parser/tests/fixtures/deleted-file.diff +9 -0
  35. package/packages/parser/tests/fixtures/empty.diff +0 -0
  36. package/packages/parser/tests/fixtures/hunk-with-context.diff +12 -0
  37. package/packages/parser/tests/fixtures/mode-change-with-content.diff +10 -0
  38. package/packages/parser/tests/fixtures/mode-change.diff +3 -0
  39. package/packages/parser/tests/fixtures/multi-file.diff +22 -0
  40. package/packages/parser/tests/fixtures/new-file.diff +9 -0
  41. package/packages/parser/tests/fixtures/no-newline.diff +10 -0
  42. package/packages/parser/tests/fixtures/renamed-file.diff +12 -0
  43. package/packages/parser/tests/fixtures/single-file-additions.diff +11 -0
  44. package/packages/parser/tests/fixtures/single-file-deletions.diff +11 -0
  45. package/packages/parser/tests/fixtures/single-file-mixed.diff +15 -0
  46. package/packages/parser/tests/fixtures/single-file-multi-hunk.diff +22 -0
  47. package/packages/parser/tests/fixtures/spaces-in-path.diff +9 -0
  48. package/packages/parser/tests/fixtures/submodule.diff +7 -0
  49. package/packages/parser/tests/fixtures/unicode-content.diff +11 -0
  50. package/packages/parser/tests/parse.test.ts +312 -0
  51. package/packages/parser/tests/word-diff-integration.test.ts +52 -0
  52. package/packages/parser/tests/word-diff.test.ts +121 -0
  53. package/packages/parser/tsconfig.json +10 -0
  54. package/packages/skills/diffity-resolve/SKILL.md +55 -0
  55. package/packages/skills/diffity-review/SKILL.md +74 -0
  56. package/packages/skills/diffity-start/SKILL.md +25 -0
  57. package/packages/ui/index.html +13 -0
  58. package/packages/ui/package.json +35 -0
  59. package/packages/ui/public/brand.svg +12 -0
  60. package/packages/ui/public/favicon.svg +15 -0
  61. package/packages/ui/src/app.tsx +14 -0
  62. package/packages/ui/src/components/comment-bubble.tsx +78 -0
  63. package/packages/ui/src/components/comment-form-row.tsx +58 -0
  64. package/packages/ui/src/components/comment-form.tsx +78 -0
  65. package/packages/ui/src/components/comment-line-number.tsx +60 -0
  66. package/packages/ui/src/components/comment-thread.tsx +209 -0
  67. package/packages/ui/src/components/commit-list.tsx +100 -0
  68. package/packages/ui/src/components/dashboard.tsx +84 -0
  69. package/packages/ui/src/components/diff-line.tsx +90 -0
  70. package/packages/ui/src/components/diff-page.tsx +332 -0
  71. package/packages/ui/src/components/diff-stats.tsx +20 -0
  72. package/packages/ui/src/components/diff-view.tsx +278 -0
  73. package/packages/ui/src/components/expand-row.tsx +45 -0
  74. package/packages/ui/src/components/file-block.tsx +536 -0
  75. package/packages/ui/src/components/file-tree-item.tsx +84 -0
  76. package/packages/ui/src/components/file-tree.tsx +72 -0
  77. package/packages/ui/src/components/general-comments.tsx +174 -0
  78. package/packages/ui/src/components/hunk-block-split.tsx +357 -0
  79. package/packages/ui/src/components/hunk-block.tsx +161 -0
  80. package/packages/ui/src/components/hunk-header.tsx +144 -0
  81. package/packages/ui/src/components/hunk-with-gap.tsx +113 -0
  82. package/packages/ui/src/components/icons/arrow-down-icon.tsx +7 -0
  83. package/packages/ui/src/components/icons/arrow-up-icon.tsx +7 -0
  84. package/packages/ui/src/components/icons/check-circle-icon.tsx +8 -0
  85. package/packages/ui/src/components/icons/check-icon.tsx +9 -0
  86. package/packages/ui/src/components/icons/chevron-down-icon.tsx +11 -0
  87. package/packages/ui/src/components/icons/chevron-icon.tsx +20 -0
  88. package/packages/ui/src/components/icons/chevron-up-down-icon.tsx +7 -0
  89. package/packages/ui/src/components/icons/chevron-up-icon.tsx +11 -0
  90. package/packages/ui/src/components/icons/comment-icon.tsx +9 -0
  91. package/packages/ui/src/components/icons/copy-icon.tsx +10 -0
  92. package/packages/ui/src/components/icons/eye-icon.tsx +10 -0
  93. package/packages/ui/src/components/icons/eye-off-icon.tsx +12 -0
  94. package/packages/ui/src/components/icons/file-icon.tsx +7 -0
  95. package/packages/ui/src/components/icons/folder-icon.tsx +19 -0
  96. package/packages/ui/src/components/icons/git-branch-icon.tsx +13 -0
  97. package/packages/ui/src/components/icons/keyboard-icon.tsx +13 -0
  98. package/packages/ui/src/components/icons/moon-icon.tsx +9 -0
  99. package/packages/ui/src/components/icons/plus-icon.tsx +9 -0
  100. package/packages/ui/src/components/icons/search-icon.tsx +10 -0
  101. package/packages/ui/src/components/icons/sidebar-icon.tsx +10 -0
  102. package/packages/ui/src/components/icons/spinner.tsx +7 -0
  103. package/packages/ui/src/components/icons/split-view-icon.tsx +10 -0
  104. package/packages/ui/src/components/icons/sun-icon.tsx +17 -0
  105. package/packages/ui/src/components/icons/trash-icon.tsx +11 -0
  106. package/packages/ui/src/components/icons/undo-icon.tsx +9 -0
  107. package/packages/ui/src/components/icons/unified-view-icon.tsx +12 -0
  108. package/packages/ui/src/components/icons/x-icon.tsx +10 -0
  109. package/packages/ui/src/components/line-number-cell.tsx +18 -0
  110. package/packages/ui/src/components/markdown-content.tsx +139 -0
  111. package/packages/ui/src/components/orphaned-threads.tsx +80 -0
  112. package/packages/ui/src/components/overview-file-list.tsx +57 -0
  113. package/packages/ui/src/components/render-expansion-rows.tsx +47 -0
  114. package/packages/ui/src/components/shortcut-modal.tsx +93 -0
  115. package/packages/ui/src/components/sidebar.tsx +80 -0
  116. package/packages/ui/src/components/skeleton.tsx +9 -0
  117. package/packages/ui/src/components/stale-diff-banner.tsx +21 -0
  118. package/packages/ui/src/components/summary-bar.tsx +39 -0
  119. package/packages/ui/src/components/toolbar.tsx +246 -0
  120. package/packages/ui/src/components/ui/badge.tsx +17 -0
  121. package/packages/ui/src/components/ui/confirm-dialog.tsx +52 -0
  122. package/packages/ui/src/components/ui/icon-button.tsx +23 -0
  123. package/packages/ui/src/components/ui/status-badge.tsx +57 -0
  124. package/packages/ui/src/components/ui/thread-badge.tsx +35 -0
  125. package/packages/ui/src/components/word-diff.tsx +126 -0
  126. package/packages/ui/src/hooks/use-comment-actions.ts +97 -0
  127. package/packages/ui/src/hooks/use-commits.ts +12 -0
  128. package/packages/ui/src/hooks/use-copy.ts +18 -0
  129. package/packages/ui/src/hooks/use-diff-staleness.ts +58 -0
  130. package/packages/ui/src/hooks/use-diff.ts +12 -0
  131. package/packages/ui/src/hooks/use-highlighter.ts +190 -0
  132. package/packages/ui/src/hooks/use-info.ts +12 -0
  133. package/packages/ui/src/hooks/use-keyboard.ts +55 -0
  134. package/packages/ui/src/hooks/use-line-selection.ts +157 -0
  135. package/packages/ui/src/hooks/use-overview.ts +12 -0
  136. package/packages/ui/src/hooks/use-review-threads.ts +12 -0
  137. package/packages/ui/src/hooks/use-search-params.ts +26 -0
  138. package/packages/ui/src/hooks/use-theme.ts +34 -0
  139. package/packages/ui/src/hooks/use-thread-navigation.ts +43 -0
  140. package/packages/ui/src/lib/api.ts +232 -0
  141. package/packages/ui/src/lib/cn.ts +6 -0
  142. package/packages/ui/src/lib/context-expansion.ts +122 -0
  143. package/packages/ui/src/lib/diff-utils.ts +268 -0
  144. package/packages/ui/src/lib/dom-utils.ts +13 -0
  145. package/packages/ui/src/lib/file-tree.ts +122 -0
  146. package/packages/ui/src/lib/query-client.ts +10 -0
  147. package/packages/ui/src/lib/render-content.tsx +23 -0
  148. package/packages/ui/src/lib/syntax-token.ts +4 -0
  149. package/packages/ui/src/main.tsx +14 -0
  150. package/packages/ui/src/queries/commits.ts +9 -0
  151. package/packages/ui/src/queries/diff.ts +9 -0
  152. package/packages/ui/src/queries/file.ts +10 -0
  153. package/packages/ui/src/queries/info.ts +9 -0
  154. package/packages/ui/src/queries/overview.ts +9 -0
  155. package/packages/ui/src/styles/app.css +178 -0
  156. package/packages/ui/src/types/comment.ts +61 -0
  157. package/packages/ui/src/vite-env.d.ts +1 -0
  158. package/packages/ui/tests/context-expansion.test.ts +279 -0
  159. package/packages/ui/tests/diff-utils.test.ts +409 -0
  160. package/packages/ui/tsconfig.json +14 -0
  161. package/packages/ui/vite.config.ts +23 -0
  162. package/scripts/build-skills.ts +26 -0
  163. package/scripts/build.ts +15 -0
  164. package/scripts/dev.ts +32 -0
  165. package/scripts/lib/transformers/claude-code.ts +11 -0
  166. package/scripts/lib/transformers/codex.ts +17 -0
  167. package/scripts/lib/transformers/cursor.ts +17 -0
  168. package/scripts/lib/transformers/index.ts +3 -0
  169. package/scripts/lib/utils.ts +70 -0
  170. package/scripts/link-dev.ts +54 -0
  171. package/skills/diffity-resolve/SKILL.md +55 -0
  172. package/skills/diffity-review/SKILL.md +74 -0
  173. package/skills/diffity-start/SKILL.md +27 -0
  174. package/tsconfig.json +22 -0
@@ -0,0 +1,370 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { join, extname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname } from 'node:path';
7
+ import { parseDiff, type ParsedDiff } from '@diffity/parser';
8
+ import {
9
+ getDiff,
10
+ getDiffStat,
11
+ getUntrackedFiles,
12
+ getUntrackedDiff,
13
+ getRepoInfo,
14
+ getFileContent,
15
+ getStagedFiles,
16
+ getUnstagedFiles,
17
+ getRecentCommits,
18
+ getFileLineCount,
19
+ getMergeBase,
20
+ resolveRef,
21
+ revertFile,
22
+ revertHunk,
23
+ isActionableRef,
24
+ } from '@diffity/git';
25
+ import { findOrCreateSession } from './session.js';
26
+ import { handleReviewRoute } from './review-routes.js';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+
30
+ const MIME_TYPES: Record<string, string> = {
31
+ '.html': 'text/html',
32
+ '.js': 'application/javascript',
33
+ '.css': 'text/css',
34
+ '.json': 'application/json',
35
+ '.svg': 'image/svg+xml',
36
+ '.png': 'image/png',
37
+ '.ico': 'image/x-icon',
38
+ };
39
+
40
+ interface ServerOptions {
41
+ port: number;
42
+ diffArgs: string[];
43
+ description?: string;
44
+ effectiveRef?: string;
45
+ }
46
+
47
+ function sendJson(res: ServerResponse, data: unknown) {
48
+ res.writeHead(200, { 'Content-Type': 'application/json' });
49
+ res.end(JSON.stringify(data));
50
+ }
51
+
52
+ function sendError(res: ServerResponse, status: number, message: string) {
53
+ res.writeHead(status, { 'Content-Type': 'application/json' });
54
+ res.end(JSON.stringify({ error: message }));
55
+ }
56
+
57
+ function serveStatic(res: ServerResponse, filePath: string) {
58
+ if (!existsSync(filePath)) {
59
+ sendError(res, 404, 'Not found');
60
+ return;
61
+ }
62
+ const ext = extname(filePath);
63
+ const mime = MIME_TYPES[ext] || 'application/octet-stream';
64
+ const content = readFileSync(filePath);
65
+ res.writeHead(200, { 'Content-Type': mime });
66
+ res.end(content);
67
+ }
68
+
69
+
70
+ function descriptionForRef(ref: string): string {
71
+ switch (ref) {
72
+ case 'staged':
73
+ return 'Staged changes';
74
+ case 'unstaged':
75
+ return 'Unstaged changes';
76
+ case 'working':
77
+ return 'Working tree changes';
78
+ case 'untracked':
79
+ return 'Untracked files';
80
+ case 'work':
81
+ return 'All changes';
82
+ default:
83
+ if (ref.includes('..')) {
84
+ return ref;
85
+ }
86
+ return `Changes from ${ref}`;
87
+ }
88
+ }
89
+
90
+ function resolveBaseRef(ref: string): string {
91
+ if (['staged', 'working', 'work'].includes(ref)) {
92
+ return 'HEAD';
93
+ }
94
+ if (ref === 'unstaged' || ref === 'untracked') {
95
+ return 'HEAD';
96
+ }
97
+
98
+ const threeDotsIdx = ref.indexOf('...');
99
+ if (threeDotsIdx !== -1) {
100
+ const left = ref.slice(0, threeDotsIdx);
101
+ const right = ref.slice(threeDotsIdx + 3);
102
+ return getMergeBase(left, right);
103
+ }
104
+
105
+ const twoDotsIdx = ref.indexOf('..');
106
+ if (twoDotsIdx !== -1) {
107
+ return ref.slice(0, twoDotsIdx);
108
+ }
109
+
110
+ return ref;
111
+ }
112
+
113
+ interface ServerResult {
114
+ port: number;
115
+ close: () => void;
116
+ }
117
+
118
+ function readBody(req: IncomingMessage): Promise<string> {
119
+ return new Promise((resolve, reject) => {
120
+ const chunks: Buffer[] = [];
121
+ req.on('data', (chunk: Buffer) => chunks.push(chunk));
122
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
123
+ req.on('error', reject);
124
+ });
125
+ }
126
+
127
+ export function startServer(options: ServerOptions): Promise<ServerResult> {
128
+ const { port, diffArgs, description, effectiveRef } = options;
129
+
130
+ let sessionId: string | null = null;
131
+ const reviewsEnabled = isActionableRef(effectiveRef);
132
+ if (reviewsEnabled && effectiveRef) {
133
+ const session = findOrCreateSession(effectiveRef);
134
+ sessionId = session.id;
135
+ }
136
+
137
+ const includeUntracked = diffArgs.length === 0;
138
+ function enrichWithLineCounts(diff: ParsedDiff, baseRef: string): ParsedDiff {
139
+ for (const file of diff.files) {
140
+ if (file.status === 'added' || file.isBinary) {
141
+ continue;
142
+ }
143
+ const path = file.oldPath || file.newPath;
144
+ const count = getFileLineCount(path, baseRef);
145
+ if (count !== null) {
146
+ file.oldFileLineCount = count;
147
+ }
148
+ }
149
+ return diff;
150
+ }
151
+
152
+ function getFullDiff(args: string[]): string {
153
+ let raw = getDiff(args);
154
+ if (includeUntracked) {
155
+ const untrackedFiles = getUntrackedFiles();
156
+ if (untrackedFiles.length > 0) {
157
+ raw += '\n' + getUntrackedDiff(untrackedFiles);
158
+ }
159
+ }
160
+ return raw;
161
+ }
162
+
163
+ const uiDir = join(__dirname, 'ui');
164
+
165
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
166
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
167
+ const pathname = url.pathname;
168
+
169
+ res.setHeader('Access-Control-Allow-Origin', '*');
170
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
171
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
172
+
173
+ if (req.method === 'OPTIONS') {
174
+ res.writeHead(204);
175
+ res.end();
176
+ return;
177
+ }
178
+
179
+ if (pathname === '/api/revert-file' && req.method === 'POST') {
180
+ try {
181
+ const body = JSON.parse(await readBody(req));
182
+ const { filePath: path, isUntracked } = body;
183
+ if (!path || typeof path !== 'string') {
184
+ sendError(res, 400, 'Missing filePath');
185
+ return;
186
+ }
187
+ revertFile(path, !!isUntracked);
188
+ sendJson(res, { ok: true });
189
+ } catch (err) {
190
+ sendError(res, 500, `Failed to revert file: ${err}`);
191
+ }
192
+ return;
193
+ }
194
+
195
+ if (pathname === '/api/revert-hunk' && req.method === 'POST') {
196
+ try {
197
+ const body = JSON.parse(await readBody(req));
198
+ const { patch } = body;
199
+ if (!patch || typeof patch !== 'string') {
200
+ sendError(res, 400, 'Missing patch');
201
+ return;
202
+ }
203
+ revertHunk(patch);
204
+ sendJson(res, { ok: true });
205
+ } catch (err) {
206
+ sendError(res, 500, `Failed to revert hunk: ${err}`);
207
+ }
208
+ return;
209
+ }
210
+
211
+ if (pathname === '/api/overview') {
212
+ try {
213
+ const staged = getStagedFiles();
214
+ const unstaged = getUnstagedFiles();
215
+ const untracked = getUntrackedFiles();
216
+
217
+ const fileMap = new Map<string, string>();
218
+ for (const f of staged) {
219
+ fileMap.set(f, 'staged');
220
+ }
221
+ for (const f of unstaged) {
222
+ fileMap.set(f, 'modified');
223
+ }
224
+ for (const f of untracked) {
225
+ fileMap.set(f, 'added');
226
+ }
227
+
228
+ const files = Array.from(fileMap.entries()).map(([path, status]) => ({ path, status }));
229
+
230
+ sendJson(res, { files });
231
+ } catch (err) {
232
+ sendError(res, 500, `Failed to get overview: ${err}`);
233
+ }
234
+ return;
235
+ }
236
+
237
+ if (pathname === '/api/commits') {
238
+ const count = parseInt(url.searchParams.get('count') || '10', 10);
239
+ const skip = parseInt(url.searchParams.get('skip') || '0', 10);
240
+ const search = url.searchParams.get('search') || undefined;
241
+ try {
242
+ const commits = getRecentCommits({ count, skip, search });
243
+ sendJson(res, { commits, hasMore: commits.length === count });
244
+ } catch (err) {
245
+ sendError(res, 500, `Failed to get commits: ${err}`);
246
+ }
247
+ return;
248
+ }
249
+
250
+ if (pathname === '/api/diff-fingerprint') {
251
+ const ref = url.searchParams.get('ref');
252
+ let stat: string;
253
+ if (ref) {
254
+ switch (ref) {
255
+ case 'work':
256
+ case 'working':
257
+ stat = getDiffStat(['HEAD']) + '\n' + getUntrackedFiles().join('\n');
258
+ break;
259
+ case 'staged':
260
+ stat = getDiffStat(['--staged']);
261
+ break;
262
+ case 'unstaged':
263
+ stat = getDiffStat([]);
264
+ break;
265
+ case 'untracked':
266
+ stat = getUntrackedFiles().join('\n');
267
+ break;
268
+ default:
269
+ stat = getDiffStat([ref]);
270
+ break;
271
+ }
272
+ } else {
273
+ stat = getDiffStat(diffArgs);
274
+ if (includeUntracked) {
275
+ stat += '\n' + getUntrackedFiles().join('\n');
276
+ }
277
+ }
278
+ const hash = createHash('sha1').update(stat).digest('hex').slice(0, 12);
279
+ sendJson(res, { fingerprint: hash });
280
+ return;
281
+ }
282
+
283
+ if (pathname === '/api/diff') {
284
+ const ref = url.searchParams.get('ref');
285
+ const whitespace = url.searchParams.get('whitespace');
286
+ const extraArgs = whitespace === 'hide' ? ['-w'] : [];
287
+ const baseRef = ref ? resolveBaseRef(ref) : 'HEAD';
288
+
289
+ if (ref) {
290
+ sendJson(res, enrichWithLineCounts(parseDiff(resolveRef(ref, extraArgs)), baseRef));
291
+ return;
292
+ }
293
+
294
+ if (whitespace === 'hide') {
295
+ sendJson(res, enrichWithLineCounts(parseDiff(getFullDiff([...diffArgs, '-w'])), baseRef));
296
+ return;
297
+ }
298
+ sendJson(res, enrichWithLineCounts(parseDiff(getFullDiff(diffArgs)), baseRef));
299
+ return;
300
+ }
301
+
302
+ if (pathname.startsWith('/api/file/')) {
303
+ const filePath = decodeURIComponent(pathname.slice('/api/file/'.length));
304
+ const ref = url.searchParams.get('ref') || undefined;
305
+ const baseRef = ref ? resolveBaseRef(ref) : 'HEAD';
306
+ try {
307
+ const content = getFileContent(filePath, baseRef);
308
+ sendJson(res, { path: filePath, content: content.split('\n') });
309
+ } catch {
310
+ sendError(res, 404, `File not found: ${filePath}`);
311
+ }
312
+ return;
313
+ }
314
+
315
+ if (pathname === '/api/info') {
316
+ const ref = url.searchParams.get('ref');
317
+ const info = getRepoInfo();
318
+ let refDescription = description || diffArgs.join(' ') || 'Unstaged changes';
319
+ if (ref) {
320
+ refDescription = descriptionForRef(ref);
321
+ }
322
+ sendJson(res, {
323
+ ...info,
324
+ description: refDescription,
325
+ capabilities: { reviews: reviewsEnabled },
326
+ sessionId,
327
+ });
328
+ return;
329
+ }
330
+
331
+ if (handleReviewRoute(req, res, pathname, url)) {
332
+ return;
333
+ }
334
+
335
+ let filePath = join(uiDir, pathname === '/' ? 'index.html' : pathname);
336
+ if (!existsSync(filePath)) {
337
+ filePath = join(uiDir, 'index.html');
338
+ }
339
+ serveStatic(res, filePath);
340
+ });
341
+
342
+ const closeFn = () => server.close();
343
+
344
+ return new Promise((resolve, reject) => {
345
+ // When node --watch restarts the process, the old one may still hold the port
346
+ // briefly. Retry on the same port instead of falling back to a random one.
347
+ let retries = 0;
348
+ const maxRetries = 30;
349
+
350
+ const onError = (err: NodeJS.ErrnoException) => {
351
+ if (err.code === 'EADDRINUSE' && retries < maxRetries) {
352
+ retries++;
353
+ server.close();
354
+ setTimeout(() => server.listen(port), 500);
355
+ } else {
356
+ reject(err);
357
+ }
358
+ };
359
+
360
+ server.on('error', onError);
361
+ server.on('listening', () => {
362
+ const addr = server.address();
363
+ if (addr && typeof addr !== 'string') {
364
+ resolve({ port: addr.port, close: closeFn });
365
+ }
366
+ });
367
+
368
+ server.listen(port);
369
+ });
370
+ }
@@ -0,0 +1,48 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { getHeadHash, getDiffityDir } from '@diffity/git';
5
+ import { getDb } from './db.js';
6
+
7
+ export interface Session {
8
+ id: string;
9
+ ref: string;
10
+ headHash: string;
11
+ }
12
+
13
+ function sessionFilePath(): string {
14
+ return join(getDiffityDir(), 'current-session');
15
+ }
16
+
17
+ export function findOrCreateSession(ref: string): Session {
18
+ const db = getDb();
19
+ const headHash = getHeadHash();
20
+
21
+ const existing = db.prepare(
22
+ 'SELECT id, ref, head_hash FROM review_sessions WHERE ref = ? AND head_hash = ?'
23
+ ).get(ref, headHash) as { id: string; ref: string; head_hash: string } | undefined;
24
+
25
+ if (existing) {
26
+ const session: Session = { id: existing.id, ref: existing.ref, headHash: existing.head_hash };
27
+ writeFileSync(sessionFilePath(), JSON.stringify(session));
28
+ return session;
29
+ }
30
+
31
+ const id = randomUUID();
32
+ db.prepare(
33
+ 'INSERT INTO review_sessions (id, ref, head_hash) VALUES (?, ?, ?)'
34
+ ).run(id, ref, headHash);
35
+
36
+ const session: Session = { id, ref, headHash };
37
+ writeFileSync(sessionFilePath(), JSON.stringify(session));
38
+ return session;
39
+ }
40
+
41
+ export function getCurrentSession(): Session | null {
42
+ try {
43
+ const raw = readFileSync(sessionFilePath(), 'utf-8');
44
+ return JSON.parse(raw) as Session;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
@@ -0,0 +1,238 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getDb } from './db.js';
3
+
4
+ export interface ThreadAuthor {
5
+ name: string;
6
+ type: 'user' | 'agent';
7
+ }
8
+
9
+ export interface ThreadComment {
10
+ id: string;
11
+ author: ThreadAuthor;
12
+ body: string;
13
+ createdAt: string;
14
+ }
15
+
16
+ export type ThreadStatus = 'open' | 'resolved' | 'dismissed';
17
+
18
+ export interface Thread {
19
+ id: string;
20
+ sessionId: string;
21
+ filePath: string;
22
+ side: string;
23
+ startLine: number;
24
+ endLine: number;
25
+ status: ThreadStatus;
26
+ anchorContent: string | null;
27
+ createdAt: string;
28
+ updatedAt: string;
29
+ comments: ThreadComment[];
30
+ }
31
+
32
+ interface ThreadRow {
33
+ id: string;
34
+ session_id: string;
35
+ file_path: string;
36
+ side: string;
37
+ start_line: number;
38
+ end_line: number;
39
+ status: string;
40
+ anchor_content: string | null;
41
+ created_at: string;
42
+ updated_at: string;
43
+ }
44
+
45
+ interface CommentRow {
46
+ id: string;
47
+ thread_id: string;
48
+ author_name: string;
49
+ author_type: string;
50
+ body: string;
51
+ created_at: string;
52
+ }
53
+
54
+ function rowToThread(row: ThreadRow, comments: ThreadComment[]): Thread {
55
+ return {
56
+ id: row.id,
57
+ sessionId: row.session_id,
58
+ filePath: row.file_path,
59
+ side: row.side,
60
+ startLine: row.start_line,
61
+ endLine: row.end_line,
62
+ status: row.status as ThreadStatus,
63
+ anchorContent: row.anchor_content,
64
+ createdAt: row.created_at,
65
+ updatedAt: row.updated_at,
66
+ comments,
67
+ };
68
+ }
69
+
70
+ function rowToComment(row: CommentRow): ThreadComment {
71
+ return {
72
+ id: row.id,
73
+ author: { name: row.author_name, type: row.author_type as 'user' | 'agent' },
74
+ body: row.body,
75
+ createdAt: row.created_at,
76
+ };
77
+ }
78
+
79
+ function getCommentsForThreads(threadIds: string[]): Map<string, ThreadComment[]> {
80
+ if (threadIds.length === 0) {
81
+ return new Map();
82
+ }
83
+ const db = getDb();
84
+ const placeholders = threadIds.map(() => '?').join(', ');
85
+ const rows = db.prepare(
86
+ `SELECT * FROM comments WHERE thread_id IN (${placeholders}) ORDER BY created_at ASC`
87
+ ).all(...threadIds) as CommentRow[];
88
+
89
+ const map = new Map<string, ThreadComment[]>();
90
+ for (const row of rows) {
91
+ const comments = map.get(row.thread_id) ?? [];
92
+ comments.push(rowToComment(row));
93
+ map.set(row.thread_id, comments);
94
+ }
95
+ return map;
96
+ }
97
+
98
+ function getCommentsForThread(threadId: string): ThreadComment[] {
99
+ const map = getCommentsForThreads([threadId]);
100
+ return map.get(threadId) ?? [];
101
+ }
102
+
103
+ export function createThread(
104
+ sessionId: string,
105
+ filePath: string,
106
+ side: string,
107
+ startLine: number,
108
+ endLine: number,
109
+ body: string,
110
+ author: ThreadAuthor,
111
+ anchorContent?: string,
112
+ ): Thread {
113
+ const db = getDb();
114
+ const threadId = randomUUID();
115
+ const commentId = randomUUID();
116
+ const now = new Date().toISOString();
117
+
118
+ db.prepare(
119
+ 'INSERT INTO comment_threads (id, session_id, file_path, side, start_line, end_line, anchor_content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
120
+ ).run(threadId, sessionId, filePath, side, startLine, endLine, anchorContent ?? null, now, now);
121
+
122
+ db.prepare(
123
+ 'INSERT INTO comments (id, thread_id, author_name, author_type, body, created_at) VALUES (?, ?, ?, ?, ?, ?)'
124
+ ).run(commentId, threadId, author.name, author.type, body, now);
125
+
126
+ return {
127
+ id: threadId,
128
+ sessionId,
129
+ filePath,
130
+ side,
131
+ startLine,
132
+ endLine,
133
+ status: 'open',
134
+ anchorContent: anchorContent ?? null,
135
+ createdAt: now,
136
+ updatedAt: now,
137
+ comments: [{
138
+ id: commentId,
139
+ author,
140
+ body,
141
+ createdAt: now,
142
+ }],
143
+ };
144
+ }
145
+
146
+ export function getThreadsForSession(sessionId: string, status?: ThreadStatus): Thread[] {
147
+ const db = getDb();
148
+ let rows: ThreadRow[];
149
+ if (status) {
150
+ rows = db.prepare(
151
+ 'SELECT * FROM comment_threads WHERE session_id = ? AND status = ? ORDER BY created_at ASC'
152
+ ).all(sessionId, status) as ThreadRow[];
153
+ } else {
154
+ rows = db.prepare(
155
+ 'SELECT * FROM comment_threads WHERE session_id = ? ORDER BY created_at ASC'
156
+ ).all(sessionId) as ThreadRow[];
157
+ }
158
+
159
+ const commentsByThread = getCommentsForThreads(rows.map(r => r.id));
160
+ return rows.map(row => rowToThread(row, commentsByThread.get(row.id) ?? []));
161
+ }
162
+
163
+ export function getThread(idOrPrefix: string): Thread | null {
164
+ const db = getDb();
165
+ let row = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(idOrPrefix) as ThreadRow | undefined;
166
+
167
+ if (!row && idOrPrefix.length >= 8) {
168
+ row = db.prepare('SELECT * FROM comment_threads WHERE id LIKE ?').get(idOrPrefix + '%') as ThreadRow | undefined;
169
+ }
170
+
171
+ if (!row) {
172
+ return null;
173
+ }
174
+
175
+ return rowToThread(row, getCommentsForThread(row.id));
176
+ }
177
+
178
+ export function addReply(threadId: string, body: string, author: ThreadAuthor): ThreadComment {
179
+ const db = getDb();
180
+ const commentId = randomUUID();
181
+ const now = new Date().toISOString();
182
+
183
+ db.prepare(
184
+ 'INSERT INTO comments (id, thread_id, author_name, author_type, body, created_at) VALUES (?, ?, ?, ?, ?, ?)'
185
+ ).run(commentId, threadId, author.name, author.type, body, now);
186
+
187
+ db.prepare(
188
+ 'UPDATE comment_threads SET updated_at = ? WHERE id = ?'
189
+ ).run(now, threadId);
190
+
191
+ return {
192
+ id: commentId,
193
+ author,
194
+ body,
195
+ createdAt: now,
196
+ };
197
+ }
198
+
199
+ export function updateThreadStatus(threadId: string, status: ThreadStatus, summaryBody?: string, summaryAuthor?: ThreadAuthor): void {
200
+ const db = getDb();
201
+ const now = new Date().toISOString();
202
+
203
+ db.prepare(
204
+ 'UPDATE comment_threads SET status = ?, updated_at = ? WHERE id = ?'
205
+ ).run(status, now, threadId);
206
+
207
+ if (summaryBody && summaryAuthor) {
208
+ const commentId = randomUUID();
209
+ db.prepare(
210
+ 'INSERT INTO comments (id, thread_id, author_name, author_type, body, created_at) VALUES (?, ?, ?, ?, ?, ?)'
211
+ ).run(commentId, threadId, summaryAuthor.name, summaryAuthor.type, summaryBody, now);
212
+ }
213
+ }
214
+
215
+ export function deleteThread(threadId: string): void {
216
+ const db = getDb();
217
+ db.prepare('DELETE FROM comment_threads WHERE id = ?').run(threadId);
218
+ }
219
+
220
+ export function deleteAllThreadsForSession(sessionId: string): void {
221
+ const db = getDb();
222
+ db.prepare('DELETE FROM comment_threads WHERE session_id = ?').run(sessionId);
223
+ }
224
+
225
+ export function deleteComment(commentId: string): void {
226
+ const db = getDb();
227
+ const comment = db.prepare('SELECT thread_id FROM comments WHERE id = ?').get(commentId) as { thread_id: string } | undefined;
228
+ if (!comment) {
229
+ return;
230
+ }
231
+
232
+ db.prepare('DELETE FROM comments WHERE id = ?').run(commentId);
233
+
234
+ const remaining = db.prepare('SELECT COUNT(*) as count FROM comments WHERE thread_id = ?').get(comment.thread_id) as { count: number };
235
+ if (remaining.count === 0) {
236
+ db.prepare('DELETE FROM comment_threads WHERE id = ?').run(comment.thread_id);
237
+ }
238
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "composite": true
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "references": [
10
+ { "path": "../git" },
11
+ { "path": "../parser" }
12
+ ]
13
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@diffity/git",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsc --watch"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "devDependencies": {
21
+ "@types/node": "^25.5.0",
22
+ "typescript": "^5.9.3"
23
+ }
24
+ }