difit 3.1.18 → 4.0.1

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 (100) hide show
  1. package/README.ja.md +44 -16
  2. package/README.ko.md +44 -16
  3. package/README.md +44 -16
  4. package/README.zh.md +44 -16
  5. package/dist/cli/github.d.ts +65 -0
  6. package/dist/cli/github.js +296 -0
  7. package/dist/cli/github.test.d.ts +1 -0
  8. package/dist/cli/github.test.js +341 -0
  9. package/dist/cli/index.js +42 -1
  10. package/dist/cli/index.test.js +330 -4
  11. package/dist/cli/utils.d.ts +2 -8
  12. package/dist/cli/utils.js +4 -43
  13. package/dist/cli/utils.test.js +50 -67
  14. package/dist/client/assets/{_basePickBy-DyiQWUmK.js → _basePickBy-ChXFkTMC.js} +1 -1
  15. package/dist/client/assets/{_baseUniq-DivSZEOF.js → _baseUniq-Mj_sFFQW.js} +1 -1
  16. package/dist/client/assets/{arc-c0kacVOL.js → arc-BMA6S9F1.js} +1 -1
  17. package/dist/client/assets/{architectureDiagram-2XIMDMQ5-ubymLNEe.js → architectureDiagram-2XIMDMQ5-0uiM_v5K.js} +1 -1
  18. package/dist/client/assets/{blockDiagram-WCTKOSBZ-F9D8w4_S.js → blockDiagram-WCTKOSBZ-CM7ZLL6F.js} +1 -1
  19. package/dist/client/assets/{c4Diagram-IC4MRINW-JE9Kx4yQ.js → c4Diagram-IC4MRINW-DKtCnVwn.js} +1 -1
  20. package/dist/client/assets/channel-D057yzDp.js +1 -0
  21. package/dist/client/assets/{chunk-4BX2VUAB-CYOCoDMc.js → chunk-4BX2VUAB-Wsl8DxEB.js} +1 -1
  22. package/dist/client/assets/{chunk-55IACEB6-PRBuiJg9.js → chunk-55IACEB6-CHm9X5i7.js} +1 -1
  23. package/dist/client/assets/{chunk-FMBD7UC4-C0eJ7JsI.js → chunk-FMBD7UC4-BSa8SHgd.js} +1 -1
  24. package/dist/client/assets/{chunk-JSJVCQXG-QZotPSqo.js → chunk-JSJVCQXG-Cpk76oJ3.js} +1 -1
  25. package/dist/client/assets/{chunk-KX2RTZJC-B8du3tt8.js → chunk-KX2RTZJC-D8YvfZVu.js} +1 -1
  26. package/dist/client/assets/{chunk-NQ4KR5QH-B10ldi5m.js → chunk-NQ4KR5QH-BogviJOv.js} +1 -1
  27. package/dist/client/assets/{chunk-QZHKN3VN-CpwW9rUQ.js → chunk-QZHKN3VN-DwLJYu26.js} +1 -1
  28. package/dist/client/assets/{chunk-WL4C6EOR-DwKPHpbL.js → chunk-WL4C6EOR-BFDpGxW2.js} +1 -1
  29. package/dist/client/assets/classDiagram-VBA2DB6C---D4iOts.js +1 -0
  30. package/dist/client/assets/classDiagram-v2-RAHNMMFH---D4iOts.js +1 -0
  31. package/dist/client/assets/clone-xSR3otEf.js +1 -0
  32. package/dist/client/assets/{cose-bilkent-S5V4N54A-p76yal75.js → cose-bilkent-S5V4N54A-oEosZ_5y.js} +1 -1
  33. package/dist/client/assets/{dagre-KLK3FWXG-CdDyed3V.js → dagre-KLK3FWXG-gFld4u1H.js} +1 -1
  34. package/dist/client/assets/{diagram-E7M64L7V-BaC8dXuW.js → diagram-E7M64L7V-gJq3kSrf.js} +1 -1
  35. package/dist/client/assets/{diagram-IFDJBPK2-BGf8xwJI.js → diagram-IFDJBPK2-BsUm_q22.js} +1 -1
  36. package/dist/client/assets/{diagram-P4PSJMXO-D3j16gBZ.js → diagram-P4PSJMXO-juB-sfcR.js} +1 -1
  37. package/dist/client/assets/{erDiagram-INFDFZHY-DFpDdocf.js → erDiagram-INFDFZHY-Dn77qXAt.js} +1 -1
  38. package/dist/client/assets/{flowDiagram-PKNHOUZH-Cz4mb4IF.js → flowDiagram-PKNHOUZH-DtmvDYdN.js} +1 -1
  39. package/dist/client/assets/{ganttDiagram-A5KZAMGK-CNzY9ua5.js → ganttDiagram-A5KZAMGK-BlDaKLbQ.js} +1 -1
  40. package/dist/client/assets/{gitGraphDiagram-K3NZZRJ6-DCSxL8EQ.js → gitGraphDiagram-K3NZZRJ6-DeAAeuMS.js} +1 -1
  41. package/dist/client/assets/{graph-BC2BV1-T.js → graph-NX9gBP47.js} +1 -1
  42. package/dist/client/assets/index-VxkpzDXr.css +1 -0
  43. package/dist/client/assets/index-kJdw4DY-.js +98 -0
  44. package/dist/client/assets/{infoDiagram-LFFYTUFH-BKSspZbH.js → infoDiagram-LFFYTUFH-CAaX023c.js} +1 -1
  45. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-DZ2IRYwc.js → ishikawaDiagram-PHBUUO56-CmiTQStv.js} +1 -1
  46. package/dist/client/assets/{journeyDiagram-4ABVD52K-BrjXAkii.js → journeyDiagram-4ABVD52K-B0SHC7mz.js} +1 -1
  47. package/dist/client/assets/{kanban-definition-K7BYSVSG-B1mfOekw.js → kanban-definition-K7BYSVSG-IfRdhzz7.js} +1 -1
  48. package/dist/client/assets/{layout-CWTG02uT.js → layout-l3OdNQhJ.js} +1 -1
  49. package/dist/client/assets/{linear-CGgOKp1d.js → linear-CQ0hx5Qs.js} +1 -1
  50. package/dist/client/assets/{mermaid.core-DTPtVBG7.js → mermaid.core-DqlPTabt.js} +4 -4
  51. package/dist/client/assets/{mindmap-definition-YRQLILUH-DByVRPFT.js → mindmap-definition-YRQLILUH-DIgSmG_f.js} +1 -1
  52. package/dist/client/assets/{pieDiagram-SKSYHLDU-DEgvAxAy.js → pieDiagram-SKSYHLDU-FzM5qoIB.js} +1 -1
  53. package/dist/client/assets/{prism-csharp-DqTrHqwJ.js → prism-csharp-DCfUUOUs.js} +1 -1
  54. package/dist/client/assets/{prism-elixir-DEJaM00V.js → prism-elixir-riuOL1mm.js} +1 -1
  55. package/dist/client/assets/{prism-hcl-HvJ0aPiH.js → prism-hcl-CizuX1s4.js} +1 -1
  56. package/dist/client/assets/{prism-java-DDUFERTh.js → prism-java-DYCKrDUh.js} +1 -1
  57. package/dist/client/assets/{prism-perl-CNA3SNC9.js → prism-perl-BJwBYR3Y.js} +1 -1
  58. package/dist/client/assets/{prism-php-hBQuhE2A.js → prism-php-BMhFuA7y.js} +1 -1
  59. package/dist/client/assets/{prism-ruby-BKap8imy.js → prism-ruby-Bcu0cDEh.js} +1 -1
  60. package/dist/client/assets/{prism-solidity-DHc7LZHq.js → prism-solidity-DDDs3w-w.js} +1 -1
  61. package/dist/client/assets/{quadrantDiagram-337W2JSQ-DTtikTvc.js → quadrantDiagram-337W2JSQ-BBrApyD7.js} +1 -1
  62. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-B34R-xD0.js → requirementDiagram-Z7DCOOCP-CLXiwUaA.js} +1 -1
  63. package/dist/client/assets/{sankeyDiagram-WA2Y5GQK-Dts1ZXRC.js → sankeyDiagram-WA2Y5GQK-9Y3Ly5qe.js} +1 -1
  64. package/dist/client/assets/{sequenceDiagram-2WXFIKYE-DzM3WhEY.js → sequenceDiagram-2WXFIKYE-DEpX1BA5.js} +1 -1
  65. package/dist/client/assets/{stateDiagram-RAJIS63D-B2dF8YnK.js → stateDiagram-RAJIS63D-Ck3ullwA.js} +1 -1
  66. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-X6UiDsar.js +1 -0
  67. package/dist/client/assets/{timeline-definition-YZTLITO2-BO4OtcEm.js → timeline-definition-YZTLITO2-CMezf3XV.js} +1 -1
  68. package/dist/client/assets/{treemap-KZPCXAKY-DaXnvVRH.js → treemap-KZPCXAKY-DqrcV0gQ.js} +1 -1
  69. package/dist/client/assets/{vennDiagram-LZ73GAT5-AIMhd8Js.js → vennDiagram-LZ73GAT5-eQg945Fz.js} +1 -1
  70. package/dist/client/assets/{xychartDiagram-JWTSCODW-Ch6W1f7P.js → xychartDiagram-JWTSCODW-_hqdXeX1.js} +1 -1
  71. package/dist/client/index.html +2 -2
  72. package/dist/server/generated-file-check.js +113 -58
  73. package/dist/server/generated-file-check.test.js +2 -0
  74. package/dist/server/git-diff-tui.d.ts +1 -1
  75. package/dist/server/git-diff-tui.js +7 -5
  76. package/dist/server/git-diff-tui.test.d.ts +1 -0
  77. package/dist/server/git-diff-tui.test.js +60 -0
  78. package/dist/server/git-diff.d.ts +4 -1
  79. package/dist/server/git-diff.js +73 -9
  80. package/dist/server/git-diff.test.js +46 -0
  81. package/dist/server/server.d.ts +3 -0
  82. package/dist/server/server.js +111 -37
  83. package/dist/server/server.test.js +152 -0
  84. package/dist/tui/App.d.ts +1 -0
  85. package/dist/tui/App.js +2 -2
  86. package/dist/types/diff.d.ts +74 -14
  87. package/dist/utils/commentFormatting.d.ts +4 -2
  88. package/dist/utils/commentFormatting.js +57 -19
  89. package/dist/utils/commentImports.d.ts +9 -0
  90. package/dist/utils/commentImports.js +264 -0
  91. package/dist/utils/commentImports.test.d.ts +1 -0
  92. package/dist/utils/commentImports.test.js +197 -0
  93. package/package.json +1 -1
  94. package/dist/client/assets/channel-Ca4c0q8d.js +0 -1
  95. package/dist/client/assets/classDiagram-VBA2DB6C-CJLw9sK7.js +0 -1
  96. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CJLw9sK7.js +0 -1
  97. package/dist/client/assets/clone-D0mDLEir.js +0 -1
  98. package/dist/client/assets/index-DHt9OwVU.css +0 -1
  99. package/dist/client/assets/index-mE8CA51x.js +0 -95
  100. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-ReD0hBzH.js +0 -1
@@ -7,6 +7,7 @@ import open from 'open';
7
7
  const __filename = fileURLToPath(import.meta.url);
8
8
  const __dirname = dirname(__filename);
9
9
  import { formatCommentsOutput } from '../utils/commentFormatting.js';
10
+ import { serializeCommentImports } from '../utils/commentImports.js';
10
11
  import { normalizeDiffViewMode } from '../utils/diffMode.js';
11
12
  import { resolveEditorOption } from '../utils/editorOptions.js';
12
13
  import { getFileExtension } from '../utils/fileUtils.js';
@@ -17,6 +18,12 @@ export async function startServer(options) {
17
18
  const app = express();
18
19
  const repositoryPath = resolve(options.repoPath ?? process.cwd());
19
20
  const repositoryId = createHash('sha256').update(repositoryPath).digest('hex');
21
+ const initialCommentImports = options.commentImports || [];
22
+ const initialBaseCommitish = options.baseCommitish ?? '';
23
+ const initialTargetCommitish = options.targetCommitish ?? '';
24
+ const commentImportId = initialCommentImports.length > 0
25
+ ? createHash('sha256').update(serializeCommentImports(initialCommentImports)).digest('hex')
26
+ : undefined;
20
27
  const parser = new GitDiffParser(repositoryPath);
21
28
  const fileWatcher = new FileWatcherService();
22
29
  const generatedStatusCache = new Map();
@@ -44,7 +51,7 @@ export async function startServer(options) {
44
51
  diffDataCache = parser.parseStdinDiff(options.stdinDiff);
45
52
  }
46
53
  else {
47
- diffDataCache = await parser.parseDiff(options.targetCommitish ?? '', options.baseCommitish ?? '', currentIgnoreWhitespace);
54
+ diffDataCache = await parser.parseDiff(options.targetCommitish ?? '', options.baseCommitish ?? '', currentIgnoreWhitespace, options.contextLines);
48
55
  }
49
56
  // Function to invalidate cache when file changes are detected
50
57
  const invalidateCache = () => {
@@ -55,10 +62,28 @@ export async function startServer(options) {
55
62
  // Track current revisions for cache invalidation
56
63
  let currentBaseCommitish = options.baseCommitish ?? '';
57
64
  let currentTargetCommitish = options.targetCommitish ?? '';
65
+ function parseRepositoryRelativePath(filepath) {
66
+ if (typeof filepath !== 'string' || filepath.length === 0) {
67
+ return { ok: false, error: 'Invalid file path' };
68
+ }
69
+ const normalizedFilepath = filepath.replace(/\\/g, '/');
70
+ const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
71
+ if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
72
+ return { ok: false, error: 'File path outside repository' };
73
+ }
74
+ const resolvedPath = resolve(repositoryPath, normalizedFilepath);
75
+ if (resolvedPath !== repositoryPath && !resolvedPath.startsWith(`${repositoryPath}${sep}`)) {
76
+ return { ok: false, error: 'File path outside repository' };
77
+ }
78
+ return { ok: true, path: normalizedFilepath };
79
+ }
58
80
  app.get('/api/diff', async (req, res) => {
59
81
  const ignoreWhitespace = req.query.ignoreWhitespace === 'true';
60
82
  const requestedBase = req.query.base || options.baseCommitish || '';
61
83
  const requestedTarget = req.query.target || options.targetCommitish || '';
84
+ const shouldIncludeCommentImports = initialCommentImports.length > 0 &&
85
+ (Boolean(options.stdinDiff) ||
86
+ (requestedBase === initialBaseCommitish && requestedTarget === initialTargetCommitish));
62
87
  // Check if revisions or whitespace setting changed
63
88
  const revisionsChanged = requestedBase !== currentBaseCommitish || requestedTarget !== currentTargetCommitish;
64
89
  const whitespaceChanged = ignoreWhitespace !== currentIgnoreWhitespace;
@@ -67,7 +92,7 @@ export async function startServer(options) {
67
92
  currentIgnoreWhitespace = ignoreWhitespace;
68
93
  currentBaseCommitish = requestedBase;
69
94
  currentTargetCommitish = requestedTarget;
70
- diffDataCache = await parser.parseDiff(requestedTarget, requestedBase, ignoreWhitespace);
95
+ diffDataCache = await parser.parseDiff(requestedTarget, requestedBase, ignoreWhitespace, options.contextLines);
71
96
  generatedStatusCache.clear();
72
97
  }
73
98
  // Resolve symbolic refs like HEAD/HEAD^ to actual hashes for the UI
@@ -106,6 +131,8 @@ export async function startServer(options) {
106
131
  requestedTargetCommitish,
107
132
  clearComments: options.clearComments,
108
133
  repositoryId,
134
+ commentImports: shouldIncludeCommentImports ? initialCommentImports : undefined,
135
+ commentImportId: shouldIncludeCommentImports ? commentImportId : undefined,
109
136
  });
110
137
  });
111
138
  app.get(/^\/api\/generated-status\/(.*)$/, async (req, res) => {
@@ -114,22 +141,12 @@ export async function startServer(options) {
114
141
  return;
115
142
  }
116
143
  try {
117
- const filepath = req.params[0];
118
- if (typeof filepath !== 'string' || filepath.length === 0) {
119
- res.status(400).json({ error: 'Invalid file path' });
120
- return;
121
- }
122
- const normalizedFilepath = filepath.replace(/\\/g, '/');
123
- const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
124
- if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
125
- res.status(400).json({ error: 'File path outside repository' });
126
- return;
127
- }
128
- const resolvedPath = resolve(repositoryPath, normalizedFilepath);
129
- if (resolvedPath !== repositoryPath && !resolvedPath.startsWith(`${repositoryPath}${sep}`)) {
130
- res.status(400).json({ error: 'File path outside repository' });
144
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
145
+ if (!filepathResult.ok) {
146
+ res.status(400).json({ error: filepathResult.error });
131
147
  return;
132
148
  }
149
+ const normalizedFilepath = filepathResult.path;
133
150
  const ref = req.query.ref || currentTargetCommitish || 'HEAD';
134
151
  const cacheKey = `${ref}:${normalizedFilepath}`;
135
152
  const now = Date.now();
@@ -162,7 +179,7 @@ export async function startServer(options) {
162
179
  return;
163
180
  }
164
181
  try {
165
- const { branches, commits, resolvedBase, resolvedTarget } = await parser.getRevisionOptions(currentBaseCommitish, currentTargetCommitish);
182
+ const { branches, commits, originDefaultBranch, resolvedBase, resolvedTarget } = await parser.getRevisionOptions(currentBaseCommitish, currentTargetCommitish);
166
183
  const response = {
167
184
  specialOptions: [
168
185
  { value: '.', label: 'All Uncommitted Changes' },
@@ -171,6 +188,7 @@ export async function startServer(options) {
171
188
  ],
172
189
  branches,
173
190
  commits,
191
+ originDefaultBranch,
174
192
  resolvedBase,
175
193
  resolvedTarget,
176
194
  };
@@ -187,10 +205,22 @@ export async function startServer(options) {
187
205
  res.status(404).json({ error: 'Line count not available for stdin diff' });
188
206
  return;
189
207
  }
190
- const filepath = req.params[0];
208
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
209
+ if (!filepathResult.ok) {
210
+ res.status(400).json({ error: filepathResult.error });
211
+ return;
212
+ }
213
+ const filepath = filepathResult.path;
191
214
  const oldRef = req.query.oldRef;
215
+ const oldPathResult = req.query.oldPath
216
+ ? parseRepositoryRelativePath(req.query.oldPath)
217
+ : { ok: true, path: filepath };
218
+ if (!oldPathResult.ok) {
219
+ res.status(400).json({ error: oldPathResult.error });
220
+ return;
221
+ }
192
222
  const newRef = req.query.newRef;
193
- const oldPath = req.query.oldPath || filepath;
223
+ const oldPath = oldPathResult.path;
194
224
  const result = {};
195
225
  if (oldRef) {
196
226
  try {
@@ -222,7 +252,12 @@ export async function startServer(options) {
222
252
  res.status(404).json({ error: 'Blob content not available for stdin diff' });
223
253
  return;
224
254
  }
225
- const filepath = req.params[0];
255
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
256
+ if (!filepathResult.ok) {
257
+ res.status(400).json({ error: filepathResult.error });
258
+ return;
259
+ }
260
+ const filepath = filepathResult.path;
226
261
  const ref = req.query.ref || 'HEAD';
227
262
  const blob = await parser.getBlobContent(filepath, ref);
228
263
  // Determine content type based on file extension
@@ -254,18 +289,59 @@ export async function startServer(options) {
254
289
  res.status(404).json({ error: 'File not found' });
255
290
  }
256
291
  });
257
- // Store comments for final output
258
- let finalComments = [];
259
- // Parse comments from request body (handles both JSON and text/plain)
292
+ let finalThreads = [];
293
+ function normalizeComment(comment) {
294
+ return {
295
+ id: comment.id,
296
+ file: comment.file,
297
+ line: comment.line,
298
+ side: comment.side,
299
+ createdAt: comment.timestamp,
300
+ updatedAt: comment.timestamp,
301
+ codeContent: comment.codeContent,
302
+ messages: [
303
+ {
304
+ id: comment.id,
305
+ body: comment.body,
306
+ author: comment.author,
307
+ createdAt: comment.timestamp,
308
+ updatedAt: comment.timestamp,
309
+ },
310
+ ],
311
+ };
312
+ }
313
+ function normalizeThreadPayload(thread) {
314
+ if ('file' in thread && 'line' in thread) {
315
+ return thread;
316
+ }
317
+ return {
318
+ id: thread.id,
319
+ file: thread.filePath,
320
+ line: typeof thread.position.line === 'number'
321
+ ? thread.position.line
322
+ : [thread.position.line.start, thread.position.line.end],
323
+ side: thread.position.side,
324
+ createdAt: thread.createdAt,
325
+ updatedAt: thread.updatedAt,
326
+ codeContent: thread.codeSnapshot?.content,
327
+ messages: thread.messages,
328
+ };
329
+ }
260
330
  function parseCommentsPayload(body) {
261
331
  const payload = typeof body === 'string'
262
332
  ? JSON.parse(body)
263
333
  : body;
264
- return payload.comments || [];
334
+ if (Array.isArray(payload.threads)) {
335
+ return payload.threads.map(normalizeThreadPayload);
336
+ }
337
+ if (Array.isArray(payload.comments)) {
338
+ return payload.comments.map(normalizeComment);
339
+ }
340
+ return [];
265
341
  }
266
342
  app.post('/api/comments', (req, res) => {
267
343
  try {
268
- finalComments = parseCommentsPayload(req.body);
344
+ finalThreads = parseCommentsPayload(req.body);
269
345
  res.json({ success: true });
270
346
  }
271
347
  catch (error) {
@@ -274,8 +350,9 @@ export async function startServer(options) {
274
350
  }
275
351
  });
276
352
  app.get('/api/comments-output', (_req, res) => {
277
- if (finalComments.length > 0) {
278
- const output = formatCommentsOutput(finalComments);
353
+ res.type('text/plain');
354
+ if (finalThreads.length > 0) {
355
+ const output = formatCommentsOutput(finalThreads);
279
356
  res.send(output);
280
357
  }
281
358
  else {
@@ -292,12 +369,12 @@ export async function startServer(options) {
292
369
  res.status(400).json({ error: 'Invalid request payload' });
293
370
  return;
294
371
  }
295
- const repoRoot = resolve(options.repoPath ?? process.cwd());
296
- const resolvedPath = resolve(repoRoot, filePath);
297
- if (resolvedPath !== repoRoot && !resolvedPath.startsWith(`${repoRoot}${sep}`)) {
298
- res.status(400).json({ error: 'File path outside repository' });
372
+ const filepathResult = parseRepositoryRelativePath(filePath);
373
+ if (!filepathResult.ok) {
374
+ res.status(400).json({ error: filepathResult.error });
299
375
  return;
300
376
  }
377
+ const resolvedPath = resolve(repositoryPath, filepathResult.path);
301
378
  const editorInput = typeof editor === 'string' ? editor : (process.env.DIFIT_EDITOR ?? process.env.EDITOR);
302
379
  const resolvedEditor = resolveEditorOption(editorInput);
303
380
  if (resolvedEditor.protocol === null) {
@@ -324,7 +401,7 @@ export async function startServer(options) {
324
401
  else {
325
402
  args.push(resolvedPath);
326
403
  }
327
- args.push(repoRoot);
404
+ args.push(repositoryPath);
328
405
  return await new Promise((resolvePromise) => {
329
406
  const child = spawn(resolvedEditor.cliCommand, args, { stdio: 'ignore', detached: true });
330
407
  child.once('error', (error) => {
@@ -357,8 +434,8 @@ export async function startServer(options) {
357
434
  });
358
435
  // Function to output comments when server shuts down
359
436
  function outputFinalComments() {
360
- if (finalComments.length > 0) {
361
- console.log(formatCommentsOutput(finalComments));
437
+ if (finalThreads.length > 0) {
438
+ console.log(formatCommentsOutput(finalThreads));
362
439
  }
363
440
  }
364
441
  // SSE endpoint for file watching
@@ -413,9 +490,6 @@ export async function startServer(options) {
413
490
  // Find client files relative to the CLI executable location
414
491
  const distPath = join(__dirname, '..', 'client');
415
492
  app.use(express.static(distPath));
416
- app.get('/{*splat}', (_req, res) => {
417
- res.sendFile(join(distPath, 'index.html'));
418
- });
419
493
  }
420
494
  else {
421
495
  app.get('/', (_req, res) => {
@@ -5,6 +5,7 @@ import { startServer } from './server.js';
5
5
  // Add fetch polyfill for Node.js test environment
6
6
  const { fetch } = await import('undici');
7
7
  globalThis.fetch = fetch;
8
+ const parserInstances = vi.hoisted(() => []);
8
9
  // Helper function to get available port
9
10
  async function getAvailablePort(preferredPort) {
10
11
  let port = preferredPort;
@@ -25,6 +26,9 @@ async function getAvailablePort(preferredPort) {
25
26
  // Mock GitDiffParser
26
27
  vi.mock('./git-diff.js', () => {
27
28
  class GitDiffParserMock {
29
+ constructor() {
30
+ parserInstances.push(this);
31
+ }
28
32
  validateCommit = vi.fn().mockResolvedValue(true);
29
33
  parseDiff = vi.fn().mockResolvedValue({
30
34
  targetCommit: 'abc123',
@@ -59,6 +63,7 @@ vi.mock('./git-diff.js', () => {
59
63
  isEmpty: false,
60
64
  });
61
65
  getBlobContent = vi.fn().mockResolvedValue(Buffer.from('mock image data'));
66
+ getLineCount = vi.fn().mockResolvedValue(42);
62
67
  getGeneratedStatus = vi.fn().mockResolvedValue({
63
68
  isGenerated: true,
64
69
  source: 'content',
@@ -67,6 +72,7 @@ vi.mock('./git-diff.js', () => {
67
72
  getRevisionOptions = vi.fn().mockResolvedValue({
68
73
  branches: [{ name: 'main', current: true }],
69
74
  commits: [{ hash: 'abc1234', shortHash: 'abc1234', message: 'Test commit' }],
75
+ originDefaultBranch: 'origin/main',
70
76
  resolvedBase: 'abc1234',
71
77
  resolvedTarget: 'def5678',
72
78
  });
@@ -166,6 +172,7 @@ describe('Server Integration Tests', () => {
166
172
  // Mock process.exit to prevent tests from actually exiting
167
173
  originalProcessExit = process.exit;
168
174
  process.exit = vi.fn();
175
+ parserInstances.length = 0;
169
176
  });
170
177
  afterEach(async () => {
171
178
  // Restore process.exit
@@ -225,6 +232,17 @@ describe('Server Integration Tests', () => {
225
232
  servers.push(result.server);
226
233
  expect(result.url).toContain('http://localhost:'); // Display host conversion
227
234
  });
235
+ it('passes context lines to the initial diff load', async () => {
236
+ const result = await startServer({
237
+ targetCommitish: 'HEAD',
238
+ baseCommitish: 'HEAD^',
239
+ preferredPort: 9025,
240
+ contextLines: 4,
241
+ });
242
+ servers.push(result.server);
243
+ const parser = parserInstances.at(-1);
244
+ expect(parser?.parseDiff).toHaveBeenCalledWith('HEAD', 'HEAD^', false, 4);
245
+ });
228
246
  });
229
247
  describe('API endpoints', () => {
230
248
  let port;
@@ -255,6 +273,88 @@ describe('Server Integration Tests', () => {
255
273
  expect(response.ok).toBe(true);
256
274
  expect(data).toHaveProperty('ignoreWhitespace', true);
257
275
  });
276
+ it('GET /api/diff preserves context lines when recalculating revisions', async () => {
277
+ const result = await startServer({
278
+ targetCommitish: 'HEAD',
279
+ baseCommitish: 'HEAD^',
280
+ preferredPort: 9031,
281
+ contextLines: 2,
282
+ });
283
+ servers.push(result.server);
284
+ const parser = parserInstances.at(-1);
285
+ parser?.parseDiff.mockClear();
286
+ const response = await fetch(`http://localhost:${result.port}/api/diff?base=main&target=feature&ignoreWhitespace=true`);
287
+ expect(response.ok).toBe(true);
288
+ expect(parser?.parseDiff).toHaveBeenCalledWith('feature', 'main', true, 2);
289
+ });
290
+ it('GET /api/diff returns comment import payload when configured', async () => {
291
+ const importedComments = [
292
+ {
293
+ type: 'thread',
294
+ filePath: 'test.js',
295
+ position: { side: 'new', line: 10 },
296
+ body: 'Imported comment',
297
+ },
298
+ ];
299
+ const importServer = await startServer({
300
+ targetCommitish: 'HEAD',
301
+ baseCommitish: 'HEAD^',
302
+ preferredPort: 9034,
303
+ commentImports: importedComments,
304
+ });
305
+ servers.push(importServer.server);
306
+ const response = await fetch(`http://localhost:${importServer.port}/api/diff`);
307
+ const data = (await response.json());
308
+ expect(response.ok).toBe(true);
309
+ expect(data.commentImports).toEqual(importedComments);
310
+ expect(data.commentImportId).toEqual(expect.any(String));
311
+ });
312
+ it('GET /api/diff returns clearComments together with comment import payload', async () => {
313
+ const importedComments = [
314
+ {
315
+ type: 'thread',
316
+ filePath: 'test.js',
317
+ position: { side: 'new', line: 10 },
318
+ body: 'Imported comment',
319
+ },
320
+ ];
321
+ const importServer = await startServer({
322
+ targetCommitish: 'HEAD',
323
+ baseCommitish: 'HEAD^',
324
+ preferredPort: 9037,
325
+ clearComments: true,
326
+ commentImports: importedComments,
327
+ });
328
+ servers.push(importServer.server);
329
+ const response = await fetch(`http://localhost:${importServer.port}/api/diff`);
330
+ const data = (await response.json());
331
+ expect(response.ok).toBe(true);
332
+ expect(data.clearComments).toBe(true);
333
+ expect(data.commentImports).toEqual(importedComments);
334
+ expect(data.commentImportId).toEqual(expect.any(String));
335
+ });
336
+ it('GET /api/diff omits comment import payload after revision changes', async () => {
337
+ const importedComments = [
338
+ {
339
+ type: 'thread',
340
+ filePath: 'test.js',
341
+ position: { side: 'new', line: 10 },
342
+ body: 'Imported comment',
343
+ },
344
+ ];
345
+ const importServer = await startServer({
346
+ targetCommitish: 'HEAD',
347
+ baseCommitish: 'HEAD^',
348
+ preferredPort: 9038,
349
+ commentImports: importedComments,
350
+ });
351
+ servers.push(importServer.server);
352
+ const response = await fetch(`http://localhost:${importServer.port}/api/diff?base=main&target=feature`);
353
+ const data = (await response.json());
354
+ expect(response.ok).toBe(true);
355
+ expect(data.commentImports).toBeUndefined();
356
+ expect(data.commentImportId).toBeUndefined();
357
+ });
258
358
  it('GET /api/generated-status/* returns generated status', async () => {
259
359
  const response = await fetch(`http://localhost:${port}/api/generated-status/src/query.ts?ref=HEAD`);
260
360
  const data = (await response.json());
@@ -329,6 +429,7 @@ describe('Server Integration Tests', () => {
329
429
  const response = await fetch(`http://localhost:${port}/api/comments-output`);
330
430
  const output = await response.text();
331
431
  expect(response.ok).toBe(true);
432
+ expect(response.headers.get('Content-Type')).toContain('text/plain');
332
433
  expect(output).toContain('Comments from review session');
333
434
  expect(output).toContain('test.js:L10');
334
435
  expect(output).toContain('First comment');
@@ -427,6 +528,17 @@ describe('Server Integration Tests', () => {
427
528
  // But the server should not crash
428
529
  expect([200, 404]).toContain(response.status);
429
530
  });
531
+ it('returns 404 for unknown paths in production mode', async () => {
532
+ process.env.NODE_ENV = 'production';
533
+ const result = await startServer({
534
+ targetCommitish: 'HEAD',
535
+ baseCommitish: 'HEAD^',
536
+ preferredPort: 9055,
537
+ });
538
+ servers.push(result.server);
539
+ const response = await fetch(`http://localhost:${result.port}/not-a-route`);
540
+ expect(response.status).toBe(404);
541
+ });
430
542
  });
431
543
  describe('Mode option handling', () => {
432
544
  it('accepts mode option in server configuration', async () => {
@@ -484,6 +596,7 @@ describe('Server Integration Tests', () => {
484
596
  expect(data.commits).toEqual([
485
597
  { hash: 'abc1234', shortHash: 'abc1234', message: 'Test commit' },
486
598
  ]);
599
+ expect(data.originDefaultBranch).toBe('origin/main');
487
600
  expect(data.resolvedBase).toBe('abc1234');
488
601
  expect(data.resolvedTarget).toBe('def5678');
489
602
  });
@@ -529,6 +642,39 @@ describe('Server Integration Tests', () => {
529
642
  expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Origin, X-Requested-With, Content-Type, Accept');
530
643
  });
531
644
  });
645
+ describe('Line count API', () => {
646
+ let port;
647
+ beforeEach(async () => {
648
+ const result = await startServer({
649
+ targetCommitish: 'HEAD',
650
+ baseCommitish: 'HEAD^',
651
+ preferredPort: 9050,
652
+ });
653
+ servers.push(result.server);
654
+ port = result.port;
655
+ });
656
+ it('returns line counts for repository files', async () => {
657
+ const response = await fetch(`http://localhost:${port}/api/line-count/src%2Findex.ts?oldRef=HEAD~1&newRef=HEAD`);
658
+ const data = (await response.json());
659
+ expect(response.ok).toBe(true);
660
+ expect(data).toEqual({
661
+ oldLineCount: 42,
662
+ newLineCount: 42,
663
+ });
664
+ });
665
+ it('rejects paths outside repository', async () => {
666
+ const response = await fetch(`http://localhost:${port}/api/line-count/..%2Foutside.txt`);
667
+ const data = (await response.json());
668
+ expect(response.status).toBe(400);
669
+ expect(data).toHaveProperty('error', 'File path outside repository');
670
+ });
671
+ it('rejects oldPath values outside repository', async () => {
672
+ const response = await fetch(`http://localhost:${port}/api/line-count/src%2Findex.ts?oldRef=HEAD~1&oldPath=..%2Foutside.txt`);
673
+ const data = (await response.json());
674
+ expect(response.status).toBe(400);
675
+ expect(data).toHaveProperty('error', 'File path outside repository');
676
+ });
677
+ });
532
678
  describe('Blob API endpoints', () => {
533
679
  let port;
534
680
  beforeEach(async () => {
@@ -609,6 +755,12 @@ describe('Server Integration Tests', () => {
609
755
  expect(response.ok).toBe(true);
610
756
  }
611
757
  });
758
+ it('rejects paths outside repository', async () => {
759
+ const response = await fetch(`http://localhost:${port}/api/blob/..%2Foutside.txt?ref=HEAD`);
760
+ const data = (await response.json());
761
+ expect(response.status).toBe(400);
762
+ expect(data).toHaveProperty('error', 'File path outside repository');
763
+ });
612
764
  });
613
765
  describe('Keep-alive option', () => {
614
766
  it('accepts keepAlive option without error', async () => {
package/dist/tui/App.d.ts CHANGED
@@ -4,6 +4,7 @@ interface AppProps {
4
4
  baseCommitish: string;
5
5
  mode?: string;
6
6
  repoPath?: string;
7
+ contextLines?: number;
7
8
  }
8
9
  declare const App: React.FC<AppProps>;
9
10
  export default App;
package/dist/tui/App.js CHANGED
@@ -6,7 +6,7 @@ import DiffViewer from './components/DiffViewer.js';
6
6
  import FileList from './components/FileList.js';
7
7
  import SideBySideDiffViewer from './components/SideBySideDiffViewer.js';
8
8
  import StatusBar from './components/StatusBar.js';
9
- const App = ({ targetCommitish, baseCommitish, mode, repoPath }) => {
9
+ const App = ({ targetCommitish, baseCommitish, mode, repoPath, contextLines, }) => {
10
10
  const [files, setFiles] = useState([]);
11
11
  const [selectedFileIndex, setSelectedFileIndex] = useState(0);
12
12
  const [loading, setLoading] = useState(true);
@@ -17,7 +17,7 @@ const App = ({ targetCommitish, baseCommitish, mode, repoPath }) => {
17
17
  setLoading(true);
18
18
  setError(null);
19
19
  try {
20
- const fileDiffs = await loadGitDiff(targetCommitish, baseCommitish, repoPath);
20
+ const fileDiffs = await loadGitDiff(targetCommitish, baseCommitish, repoPath, contextLines);
21
21
  setFiles(fileDiffs);
22
22
  setLoading(false);
23
23
  }
@@ -34,6 +34,18 @@ export interface ParsedDiff {
34
34
  export type DiffViewMode = 'split' | 'unified';
35
35
  export type LegacyDiffViewMode = 'side-by-side' | 'inline';
36
36
  export type DiffSide = 'old' | 'new';
37
+ export type DiffLineRange = number | {
38
+ start: number;
39
+ end: number;
40
+ };
41
+ export interface DiffCommentPosition {
42
+ side: DiffSide;
43
+ line: DiffLineRange;
44
+ }
45
+ export interface DiffCommentCodeSnapshot {
46
+ content: string;
47
+ language?: string;
48
+ }
37
49
  export interface DiffResponse {
38
50
  commit: string;
39
51
  files: DiffFile[];
@@ -47,6 +59,8 @@ export interface DiffResponse {
47
59
  requestedTargetCommitish?: string;
48
60
  clearComments?: boolean;
49
61
  repositoryId?: string;
62
+ commentImports?: CommentImport[];
63
+ commentImportId?: string;
50
64
  }
51
65
  export interface GeneratedStatusResponse {
52
66
  path: string;
@@ -69,38 +83,82 @@ export interface LineSelection {
69
83
  side: DiffSide;
70
84
  lineNumber: number;
71
85
  }
72
- export interface DiffComment {
86
+ export interface LegacyDiffComment {
73
87
  id: string;
74
88
  filePath: string;
75
89
  body: string;
76
90
  author?: string;
77
91
  createdAt: string;
78
92
  updatedAt: string;
79
- position: {
80
- side: DiffSide;
81
- line: number | {
82
- start: number;
83
- end: number;
84
- };
85
- };
86
- codeSnapshot?: {
87
- content: string;
88
- language?: string;
89
- };
93
+ position: DiffCommentPosition;
94
+ codeSnapshot?: DiffCommentCodeSnapshot;
95
+ }
96
+ export interface DiffCommentMessage {
97
+ id: string;
98
+ body: string;
99
+ author?: string;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ }
103
+ export interface DiffCommentThread {
104
+ id: string;
105
+ filePath: string;
106
+ createdAt: string;
107
+ updatedAt: string;
108
+ position: DiffCommentPosition;
109
+ codeSnapshot?: DiffCommentCodeSnapshot;
110
+ messages: DiffCommentMessage[];
111
+ }
112
+ interface CommentImportBase {
113
+ id?: string;
114
+ filePath: string;
115
+ position: DiffCommentPosition;
116
+ body: string;
117
+ author?: string;
118
+ createdAt?: string;
119
+ updatedAt?: string;
120
+ codeSnapshot?: DiffCommentCodeSnapshot;
90
121
  }
122
+ export interface ThreadCommentImport extends CommentImportBase {
123
+ type: 'thread';
124
+ }
125
+ export interface ReplyCommentImport extends CommentImportBase {
126
+ type: 'reply';
127
+ }
128
+ export type CommentImport = ThreadCommentImport | ReplyCommentImport;
91
129
  export interface ViewedFileRecord {
92
130
  filePath: string;
93
131
  viewedAt: string;
94
132
  diffContentHash: string;
95
133
  }
96
- export interface DiffContextStorage {
134
+ export interface LegacyDiffContextStorage {
97
135
  version: 1;
98
136
  baseCommitish: string;
99
137
  targetCommitish: string;
100
138
  createdAt: string;
101
139
  lastModifiedAt: string;
102
- comments: DiffComment[];
140
+ comments: LegacyDiffComment[];
141
+ viewedFiles: ViewedFileRecord[];
142
+ }
143
+ export interface DiffContextStorage {
144
+ version: 2;
145
+ baseCommitish: string;
146
+ targetCommitish: string;
147
+ createdAt: string;
148
+ lastModifiedAt: string;
149
+ threads: DiffCommentThread[];
103
150
  viewedFiles: ViewedFileRecord[];
151
+ appliedCommentImportIds: string[];
152
+ }
153
+ export interface CommentThread {
154
+ id: string;
155
+ file: string;
156
+ line: LineNumber;
157
+ side?: DiffSide;
158
+ createdAt: string;
159
+ updatedAt: string;
160
+ codeContent?: string;
161
+ messages: DiffCommentMessage[];
104
162
  }
105
163
  export interface RevisionOption {
106
164
  value: string;
@@ -119,6 +177,7 @@ export interface RevisionsResponse {
119
177
  specialOptions: RevisionOption[];
120
178
  branches: BranchInfo[];
121
179
  commits: CommitInfo[];
180
+ originDefaultBranch?: string;
122
181
  resolvedBase?: string;
123
182
  resolvedTarget?: string;
124
183
  }
@@ -140,3 +199,4 @@ export interface ExpandedRange {
140
199
  export interface ExpandedLine extends DiffLine {
141
200
  isExpanded?: boolean;
142
201
  }
202
+ export {};