diff-easy 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 (172) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +142 -0
  3. package/dist/cli/cmux.d.ts +9 -0
  4. package/dist/cli/cmux.js +274 -0
  5. package/dist/cli/comment.d.ts +2 -0
  6. package/dist/cli/comment.js +91 -0
  7. package/dist/cli/github.d.ts +84 -0
  8. package/dist/cli/github.js +364 -0
  9. package/dist/cli/index.d.ts +2 -0
  10. package/dist/cli/index.js +397 -0
  11. package/dist/cli/turns.d.ts +14 -0
  12. package/dist/cli/turns.js +333 -0
  13. package/dist/cli/utils.d.ts +27 -0
  14. package/dist/cli/utils.js +204 -0
  15. package/dist/client/assets/arc-CHYjdFTk.js +1 -0
  16. package/dist/client/assets/architecture-7EHR7CIX-weHBYDgM.js +1 -0
  17. package/dist/client/assets/architectureDiagram-3BPJPVTR-DRa2msuJ.js +36 -0
  18. package/dist/client/assets/array-BifhSqXX.js +1 -0
  19. package/dist/client/assets/blockDiagram-GPEHLZMM-BTdKsXy5.js +132 -0
  20. package/dist/client/assets/c4Diagram-AAUBKEIU-CwTvCkOg.js +10 -0
  21. package/dist/client/assets/channel-Cv40nQW1.js +1 -0
  22. package/dist/client/assets/chunk-2J33WTMH-BFsjrTtc.js +1 -0
  23. package/dist/client/assets/chunk-3OPIFGDE-V_xVw1dq.js +62 -0
  24. package/dist/client/assets/chunk-4BX2VUAB-DoLO6LLy.js +1 -0
  25. package/dist/client/assets/chunk-55IACEB6-CHgBwbdB.js +1 -0
  26. package/dist/client/assets/chunk-5ZQYHXKU-DMixAgQG.js +2 -0
  27. package/dist/client/assets/chunk-727SXJPM-C-3FZTmm.js +206 -0
  28. package/dist/client/assets/chunk-AQP2D5EJ-DpQZhXt-.js +231 -0
  29. package/dist/client/assets/chunk-BSJP7CBP-BnO9E_G4.js +1 -0
  30. package/dist/client/assets/chunk-CSCIHK7Q-BxItphyO.js +124 -0
  31. package/dist/client/assets/chunk-FMBD7UC4-4hEcfvfT.js +15 -0
  32. package/dist/client/assets/chunk-KSCS5N6A-DnArkpOI.js +10 -0
  33. package/dist/client/assets/chunk-L5ZTLDWV-DQoYJ1BW.js +1 -0
  34. package/dist/client/assets/chunk-LZXEDZCA-CdCevBX-.js +2 -0
  35. package/dist/client/assets/chunk-ND2GUHAM-Dt89U_nU.js +1 -0
  36. package/dist/client/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
  37. package/dist/client/assets/chunk-NZK2D7GU-C2Ev_f_w.js +1 -0
  38. package/dist/client/assets/chunk-O5CBEL6O-BeZ3MFnJ.js +70 -0
  39. package/dist/client/assets/chunk-QZHKN3VN-BmDOSWHp.js +1 -0
  40. package/dist/client/assets/chunk-WU5MYG2G-gLuInTk4.js +1 -0
  41. package/dist/client/assets/chunk-XPW4576I-DPbDc8_T.js +32 -0
  42. package/dist/client/assets/classDiagram-4FO5ZUOK-DOpVKUrb.js +1 -0
  43. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-DOpVKUrb.js +1 -0
  44. package/dist/client/assets/cose-bilkent-S5V4N54A-CCPyalms.js +1 -0
  45. package/dist/client/assets/cytoscape.esm-FqbQrHcz.js +321 -0
  46. package/dist/client/assets/dagre-BM42HDAG-6jrYMjX3.js +4 -0
  47. package/dist/client/assets/dagre-Bx709z4p.js +1 -0
  48. package/dist/client/assets/defaultLocale-C8Fc0cco.js +1 -0
  49. package/dist/client/assets/diagram-2AECGRRQ-dCQ_KoXl.js +43 -0
  50. package/dist/client/assets/diagram-5GNKFQAL-B3Xj4-XE.js +10 -0
  51. package/dist/client/assets/diagram-KO2AKTUF-DaGgF2yL.js +3 -0
  52. package/dist/client/assets/diagram-LMA3HP47-CF_c4tPT.js +24 -0
  53. package/dist/client/assets/diagram-OG6HWLK6-BgBHbJkT.js +24 -0
  54. package/dist/client/assets/dist-7wVkzZne.js +1 -0
  55. package/dist/client/assets/erDiagram-TEJ5UH35-BZgX1grR.js +85 -0
  56. package/dist/client/assets/eventmodeling-FCH6USID-BaI6RNKr.js +1 -0
  57. package/dist/client/assets/flowDiagram-I6XJVG4X-BFNUa12V.js +162 -0
  58. package/dist/client/assets/ganttDiagram-6RSMTGT7-C8t6BThI.js +292 -0
  59. package/dist/client/assets/gitGraph-WXDBUCRP-BlicfDkM.js +1 -0
  60. package/dist/client/assets/gitGraphDiagram-PVQCEYII-BmnIXuXP.js +106 -0
  61. package/dist/client/assets/graphlib-B8gBHxth.js +1 -0
  62. package/dist/client/assets/index-BfVtbKTZ.css +2 -0
  63. package/dist/client/assets/index-p8zzfjdu.js +87 -0
  64. package/dist/client/assets/info-J43DQDTF-DHLG38N3.js +1 -0
  65. package/dist/client/assets/infoDiagram-5YYISTIA-oQ-lq-45.js +2 -0
  66. package/dist/client/assets/init-D6jRqBbL.js +1 -0
  67. package/dist/client/assets/ishikawaDiagram-YF4QCWOH-DrODlBLD.js +70 -0
  68. package/dist/client/assets/journeyDiagram-JHISSGLW-Bxg5wCQz.js +139 -0
  69. package/dist/client/assets/kanban-definition-UN3LZRKU-DAdvUDIB.js +89 -0
  70. package/dist/client/assets/katex-Vhh-h91d.js +257 -0
  71. package/dist/client/assets/line-D8kNd46u.js +1 -0
  72. package/dist/client/assets/linear-jwGZSrg4.js +1 -0
  73. package/dist/client/assets/mermaid-parser.core-PVPfUfyU.js +4 -0
  74. package/dist/client/assets/mermaid.core-DwdP6CFg.js +9 -0
  75. package/dist/client/assets/mindmap-definition-RKZ34NQL-DeOoL56Q.js +96 -0
  76. package/dist/client/assets/ordinal-hYBb2elL.js +1 -0
  77. package/dist/client/assets/packet-YPE3B663-BBEaxdJP.js +1 -0
  78. package/dist/client/assets/path-BWPyau1x.js +1 -0
  79. package/dist/client/assets/pie-LRSECV5Y-fvTRFm-2.js +1 -0
  80. package/dist/client/assets/pieDiagram-4H26LBE5-oep1px2r.js +30 -0
  81. package/dist/client/assets/prism-bash-D6zCJ74D.js +1 -0
  82. package/dist/client/assets/prism-clojure-_z-zoNcE.js +1 -0
  83. package/dist/client/assets/prism-csharp-XPn2bw0d.js +1 -0
  84. package/dist/client/assets/prism-dart-CDv16Fbu.js +1 -0
  85. package/dist/client/assets/prism-elixir-DhHhV5Rj.js +1 -0
  86. package/dist/client/assets/prism-haskell-5hq1Nh9c.js +1 -0
  87. package/dist/client/assets/prism-hcl-B6Z0Ouoo.js +1 -0
  88. package/dist/client/assets/prism-java-6wtusvIl.js +1 -0
  89. package/dist/client/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  90. package/dist/client/assets/prism-nix-C88dTX93.js +1 -0
  91. package/dist/client/assets/prism-perl-Lsbi34sH.js +1 -0
  92. package/dist/client/assets/prism-php-Co4fM40K.js +1 -0
  93. package/dist/client/assets/prism-protobuf-yzSHlhwy.js +1 -0
  94. package/dist/client/assets/prism-ruby-DFCASkuD.js +1 -0
  95. package/dist/client/assets/prism-scala-VkM3wCOL.js +1 -0
  96. package/dist/client/assets/prism-solidity-DTom4k5p.js +1 -0
  97. package/dist/client/assets/prism-sql-CYhVDMEE.js +1 -0
  98. package/dist/client/assets/prism-vim-BgpkjCK5.js +1 -0
  99. package/dist/client/assets/quadrantDiagram-W4KKPZXB-D9l66CHb.js +7 -0
  100. package/dist/client/assets/radar-GUYGQ44K-DAU5EPVd.js +1 -0
  101. package/dist/client/assets/requirementDiagram-4Y6WPE33-helpQlwb.js +84 -0
  102. package/dist/client/assets/rough.esm-CSKSodPl.js +1 -0
  103. package/dist/client/assets/sankeyDiagram-5OEKKPKP-C9sNuuvA.js +40 -0
  104. package/dist/client/assets/sequenceDiagram-3UESZ5HK-BP0bhCwQ.js +162 -0
  105. package/dist/client/assets/src-CDAPGf2l.js +1 -0
  106. package/dist/client/assets/stateDiagram-AJRCARHV-CsFizpla.js +1 -0
  107. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Bvn8tanl.js +1 -0
  108. package/dist/client/assets/timeline-definition-PNZ67QCA-BnkmHs1F.js +120 -0
  109. package/dist/client/assets/treeView-BLDUP644-DD9HZO5W.js +1 -0
  110. package/dist/client/assets/treemap-LRROVOQU-BBKuQ1lo.js +1 -0
  111. package/dist/client/assets/vennDiagram-CIIHVFJN-BPFTHflF.js +34 -0
  112. package/dist/client/assets/wardley-L42UT6IY-y88gDkwb.js +1 -0
  113. package/dist/client/assets/wardleyDiagram-YWT4CUSO-BlcA7IVh.js +78 -0
  114. package/dist/client/assets/xychartDiagram-2RQKCTM6-DX5vUCRH.js +7 -0
  115. package/dist/client/favicon.png +0 -0
  116. package/dist/client/index.html +14 -0
  117. package/dist/client/logo.png +0 -0
  118. package/dist/client/site-data/blobs/080c0e6/cHVibGljL3NpdGUtZGF0YS9vZy1pbWFnZS5wbmc.png +0 -0
  119. package/dist/client/site-data/blobs/55f23a1/bGFuZGluZy9wdWJsaWMvZGlmaXQvbG9nby5wbmc.png +0 -0
  120. package/dist/client/site-data/blobs/66ff7c6/cHVibGljL2xvZ28ucG5n.png +0 -0
  121. package/dist/client/site-data/blobs/e6977fe/cHVibGljL2xvZ28ucG5n.png +0 -0
  122. package/dist/client/site-data/manifest.json +1 -0
  123. package/dist/client/site-data/og-image.png +0 -0
  124. package/dist/client/site-data/snapshots/55f23a1...080c0e6.json +1 -0
  125. package/dist/client/site-data/snapshots/66ff7c6...e6977fe.json +1 -0
  126. package/dist/client/site-data/snapshots/7d40fd4...a72112f-comments.json +1 -0
  127. package/dist/client/site-data/snapshots/7d40fd4...a72112f.json +1 -0
  128. package/dist/server/file-watcher.d.ts +33 -0
  129. package/dist/server/file-watcher.js +340 -0
  130. package/dist/server/generated-file-check.d.ts +17 -0
  131. package/dist/server/generated-file-check.js +280 -0
  132. package/dist/server/git-diff.d.ts +94 -0
  133. package/dist/server/git-diff.js +909 -0
  134. package/dist/server/grouping/cache.d.ts +3 -0
  135. package/dist/server/grouping/cache.js +22 -0
  136. package/dist/server/grouping/claudeCli.d.ts +10 -0
  137. package/dist/server/grouping/claudeCli.js +95 -0
  138. package/dist/server/grouping/digest.d.ts +15 -0
  139. package/dist/server/grouping/digest.js +38 -0
  140. package/dist/server/grouping/reconcile.d.ts +6 -0
  141. package/dist/server/grouping/reconcile.js +34 -0
  142. package/dist/server/grouping/types.d.ts +9 -0
  143. package/dist/server/grouping/types.js +1 -0
  144. package/dist/server/server.d.ts +37 -0
  145. package/dist/server/server.js +1283 -0
  146. package/dist/shared/grouping.d.ts +4 -0
  147. package/dist/shared/grouping.js +170 -0
  148. package/dist/types/diff.d.ts +263 -0
  149. package/dist/types/diff.js +1 -0
  150. package/dist/types/watch.d.ts +59 -0
  151. package/dist/types/watch.js +8 -0
  152. package/dist/utils/commentFormatting.d.ts +6 -0
  153. package/dist/utils/commentFormatting.js +90 -0
  154. package/dist/utils/commentImports.d.ts +11 -0
  155. package/dist/utils/commentImports.js +386 -0
  156. package/dist/utils/createId.d.ts +1 -0
  157. package/dist/utils/createId.js +5 -0
  158. package/dist/utils/diffMode.d.ts +3 -0
  159. package/dist/utils/diffMode.js +13 -0
  160. package/dist/utils/diffSelection.d.ts +13 -0
  161. package/dist/utils/diffSelection.js +39 -0
  162. package/dist/utils/editorOptions.d.ts +60 -0
  163. package/dist/utils/editorOptions.js +171 -0
  164. package/dist/utils/fileUtils.d.ts +12 -0
  165. package/dist/utils/fileUtils.js +21 -0
  166. package/dist/utils/hubConfig.d.ts +32 -0
  167. package/dist/utils/hubConfig.js +51 -0
  168. package/dist/utils/suggestionUtils.d.ts +19 -0
  169. package/dist/utils/suggestionUtils.js +31 -0
  170. package/dist/utils/turnLog.d.ts +47 -0
  171. package/dist/utils/turnLog.js +133 -0
  172. package/package.json +105 -0
@@ -0,0 +1,1283 @@
1
+ import { execFileSync, spawn } from 'child_process';
2
+ import { createHash } from 'crypto';
3
+ import { existsSync, readdirSync } from 'fs';
4
+ import { basename, join, dirname, isAbsolute, resolve, sep } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import express from 'express';
7
+ import open from 'open';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ import { DiffMode } from '../types/watch.js';
11
+ import { formatCommentsOutput } from '../utils/commentFormatting.js';
12
+ import { mergeCommentImports, mergeCommentThreads, normalizeCommentImports, serializeCommentImports, } from '../utils/commentImports.js';
13
+ import { buildEditorSpawnSpec, CUSTOM_EDITOR_ID, NONE_EDITOR_ID, resolveEditorOption, } from '../utils/editorOptions.js';
14
+ import { getFileExtension } from '../utils/fileUtils.js';
15
+ import { countTurnsSince, deleteHubInfo, deleteServerInfo, latestTurnTimestamp, readTurnLog, writeHubInfo, writeServerInfo, } from '../utils/turnLog.js';
16
+ import { pushRecentFolder, readHubConfig, writeHubConfig, } from '../utils/hubConfig.js';
17
+ import { getPrCommentImports, getPrDetails, getPrPatch } from '../cli/github.js';
18
+ import { FileWatcherService } from './file-watcher.js';
19
+ import { GitDiffParser } from './git-diff.js';
20
+ import { groupWithClaude } from './grouping/claudeCli.js';
21
+ import { readCache, writeCache } from './grouping/cache.js';
22
+ import { groupSmart } from '../shared/grouping.js';
23
+ import { createDiffSelection, diffSelectionsEqual, getDiffSelectionKey, isWorkingTreeTarget, } from '../utils/diffSelection.js';
24
+ const GENERATED_STATUS_CACHE_TTL_MS = 60_000;
25
+ const MAX_DIFF_CACHE_ENTRIES = 8;
26
+ function createDiffCacheKey(selection, ignoreWhitespace) {
27
+ return `${getDiffSelectionKey(selection)}\u0000${ignoreWhitespace ? '1' : '0'}`;
28
+ }
29
+ function getCachedDiffResponse(cache, key) {
30
+ const cached = cache.get(key);
31
+ if (!cached) {
32
+ return undefined;
33
+ }
34
+ // Refresh insertion order to keep the most recently used entry.
35
+ cache.delete(key);
36
+ cache.set(key, cached);
37
+ return cached;
38
+ }
39
+ function setCachedDiffResponse(cache, key, value) {
40
+ if (cache.has(key)) {
41
+ cache.delete(key);
42
+ }
43
+ cache.set(key, value);
44
+ while (cache.size > MAX_DIFF_CACHE_ENTRIES) {
45
+ const oldestKey = cache.keys().next().value;
46
+ if (typeof oldestKey !== 'string') {
47
+ break;
48
+ }
49
+ cache.delete(oldestKey);
50
+ }
51
+ }
52
+ function createResolvedCommentSelection(responseDiffData, fallbackSelection, stdinDiff) {
53
+ const baseCommitish = responseDiffData.baseCommitish ?? (stdinDiff ? 'stdin' : fallbackSelection.baseCommitish);
54
+ const targetCommitish = responseDiffData.targetCommitish ?? (stdinDiff ? 'stdin' : fallbackSelection.targetCommitish);
55
+ const baseMode = responseDiffData.requestedBaseMode ?? fallbackSelection.baseMode;
56
+ return createDiffSelection(baseCommitish, targetCommitish, baseMode);
57
+ }
58
+ function createCommentSessionKey(selection) {
59
+ return getDiffSelectionKey(selection);
60
+ }
61
+ /**
62
+ * Disable git's optional index-refresh lock for every git child this process
63
+ * spawns. Read commands (git status/diff) otherwise opportunistically rewrite
64
+ * `.git/index`'s stat cache; the file watcher treats that as a change and
65
+ * triggers an endless reload loop (fetch → index rewrite → reload → fetch …).
66
+ * `??=` respects an explicit operator override.
67
+ */
68
+ export function disableGitOptionalLocks() {
69
+ process.env.GIT_OPTIONAL_LOCKS ??= '0';
70
+ }
71
+ export async function startServer(options) {
72
+ // Set once at the server entry — before any GitDiffParser or the file watcher
73
+ // is created — rather than as a per-instance constructor side effect.
74
+ disableGitOptionalLocks();
75
+ const app = express();
76
+ const initialCommentImports = options.commentImports || [];
77
+ const initialSelection = options.selection ?? createDiffSelection('', '');
78
+ const commentImportId = initialCommentImports.length > 0
79
+ ? createHash('sha256').update(serializeCommentImports(initialCommentImports)).digest('hex')
80
+ : undefined;
81
+ // diff-easy: the active workspace. In hub mode it starts null (the UI picks a
82
+ // source at runtime); otherwise it's the repo fixed at startup. All readers go
83
+ // through `currentWorkspace` so the runtime swap is a localized change.
84
+ let currentWorkspace = null;
85
+ if (!options.hub) {
86
+ const repositoryPath = resolve(options.repoPath ?? process.cwd());
87
+ const repositoryId = createHash('sha256').update(repositoryPath).digest('hex');
88
+ currentWorkspace = {
89
+ repoPath: repositoryPath,
90
+ repoId: repositoryId,
91
+ parser: new GitDiffParser(repositoryPath),
92
+ // stdin / --pr launch is a static-patch workspace.
93
+ patch: options.stdinDiff,
94
+ commentImports: initialCommentImports.length > 0 ? initialCommentImports : undefined,
95
+ commentImportId,
96
+ prTitle: options.prTitle,
97
+ prBody: options.prBody,
98
+ };
99
+ }
100
+ const fileWatcher = new FileWatcherService();
101
+ const generatedStatusCache = new Map();
102
+ const diffDataCache = new Map();
103
+ const initialIgnoreWhitespace = options.ignoreWhitespace || false;
104
+ const parseBaseMode = (value) => {
105
+ if (value === 'merge-base') {
106
+ return 'merge-base';
107
+ }
108
+ return undefined;
109
+ };
110
+ app.use(express.json());
111
+ app.use(express.text()); // For sendBeacon text/plain requests
112
+ app.use((_req, res, next) => {
113
+ res.header('Access-Control-Allow-Origin', 'http://localhost:*');
114
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
115
+ res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
116
+ next();
117
+ });
118
+ // Generate initial diff data for the isEmpty check. In hub mode there is no
119
+ // workspace yet, so there is nothing to validate or pre-render.
120
+ let initialDiffData = null;
121
+ if (currentWorkspace) {
122
+ const workspace = currentWorkspace; // non-null; survives await narrowing
123
+ // Skip validation for a static-patch (stdin/PR) workspace.
124
+ if (!workspace.patch) {
125
+ const isValidCommit = await workspace.parser.validateCommit(initialSelection.targetCommitish);
126
+ if (!isValidCommit) {
127
+ throw new Error(`Invalid or non-existent commit: ${initialSelection.targetCommitish}`);
128
+ }
129
+ }
130
+ if (workspace.patch) {
131
+ // Parse the static patch directly
132
+ initialDiffData = workspace.parser.parseStdinDiff(workspace.patch);
133
+ }
134
+ else {
135
+ initialDiffData = await workspace.parser.parseDiff(initialSelection, initialIgnoreWhitespace, options.contextLines);
136
+ setCachedDiffResponse(diffDataCache, createDiffCacheKey(initialSelection, initialIgnoreWhitespace), initialDiffData);
137
+ }
138
+ }
139
+ // Function to invalidate cache when file changes are detected
140
+ const invalidateCache = () => {
141
+ diffDataCache.clear();
142
+ generatedStatusCache.clear();
143
+ currentWorkspace?.parser.clearResolvedCommitCache();
144
+ };
145
+ // Track current revisions for cache invalidation
146
+ let currentSelection = initialSelection;
147
+ let currentCommentSelection = initialDiffData
148
+ ? createResolvedCommentSelection(initialDiffData, initialSelection, Boolean(currentWorkspace?.patch))
149
+ : initialSelection;
150
+ function parseRepositoryRelativePath(filepath) {
151
+ if (!currentWorkspace) {
152
+ return { ok: false, error: 'File path outside repository' };
153
+ }
154
+ if (typeof filepath !== 'string' || filepath.length === 0) {
155
+ return { ok: false, error: 'Invalid file path' };
156
+ }
157
+ const normalizedFilepath = filepath.replace(/\\/g, '/');
158
+ const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
159
+ if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
160
+ return { ok: false, error: 'File path outside repository' };
161
+ }
162
+ const resolvedPath = resolve(currentWorkspace.repoPath, normalizedFilepath);
163
+ if (resolvedPath !== currentWorkspace.repoPath &&
164
+ !resolvedPath.startsWith(`${currentWorkspace.repoPath}${sep}`)) {
165
+ return { ok: false, error: 'File path outside repository' };
166
+ }
167
+ return { ok: true, path: normalizedFilepath };
168
+ }
169
+ function parseEditorRequest(value) {
170
+ if (!value || typeof value !== 'object') {
171
+ return { id: undefined, command: undefined, argsTemplate: undefined };
172
+ }
173
+ const candidate = value;
174
+ return {
175
+ id: typeof candidate.id === 'string' ? candidate.id : undefined,
176
+ command: typeof candidate.command === 'string' ? candidate.command : undefined,
177
+ argsTemplate: typeof candidate.argsTemplate === 'string' ? candidate.argsTemplate : undefined,
178
+ };
179
+ }
180
+ const commentSessions = new Map();
181
+ const initialCommentThreads = mergeCommentImports([], initialCommentImports).threads;
182
+ if (initialCommentThreads.length > 0) {
183
+ commentSessions.set(createCommentSessionKey(currentCommentSelection), {
184
+ threads: initialCommentThreads,
185
+ version: 1,
186
+ });
187
+ }
188
+ function getCommentSelectionFromQuery(query) {
189
+ const hasBase = typeof query.base === 'string';
190
+ const hasTarget = typeof query.target === 'string';
191
+ const hasBaseMode = typeof query.baseMode === 'string';
192
+ if (!hasBase && !hasTarget && !hasBaseMode) {
193
+ return currentCommentSelection;
194
+ }
195
+ return createDiffSelection(hasBase ? query.base : currentCommentSelection.baseCommitish, hasTarget ? query.target : currentCommentSelection.targetCommitish, hasBaseMode
196
+ ? parseBaseMode(query.baseMode)
197
+ : hasBase || hasTarget
198
+ ? undefined
199
+ : currentCommentSelection.baseMode);
200
+ }
201
+ function getOrCreateCommentSession(selection) {
202
+ const key = createCommentSessionKey(selection);
203
+ const existing = commentSessions.get(key);
204
+ if (existing) {
205
+ return existing;
206
+ }
207
+ const nextSession = {
208
+ threads: [],
209
+ version: 0,
210
+ };
211
+ commentSessions.set(key, nextSession);
212
+ return nextSession;
213
+ }
214
+ app.get('/api/diff', async (req, res) => {
215
+ const workspace = currentWorkspace;
216
+ if (!workspace) {
217
+ res.json({ needsWorkspace: true });
218
+ return;
219
+ }
220
+ const ignoreWhitespace = req.query.ignoreWhitespace === 'true';
221
+ const hasBase = typeof req.query.base === 'string';
222
+ const hasTarget = typeof req.query.target === 'string';
223
+ const hasBaseMode = typeof req.query.baseMode === 'string';
224
+ const requestedSelection = createDiffSelection(hasBase ? req.query.base : currentSelection.baseCommitish, hasTarget ? req.query.target : currentSelection.targetCommitish, hasBaseMode
225
+ ? parseBaseMode(req.query.baseMode)
226
+ : hasBase || hasTarget
227
+ ? undefined
228
+ : currentSelection.baseMode);
229
+ const isStatic = Boolean(workspace.patch);
230
+ const wsImports = workspace.commentImports ?? [];
231
+ const shouldIncludeCommentImports = wsImports.length > 0 &&
232
+ (isStatic || diffSelectionsEqual(requestedSelection, initialSelection));
233
+ currentSelection = requestedSelection;
234
+ let responseDiffData;
235
+ let diffError;
236
+ if (isStatic) {
237
+ // Static patch (stdin / PR): the diff is the workspace's patch.
238
+ responseDiffData = workspace.parser.parseStdinDiff(workspace.patch);
239
+ }
240
+ else {
241
+ const cacheKey = createDiffCacheKey(requestedSelection, ignoreWhitespace);
242
+ // Working-tree targets ('.'/'working'/'staged') are mutable: the same
243
+ // selection yields a different diff as the tree/index/HEAD change. The
244
+ // diff cache is keyed by the selection string and only evicted by the file
245
+ // watcher, so a single missed event (e.g. a commit) would strand a stale
246
+ // diff until a server restart. Never cache them — re-parsing is cheap and
247
+ // correctness beats the micro-optimization. Immutable ranges still cache.
248
+ const mutableTarget = isWorkingTreeTarget(requestedSelection.targetCommitish);
249
+ const cached = mutableTarget ? null : getCachedDiffResponse(diffDataCache, cacheKey);
250
+ if (cached) {
251
+ responseDiffData = cached;
252
+ }
253
+ else {
254
+ try {
255
+ responseDiffData = await workspace.parser.parseDiff(requestedSelection, ignoreWhitespace, options.contextLines);
256
+ if (!mutableTarget)
257
+ setCachedDiffResponse(diffDataCache, cacheKey, responseDiffData);
258
+ generatedStatusCache.clear();
259
+ }
260
+ catch (error) {
261
+ // diff-easy: degrade gracefully instead of 500-ing the page. A common
262
+ // cause is a turn snapshot commit that was pruned by `git gc`.
263
+ diffError = error instanceof Error ? error.message : 'Failed to load diff';
264
+ responseDiffData = {
265
+ commit: 'Unable to load diff',
266
+ files: [],
267
+ isEmpty: true,
268
+ baseCommitish: requestedSelection.baseCommitish,
269
+ targetCommitish: requestedSelection.targetCommitish,
270
+ requestedBaseCommitish: requestedSelection.baseCommitish,
271
+ requestedTargetCommitish: requestedSelection.targetCommitish,
272
+ requestedBaseMode: requestedSelection.baseMode,
273
+ };
274
+ }
275
+ }
276
+ }
277
+ if (!responseDiffData) {
278
+ res.json({ needsWorkspace: true });
279
+ return;
280
+ }
281
+ currentCommentSelection = createResolvedCommentSelection(responseDiffData, requestedSelection, isStatic);
282
+ const baseCommitish = responseDiffData.baseCommitish ?? (isStatic ? 'stdin' : undefined);
283
+ const targetCommitish = responseDiffData.targetCommitish ?? (isStatic ? 'stdin' : undefined);
284
+ const requestedBaseCommitish = responseDiffData.requestedBaseCommitish ??
285
+ (requestedSelection.baseCommitish || (isStatic ? 'stdin' : undefined));
286
+ const requestedTargetCommitish = responseDiffData.requestedTargetCommitish ??
287
+ (requestedSelection.targetCommitish || (isStatic ? 'stdin' : undefined));
288
+ const requestedBaseMode = responseDiffData.requestedBaseMode ?? requestedSelection.baseMode;
289
+ res.json({
290
+ ...responseDiffData,
291
+ ignoreWhitespace,
292
+ openInEditorAvailable: !isStatic,
293
+ baseCommitish,
294
+ targetCommitish,
295
+ requestedBaseCommitish,
296
+ requestedTargetCommitish,
297
+ requestedBaseMode,
298
+ clearComments: options.clearComments,
299
+ repositoryId: workspace.repoId,
300
+ repoName: isStatic ? undefined : basename(workspace.repoPath),
301
+ repoPath: isStatic ? undefined : workspaceLabel(workspace.repoPath),
302
+ commentImports: shouldIncludeCommentImports ? wsImports : undefined,
303
+ commentImportId: shouldIncludeCommentImports ? workspace.commentImportId : undefined,
304
+ prTitle: isStatic ? workspace.prTitle : undefined,
305
+ prBody: isStatic ? workspace.prBody : undefined,
306
+ diffError,
307
+ });
308
+ });
309
+ app.get(/^\/api\/generated-status\/(.*)$/, async (req, res) => {
310
+ if (currentWorkspace?.patch) {
311
+ res.status(400).json({ error: 'Generated status is not available for stdin diff' });
312
+ return;
313
+ }
314
+ const workspace = currentWorkspace;
315
+ if (!workspace) {
316
+ res.status(409).json({ error: 'No workspace selected' });
317
+ return;
318
+ }
319
+ try {
320
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
321
+ if (!filepathResult.ok) {
322
+ res.status(400).json({ error: filepathResult.error });
323
+ return;
324
+ }
325
+ const normalizedFilepath = filepathResult.path;
326
+ const ref = req.query.ref || currentSelection.targetCommitish || 'HEAD';
327
+ const cacheKey = `${ref}:${normalizedFilepath}`;
328
+ const now = Date.now();
329
+ const cached = generatedStatusCache.get(cacheKey);
330
+ if (cached && cached.expiresAt > now) {
331
+ res.json(cached.value);
332
+ return;
333
+ }
334
+ const status = await workspace.parser.getGeneratedStatus(normalizedFilepath, ref);
335
+ const response = {
336
+ path: normalizedFilepath,
337
+ ref,
338
+ ...status,
339
+ };
340
+ generatedStatusCache.set(cacheKey, {
341
+ value: response,
342
+ expiresAt: now + GENERATED_STATUS_CACHE_TTL_MS,
343
+ });
344
+ res.json(response);
345
+ }
346
+ catch (error) {
347
+ console.error('Error fetching generated status:', error);
348
+ res.status(500).json({ error: 'Failed to get generated status' });
349
+ }
350
+ });
351
+ // Get available revisions for revision selector
352
+ app.get('/api/revisions', async (_req, res) => {
353
+ if (currentWorkspace?.patch) {
354
+ res.status(400).json({ error: 'Revision selection not available for stdin diff' });
355
+ return;
356
+ }
357
+ const workspace = currentWorkspace;
358
+ if (!workspace) {
359
+ res.json({ needsWorkspace: true });
360
+ return;
361
+ }
362
+ try {
363
+ const { branches, commits, originDefaultBranch, resolvedBase, resolvedTarget, currentBranch, pgitParent, branchBase, branchBaseSource, } = await workspace.parser.getRevisionOptions(currentSelection.baseCommitish, currentSelection.targetCommitish);
364
+ const response = {
365
+ specialOptions: [
366
+ { value: '.', label: 'All Uncommitted Changes' },
367
+ { value: 'staged', label: 'Staging Area' },
368
+ { value: 'working', label: 'Working Directory' },
369
+ ],
370
+ branches,
371
+ commits,
372
+ originDefaultBranch,
373
+ resolvedBase,
374
+ resolvedTarget,
375
+ currentBranch,
376
+ pgitParent,
377
+ branchBase,
378
+ branchBaseSource,
379
+ repositoryId: workspace.repoId,
380
+ };
381
+ res.json(response);
382
+ }
383
+ catch (error) {
384
+ console.error('Error fetching revisions:', error);
385
+ res.status(500).json({ error: 'Failed to fetch revisions' });
386
+ }
387
+ });
388
+ app.get('/api/commits', async (req, res) => {
389
+ const workspace = currentWorkspace;
390
+ if (!workspace || workspace.patch) {
391
+ res.status(400).json({ error: 'Commits not available' });
392
+ return;
393
+ }
394
+ const target = typeof req.query.target === 'string' ? req.query.target : '';
395
+ const base = typeof req.query.base === 'string' && req.query.base ? req.query.base : undefined;
396
+ if (!target) {
397
+ res.status(400).json({ error: 'target is required' });
398
+ return;
399
+ }
400
+ try {
401
+ const commits = await workspace.parser.getCommitsInRange(base, target);
402
+ res.json({ commits });
403
+ }
404
+ catch (error) {
405
+ console.error('Error fetching commits:', error);
406
+ res.status(500).json({ error: 'Failed to fetch commits' });
407
+ }
408
+ });
409
+ app.get(/^\/api\/line-count\/(.*)$/, async (req, res) => {
410
+ try {
411
+ if (currentWorkspace?.patch) {
412
+ res.status(404).json({ error: 'Line count not available for stdin diff' });
413
+ return;
414
+ }
415
+ const workspace = currentWorkspace;
416
+ if (!workspace) {
417
+ res.status(409).json({ error: 'No workspace selected' });
418
+ return;
419
+ }
420
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
421
+ if (!filepathResult.ok) {
422
+ res.status(400).json({ error: filepathResult.error });
423
+ return;
424
+ }
425
+ const filepath = filepathResult.path;
426
+ const oldRef = req.query.oldRef;
427
+ const oldPathResult = req.query.oldPath
428
+ ? parseRepositoryRelativePath(req.query.oldPath)
429
+ : { ok: true, path: filepath };
430
+ if (!oldPathResult.ok) {
431
+ res.status(400).json({ error: oldPathResult.error });
432
+ return;
433
+ }
434
+ const newRef = req.query.newRef;
435
+ const oldPath = oldPathResult.path;
436
+ const result = {};
437
+ if (oldRef) {
438
+ try {
439
+ result.oldLineCount = await workspace.parser.getLineCount(oldPath, oldRef);
440
+ }
441
+ catch {
442
+ result.oldLineCount = 0;
443
+ }
444
+ }
445
+ if (newRef) {
446
+ try {
447
+ result.newLineCount = await workspace.parser.getLineCount(filepath, newRef);
448
+ }
449
+ catch {
450
+ result.newLineCount = 0;
451
+ }
452
+ }
453
+ res.json(result);
454
+ }
455
+ catch (error) {
456
+ console.error('Error fetching line count:', error);
457
+ res.status(500).json({ error: 'Failed to get line count' });
458
+ }
459
+ });
460
+ app.get(/^\/api\/blob\/(.*)$/, async (req, res) => {
461
+ try {
462
+ // If using stdin diff, blob content is not available
463
+ if (currentWorkspace?.patch) {
464
+ res.status(404).json({ error: 'Blob content not available for stdin diff' });
465
+ return;
466
+ }
467
+ const workspace = currentWorkspace;
468
+ if (!workspace) {
469
+ res.status(409).json({ error: 'No workspace selected' });
470
+ return;
471
+ }
472
+ const filepathResult = parseRepositoryRelativePath(req.params[0]);
473
+ if (!filepathResult.ok) {
474
+ res.status(400).json({ error: filepathResult.error });
475
+ return;
476
+ }
477
+ const filepath = filepathResult.path;
478
+ const ref = req.query.ref || 'HEAD';
479
+ const blob = await workspace.parser.getBlobContent(filepath, ref);
480
+ // Determine content type based on file extension
481
+ const ext = getFileExtension(filepath);
482
+ const contentTypes = {
483
+ jpg: 'image/jpeg',
484
+ jpeg: 'image/jpeg',
485
+ png: 'image/png',
486
+ gif: 'image/gif',
487
+ bmp: 'image/bmp',
488
+ svg: 'image/svg+xml',
489
+ webp: 'image/webp',
490
+ ico: 'image/x-icon',
491
+ tiff: 'image/tiff',
492
+ tif: 'image/tiff',
493
+ avif: 'image/avif',
494
+ heic: 'image/heic',
495
+ heif: 'image/heif',
496
+ };
497
+ const contentType = contentTypes[ext || ''] || 'application/octet-stream';
498
+ res.setHeader('Content-Type', contentType);
499
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
500
+ res.setHeader('Pragma', 'no-cache');
501
+ res.setHeader('Expires', '0');
502
+ res.send(blob);
503
+ }
504
+ catch (error) {
505
+ console.error('Error fetching blob:', error);
506
+ res.status(404).json({ error: 'File not found' });
507
+ }
508
+ });
509
+ function normalizeLineValue(line) {
510
+ if (Array.isArray(line) && line.length === 2) {
511
+ const start = line[0];
512
+ const end = line[1];
513
+ if (typeof start === 'number' &&
514
+ typeof end === 'number' &&
515
+ Number.isInteger(start) &&
516
+ Number.isInteger(end) &&
517
+ start > 0 &&
518
+ end > 0 &&
519
+ start <= end) {
520
+ return { start, end };
521
+ }
522
+ }
523
+ if (typeof line === 'number' && Number.isInteger(line) && line > 0) {
524
+ return line;
525
+ }
526
+ return 1;
527
+ }
528
+ function normalizeComment(comment) {
529
+ const now = new Date().toISOString();
530
+ const timestamp = typeof comment.timestamp === 'string' ? comment.timestamp : now;
531
+ const threadId = typeof comment.id === 'string' && comment.id.length > 0
532
+ ? comment.id
533
+ : createHash('sha256').update(JSON.stringify(comment)).digest('hex').slice(0, 12);
534
+ const filePath = typeof comment.file === 'string' && comment.file.length > 0 ? comment.file : '<unknown file>';
535
+ return {
536
+ id: threadId,
537
+ filePath,
538
+ createdAt: timestamp,
539
+ updatedAt: timestamp,
540
+ position: {
541
+ side: comment.side ?? 'new',
542
+ line: normalizeLineValue(comment.line),
543
+ },
544
+ codeSnapshot: typeof comment.codeContent === 'string'
545
+ ? {
546
+ content: comment.codeContent,
547
+ }
548
+ : undefined,
549
+ messages: [
550
+ {
551
+ id: threadId,
552
+ body: comment.body,
553
+ author: comment.author,
554
+ createdAt: timestamp,
555
+ updatedAt: timestamp,
556
+ },
557
+ ],
558
+ };
559
+ }
560
+ function toCommentThread(thread) {
561
+ return {
562
+ id: thread.id,
563
+ file: thread.filePath,
564
+ line: typeof thread.position.line === 'number'
565
+ ? thread.position.line
566
+ : [thread.position.line.start, thread.position.line.end],
567
+ side: thread.position.side,
568
+ createdAt: thread.createdAt,
569
+ updatedAt: thread.updatedAt,
570
+ codeContent: thread.codeSnapshot?.content,
571
+ messages: thread.messages,
572
+ };
573
+ }
574
+ function normalizeThreadPayload(thread) {
575
+ if ('filePath' in thread && 'position' in thread) {
576
+ return thread;
577
+ }
578
+ const threadId = typeof thread.id === 'string' && thread.id.length > 0
579
+ ? thread.id
580
+ : createHash('sha256').update(JSON.stringify(thread)).digest('hex').slice(0, 12);
581
+ const now = new Date().toISOString();
582
+ const messages = Array.isArray(thread.messages) && thread.messages.length > 0
583
+ ? thread.messages.map((message, index) => ({
584
+ id: typeof message.id === 'string' && message.id.length > 0
585
+ ? message.id
586
+ : `${threadId}:${index}`,
587
+ body: message.body,
588
+ author: message.author,
589
+ createdAt: message.createdAt || thread.createdAt || now,
590
+ updatedAt: message.updatedAt || message.createdAt || thread.updatedAt || now,
591
+ }))
592
+ : [
593
+ {
594
+ id: threadId,
595
+ body: '',
596
+ createdAt: thread.createdAt || now,
597
+ updatedAt: thread.updatedAt || thread.createdAt || now,
598
+ },
599
+ ];
600
+ const firstMessage = messages[0];
601
+ const lastMessage = messages[messages.length - 1];
602
+ return {
603
+ id: threadId,
604
+ filePath: typeof thread.file === 'string' && thread.file.length > 0 ? thread.file : '<unknown file>',
605
+ createdAt: thread.createdAt || firstMessage?.createdAt || now,
606
+ updatedAt: thread.updatedAt || lastMessage?.updatedAt || thread.createdAt || now,
607
+ position: {
608
+ side: thread.side ?? 'new',
609
+ line: normalizeLineValue(thread.line),
610
+ },
611
+ codeSnapshot: typeof thread.codeContent === 'string'
612
+ ? {
613
+ content: thread.codeContent,
614
+ }
615
+ : undefined,
616
+ messages,
617
+ };
618
+ }
619
+ function parseCommentsPayload(body) {
620
+ const payload = typeof body === 'string'
621
+ ? JSON.parse(body)
622
+ : body;
623
+ if (Array.isArray(payload.threads)) {
624
+ return payload.threads.map(normalizeThreadPayload);
625
+ }
626
+ if (Array.isArray(payload.comments)) {
627
+ return payload.comments.map(normalizeComment);
628
+ }
629
+ return [];
630
+ }
631
+ // Version the client based its push on (omitted by older clients).
632
+ function parseBaseVersion(payload) {
633
+ if (!payload || typeof payload !== 'object')
634
+ return undefined;
635
+ const value = payload.baseVersion;
636
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
637
+ }
638
+ function parseCommentImportsPayload(body) {
639
+ if (typeof body === 'string') {
640
+ return normalizeCommentImports(JSON.parse(body));
641
+ }
642
+ return normalizeCommentImports(body);
643
+ }
644
+ function updateCommentSession(selection, nextThreads) {
645
+ const session = getOrCreateCommentSession(selection);
646
+ const previous = JSON.stringify(session.threads);
647
+ const next = JSON.stringify(nextThreads);
648
+ session.threads = nextThreads;
649
+ if (previous === next) {
650
+ return false;
651
+ }
652
+ session.version += 1;
653
+ fileWatcher.broadcast({
654
+ type: 'commentsChanged',
655
+ version: session.version,
656
+ timestamp: new Date().toISOString(),
657
+ });
658
+ return true;
659
+ }
660
+ app.post('/api/comments', (req, res) => {
661
+ try {
662
+ const selection = getCommentSelectionFromQuery(req.query);
663
+ const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
664
+ const nextThreads = parseCommentsPayload(body);
665
+ const baseVersion = parseBaseVersion(body);
666
+ const session = getOrCreateCommentSession(selection);
667
+ // Stale baseVersion means another writer (e.g. an agent) changed comments since the
668
+ // client's last read, so merge rather than overwrite. A matching/absent version replaces.
669
+ const isStale = typeof baseVersion === 'number' && baseVersion !== session.version;
670
+ const resolvedThreads = isStale
671
+ ? mergeCommentThreads(session.threads, nextThreads).threads
672
+ : nextThreads;
673
+ updateCommentSession(selection, resolvedThreads);
674
+ res.json({
675
+ success: true,
676
+ merged: isStale,
677
+ version: session.version,
678
+ threads: session.threads,
679
+ });
680
+ }
681
+ catch (error) {
682
+ console.error('Error parsing comments:', error);
683
+ res.status(400).json({ error: 'Invalid comment data' });
684
+ }
685
+ });
686
+ app.post('/api/comment-imports', (req, res) => {
687
+ try {
688
+ const selection = getCommentSelectionFromQuery(req.query);
689
+ const session = getOrCreateCommentSession(selection);
690
+ const commentImports = parseCommentImportsPayload(req.body);
691
+ const importId = createHash('sha256')
692
+ .update(serializeCommentImports(commentImports))
693
+ .digest('hex');
694
+ const merged = mergeCommentImports(session.threads, commentImports);
695
+ const changed = updateCommentSession(selection, merged.threads);
696
+ res.json({
697
+ success: true,
698
+ changed,
699
+ count: commentImports.length,
700
+ importId,
701
+ warnings: merged.warnings,
702
+ });
703
+ }
704
+ catch (error) {
705
+ console.error('Error parsing comment imports:', error);
706
+ res.status(400).json({ error: 'Invalid comment import data' });
707
+ }
708
+ });
709
+ app.get('/api/comments-json', (req, res) => {
710
+ const selection = getCommentSelectionFromQuery(req.query);
711
+ const session = getOrCreateCommentSession(selection);
712
+ res.json({
713
+ version: session.version,
714
+ threads: session.threads,
715
+ });
716
+ });
717
+ app.get('/api/comments-output', (req, res) => {
718
+ const selection = getCommentSelectionFromQuery(req.query);
719
+ const session = getOrCreateCommentSession(selection);
720
+ res.type('text/plain');
721
+ if (session.threads.length > 0) {
722
+ const output = formatCommentsOutput(session.threads.map(toCommentThread));
723
+ res.send(output);
724
+ }
725
+ else {
726
+ res.send('');
727
+ }
728
+ });
729
+ app.post('/api/open-in-editor', async (req, res) => {
730
+ if (currentWorkspace?.patch) {
731
+ res.status(400).json({ error: 'Open in editor is not available for stdin diff' });
732
+ return;
733
+ }
734
+ const { filePath, line, editor } = (req.body ?? {});
735
+ if (typeof filePath !== 'string') {
736
+ res.status(400).json({ error: 'Invalid request payload' });
737
+ return;
738
+ }
739
+ const filepathResult = parseRepositoryRelativePath(filePath);
740
+ if (!filepathResult.ok) {
741
+ res.status(400).json({ error: filepathResult.error });
742
+ return;
743
+ }
744
+ if (!currentWorkspace) {
745
+ res.status(409).json({ error: 'No workspace selected' });
746
+ return;
747
+ }
748
+ const resolvedPath = resolve(currentWorkspace.repoPath, filepathResult.path);
749
+ const editorRequest = parseEditorRequest(editor);
750
+ const editorId = editorRequest.id ?? process.env.DIFIT_EDITOR ?? process.env.EDITOR ?? undefined;
751
+ if (editorId?.toLowerCase() === NONE_EDITOR_ID) {
752
+ res.status(400).json({ error: 'Open in editor is disabled' });
753
+ return;
754
+ }
755
+ // The browser always sends command + argsTemplate in the body, so we use
756
+ // those directly. We only fall back to the preset table when neither is
757
+ // provided (for example, when DIFIT_EDITOR is set and there's no body).
758
+ let command;
759
+ let argsTemplate;
760
+ if (editorRequest.command !== undefined || editorRequest.argsTemplate !== undefined) {
761
+ command = (editorRequest.command ?? '').trim();
762
+ argsTemplate = (editorRequest.argsTemplate ?? '').trim();
763
+ }
764
+ else {
765
+ const preset = resolveEditorOption(editorId);
766
+ command = preset.command;
767
+ argsTemplate = preset.argsTemplate;
768
+ }
769
+ if (!command || !argsTemplate) {
770
+ const isCustom = editorId?.toLowerCase() === CUSTOM_EDITOR_ID;
771
+ res.status(400).json({
772
+ error: isCustom
773
+ ? 'Custom editor is not configured. Set a command and arguments in Settings > System.'
774
+ : 'Open in editor is not configured',
775
+ });
776
+ return;
777
+ }
778
+ const lineNumber = (() => {
779
+ const parsed = Number.parseInt(String(line ?? ''), 10);
780
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
781
+ })();
782
+ const spawnSpec = buildEditorSpawnSpec({
783
+ command,
784
+ argsTemplate,
785
+ filePath: resolvedPath,
786
+ lineNumber,
787
+ });
788
+ if (!spawnSpec) {
789
+ res.status(500).json({ error: 'Invalid editor configuration' });
790
+ return;
791
+ }
792
+ const launched = await new Promise((resolvePromise) => {
793
+ const child = spawn(spawnSpec.command, [...spawnSpec.args], {
794
+ stdio: 'ignore',
795
+ detached: true,
796
+ });
797
+ child.once('error', (error) => {
798
+ const code = error.code;
799
+ if (code && code !== 'ENOENT') {
800
+ console.error('Failed to launch editor CLI:', error);
801
+ }
802
+ resolvePromise(false);
803
+ });
804
+ child.once('spawn', () => {
805
+ child.unref();
806
+ resolvePromise(true);
807
+ });
808
+ });
809
+ if (!launched) {
810
+ res.status(500).json({
811
+ error: `Failed to launch editor: command "${spawnSpec.command}" is not available on PATH`,
812
+ });
813
+ return;
814
+ }
815
+ res.json({ success: true });
816
+ });
817
+ app.post('/api/grouping', async (req, res) => {
818
+ const workspace = currentWorkspace;
819
+ if (!workspace) {
820
+ res.json({ needsWorkspace: true });
821
+ return;
822
+ }
823
+ const body = (req.body ?? {});
824
+ if (typeof body.base !== 'string' || typeof body.target !== 'string') {
825
+ res.status(400).json({ error: 'base and target are required' });
826
+ return;
827
+ }
828
+ const model = typeof body.model === 'string' && body.model ? body.model : 'haiku';
829
+ const intent = typeof body.intent === 'string' ? body.intent : null;
830
+ try {
831
+ // Honor baseMode so grouping parses the SAME diff the client is showing.
832
+ // Omitting it turns a merge-base view into a direct base..target diff, which
833
+ // in a repo whose base branch has diverged pulls in hundreds of unrelated files.
834
+ const selection = createDiffSelection(body.base, body.target, body.baseMode);
835
+ const diff = await workspace.parser.parseDiff(selection);
836
+ // diff-easy: grouping results are cached under the repo's turns dir,
837
+ // alongside the agent-turn log (`.difit-turns/`) — see src/utils/turnLog.ts.
838
+ const turnsDir = join(workspace.repoPath, '.difit-turns');
839
+ // Only AI results are cached: they're the expensive ones, and caching a
840
+ // deterministic `smart`/`module` response under this (mode-less) key would
841
+ // let a non-AI request poison a later AI read. Deterministic modes run
842
+ // client-side anyway and are cheap to recompute.
843
+ const cacheable = body.mode === 'ai' &&
844
+ body.target !== '.' &&
845
+ body.target !== 'working' &&
846
+ body.target !== 'staged';
847
+ // Cache per diff, not per target: `HEAD^..HEAD` and `main..HEAD` are
848
+ // different diffs, so the base must be part of the key or they collide.
849
+ const resolveRef = (ref) => Promise.resolve()
850
+ .then(() => workspace.parser.resolveCommitish(ref))
851
+ .catch(() => ref);
852
+ const [baseHash, targetHash] = await Promise.all([
853
+ resolveRef(body.base),
854
+ resolveRef(body.target),
855
+ ]);
856
+ const cacheKey = `${baseHash}..${targetHash}${body.baseMode === 'merge-base' ? '-mb' : ''}`;
857
+ if (cacheable && !body.force) {
858
+ const cached = readCache(turnsDir, cacheKey, model);
859
+ if (cached) {
860
+ res.json(cached);
861
+ return;
862
+ }
863
+ }
864
+ const aiDisabled = process.env.DIFIT_GROUPING_NO_AI === '1';
865
+ const result = aiDisabled || body.mode !== 'ai'
866
+ ? groupSmart(diff.files)
867
+ : await groupWithClaude(diff, intent, { model });
868
+ if (cacheable)
869
+ writeCache(turnsDir, cacheKey, model, result);
870
+ res.json(result);
871
+ }
872
+ catch (error) {
873
+ res.status(500).json({ error: error instanceof Error ? error.message : 'grouping failed' });
874
+ }
875
+ });
876
+ // Function to output comments when server shuts down
877
+ function outputFinalComments() {
878
+ const session = getOrCreateCommentSession(currentCommentSelection);
879
+ if (session.threads.length > 0) {
880
+ console.log(formatCommentsOutput(session.threads.map(toCommentThread)));
881
+ }
882
+ }
883
+ // diff-easy: list captured agent turns (newest first), grouped by session.
884
+ app.get('/api/turns', (req, res) => {
885
+ if (!currentWorkspace) {
886
+ res.json({ sessions: [], activeSession: null, turns: [], needsWorkspace: true });
887
+ return;
888
+ }
889
+ const all = readTurnLog(currentWorkspace.repoPath);
890
+ const sessionFilter = typeof req.query.session === 'string' ? req.query.session : undefined;
891
+ // Order sessions by most-recent activity.
892
+ const lastActivity = new Map();
893
+ for (const turn of all) {
894
+ const ts = turn.endedAt ?? turn.startedAt;
895
+ lastActivity.set(turn.sessionId, Math.max(lastActivity.get(turn.sessionId) ?? 0, ts));
896
+ }
897
+ const sessions = [...lastActivity.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
898
+ // Fall back to the newest session when the requested filter isn't present in
899
+ // this repo — e.g. a hub tab still pinned to another repo's session. Filtering
900
+ // by a session that doesn't exist here would blank the timeline.
901
+ const activeSession = sessionFilter && sessions.includes(sessionFilter) ? sessionFilter : sessions[0];
902
+ const turns = all
903
+ .filter((t) => (activeSession ? t.sessionId === activeSession : true))
904
+ // Completed turns diff commitBefore→commitAfter; the in-progress (open)
905
+ // turn is reviewable live as commitBefore→working tree, so a session
906
+ // that's mid-turn isn't shown as empty. Drop only malformed records.
907
+ .filter((t) => t.commitAfter || t.status === 'open')
908
+ .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt));
909
+ res.json({ sessions, activeSession: activeSession ?? null, turns });
910
+ });
911
+ // diff-easy: best-effort live-refresh ping from the capture hook.
912
+ app.post('/events/turn-complete', (_req, res) => {
913
+ fileWatcher.broadcast({ type: 'turn-complete', timestamp: new Date().toISOString() });
914
+ res.json({ ok: true });
915
+ });
916
+ // ---- diff-easy hub: runtime workspace swapping + picker data ----
917
+ let swapping = false;
918
+ const workspaceLabel = (repoPath) => {
919
+ const home = process.env.HOME || '';
920
+ return home && repoPath.startsWith(`${home}/`) ? `~${repoPath.slice(home.length)}` : repoPath;
921
+ };
922
+ const isGitWorkTree = (repoPath) => {
923
+ try {
924
+ return (execFileSync('git', ['-C', repoPath, 'rev-parse', '--is-inside-work-tree'], {
925
+ stdio: ['ignore', 'pipe', 'ignore'],
926
+ })
927
+ .toString()
928
+ .trim() === 'true');
929
+ }
930
+ catch {
931
+ return false;
932
+ }
933
+ };
934
+ // Re-point the single view at a different repo: fresh parser, restart the
935
+ // watcher, clear per-workspace caches/comments, default to uncommitted, and
936
+ // tell every open tab to re-sync.
937
+ const applyRepoWorkspace = async (repoPath) => {
938
+ const abs = resolve(repoPath);
939
+ const repoId = createHash('sha256').update(abs).digest('hex');
940
+ // Hub-opened repos can't show difit's interactive untracked-file prompt, so
941
+ // enable read-only synthesis (no index mutation) to surface new files.
942
+ const workspace = {
943
+ repoPath: abs,
944
+ repoId,
945
+ parser: new GitDiffParser(abs, { includeUntracked: true }),
946
+ };
947
+ await fileWatcher.stop();
948
+ diffDataCache.clear();
949
+ generatedStatusCache.clear();
950
+ commentSessions.clear();
951
+ currentSelection = createDiffSelection('HEAD', '.');
952
+ currentCommentSelection = currentSelection;
953
+ currentWorkspace = workspace;
954
+ try {
955
+ await fileWatcher.start(DiffMode.DOT, abs, 300, invalidateCache);
956
+ }
957
+ catch {
958
+ // watcher is best-effort
959
+ }
960
+ fileWatcher.broadcast({ type: 'workspace-changed', timestamp: new Date().toISOString() });
961
+ return workspace;
962
+ };
963
+ // Re-point the view at a GitHub PR: a static-patch workspace from `gh pr diff`,
964
+ // with the PR's review comments imported. No watcher (nothing local to watch).
965
+ const applyPrWorkspace = async (url) => {
966
+ const patch = getPrPatch(url); // throws (surfaces gh auth errors)
967
+ let commentImports = [];
968
+ try {
969
+ commentImports = await getPrCommentImports(url);
970
+ }
971
+ catch (error) {
972
+ // best-effort — the patch is the core; comments are a bonus. Log it so a
973
+ // silent failure isn't mistaken for "this PR has no review comments".
974
+ console.warn(`difit: could not import review comments for ${url}: ${error instanceof Error ? error.message : String(error)}`);
975
+ }
976
+ let prDetails = null;
977
+ try {
978
+ prDetails = await getPrDetails(url);
979
+ }
980
+ catch (error) {
981
+ // best-effort — title/body only enrich grouping intent; the diff still works without them.
982
+ console.warn(`difit: could not load PR title/body for ${url}: ${error instanceof Error ? error.message : String(error)}`);
983
+ }
984
+ const workspace = {
985
+ repoPath: process.cwd(), // placeholder; static workspaces never touch it
986
+ repoId: createHash('sha256').update(`pr:${url}`).digest('hex'),
987
+ parser: new GitDiffParser(process.cwd()),
988
+ patch,
989
+ commentImports: commentImports.length > 0 ? commentImports : undefined,
990
+ commentImportId: commentImports.length > 0
991
+ ? createHash('sha256').update(serializeCommentImports(commentImports)).digest('hex')
992
+ : undefined,
993
+ prTitle: prDetails?.title || undefined,
994
+ prBody: prDetails?.body || undefined,
995
+ };
996
+ await fileWatcher.stop();
997
+ diffDataCache.clear();
998
+ generatedStatusCache.clear();
999
+ commentSessions.clear();
1000
+ currentSelection = createDiffSelection('', '');
1001
+ currentCommentSelection = currentSelection;
1002
+ currentWorkspace = workspace;
1003
+ fileWatcher.broadcast({ type: 'workspace-changed', timestamp: new Date().toISOString() });
1004
+ return workspace;
1005
+ };
1006
+ app.post('/api/workspace', async (req, res) => {
1007
+ const body = (req.body ?? {});
1008
+ if (swapping) {
1009
+ res.status(409).json({ error: 'A workspace switch is already in progress' });
1010
+ return;
1011
+ }
1012
+ swapping = true;
1013
+ try {
1014
+ if (body.type === 'repo') {
1015
+ if (typeof body.path !== 'string' || !body.path) {
1016
+ res.status(400).json({ error: 'Missing folder path' });
1017
+ return;
1018
+ }
1019
+ const abs = resolve(body.path);
1020
+ if (!isGitWorkTree(abs)) {
1021
+ res.status(400).json({ error: `Not a git repository: ${abs}` });
1022
+ return;
1023
+ }
1024
+ const ws = await applyRepoWorkspace(abs);
1025
+ const config = readHubConfig();
1026
+ const folder = { repoId: ws.repoId, path: abs, name: basename(abs) };
1027
+ pushRecentFolder(config, folder);
1028
+ // Opening a repo clears its activity badge: mark its newest turn as seen.
1029
+ const seen = latestTurnTimestamp(readTurnLog(abs));
1030
+ if (seen > 0)
1031
+ config.lastSeen[ws.repoId] = seen;
1032
+ writeHubConfig(config);
1033
+ res.json({ active: { kind: 'repo', repoId: ws.repoId, label: workspaceLabel(abs) } });
1034
+ }
1035
+ else if (body.type === 'pr') {
1036
+ if (typeof body.url !== 'string' || !body.url) {
1037
+ res.status(400).json({ error: 'Missing PR URL' });
1038
+ return;
1039
+ }
1040
+ const ws = await applyPrWorkspace(body.url);
1041
+ const label = body.url.replace(/^https?:\/\/github\.com\//, '');
1042
+ const config = readHubConfig();
1043
+ config.recentPrs = [
1044
+ { url: body.url, label, repoId: ws.repoId },
1045
+ ...config.recentPrs.filter((p) => p.url !== body.url),
1046
+ ].slice(0, 20);
1047
+ writeHubConfig(config);
1048
+ res.json({ active: { kind: 'pr', repoId: ws.repoId, label } });
1049
+ }
1050
+ else {
1051
+ res.status(400).json({ error: 'Unknown workspace type' });
1052
+ }
1053
+ }
1054
+ catch (error) {
1055
+ res
1056
+ .status(400)
1057
+ .json({ error: error instanceof Error ? error.message : 'Failed to switch workspace' });
1058
+ }
1059
+ finally {
1060
+ swapping = false;
1061
+ }
1062
+ });
1063
+ // Picker data: configured roots scanned for repos, recents, and the active source.
1064
+ app.get('/api/workspaces', (_req, res) => {
1065
+ const config = readHubConfig();
1066
+ const roots = config.roots.map((root) => {
1067
+ let repos = [];
1068
+ try {
1069
+ repos = readdirSync(root, { withFileTypes: true })
1070
+ .filter((e) => e.isDirectory())
1071
+ .map((e) => join(root, e.name))
1072
+ .filter((p) => existsSync(join(p, '.git')))
1073
+ .map((p) => ({
1074
+ repoId: createHash('sha256').update(p).digest('hex'),
1075
+ path: p,
1076
+ name: basename(p),
1077
+ }));
1078
+ }
1079
+ catch {
1080
+ repos = [];
1081
+ }
1082
+ return { root, repos };
1083
+ });
1084
+ // Per-repo "new turns since you last opened it" counts for the picker badges.
1085
+ // Map repoId → path across every known repo (scanned roots + recents), then
1086
+ // count completed turns newer than the repo's lastSeen marker.
1087
+ const repoPaths = new Map();
1088
+ for (const { repos } of roots) {
1089
+ for (const repo of repos)
1090
+ repoPaths.set(repo.repoId, repo.path);
1091
+ }
1092
+ for (const recent of config.recents)
1093
+ repoPaths.set(recent.repoId, recent.path);
1094
+ const activity = {};
1095
+ for (const [repoId, path] of repoPaths) {
1096
+ const count = countTurnsSince(readTurnLog(path), config.lastSeen[repoId] ?? 0);
1097
+ if (count > 0)
1098
+ activity[repoId] = count;
1099
+ }
1100
+ res.json({
1101
+ active: currentWorkspace
1102
+ ? {
1103
+ kind: 'repo',
1104
+ repoId: currentWorkspace.repoId,
1105
+ label: workspaceLabel(currentWorkspace.repoPath),
1106
+ }
1107
+ : null,
1108
+ roots,
1109
+ recents: config.recents,
1110
+ recentPrs: config.recentPrs,
1111
+ activity,
1112
+ });
1113
+ });
1114
+ // SSE endpoint for file watching
1115
+ app.get('/api/watch', (req, res) => {
1116
+ res.writeHead(200, {
1117
+ 'Content-Type': 'text/event-stream',
1118
+ 'Cache-Control': 'no-cache',
1119
+ Connection: 'keep-alive',
1120
+ 'Access-Control-Allow-Origin': '*',
1121
+ });
1122
+ fileWatcher.addClient(res);
1123
+ req.on('close', () => {
1124
+ fileWatcher.removeClient(res);
1125
+ });
1126
+ });
1127
+ // SSE endpoint to detect when tab is closed
1128
+ app.get('/api/heartbeat', (req, res) => {
1129
+ res.writeHead(200, {
1130
+ 'Content-Type': 'text/event-stream',
1131
+ 'Cache-Control': 'no-cache',
1132
+ Connection: 'keep-alive',
1133
+ 'Access-Control-Allow-Origin': '*',
1134
+ });
1135
+ // Send initial heartbeat
1136
+ res.write('data: connected\n\n');
1137
+ // Send heartbeat every 5 seconds
1138
+ const heartbeatInterval = setInterval(() => {
1139
+ res.write('data: heartbeat\n\n');
1140
+ }, 5000);
1141
+ // When client disconnects (tab closed, navigation, etc.)
1142
+ req.on('close', () => {
1143
+ clearInterval(heartbeatInterval);
1144
+ if (options.keepAlive) {
1145
+ console.log('Client disconnected, but server is staying alive (--keep-alive)');
1146
+ console.log('Press Ctrl+C to stop the server');
1147
+ }
1148
+ else {
1149
+ // Add a small delay to ensure any pending sendBeacon requests are processed
1150
+ setTimeout(async () => {
1151
+ console.log('Client disconnected, shutting down server...');
1152
+ // Stop file watcher
1153
+ await fileWatcher.stop();
1154
+ // Drop the advertised server/hub info so the capture hook never pings
1155
+ // a recycled port after we're gone.
1156
+ if (options.hub)
1157
+ deleteHubInfo();
1158
+ else if (currentWorkspace)
1159
+ deleteServerInfo(currentWorkspace.repoPath);
1160
+ if (!options.hub)
1161
+ outputFinalComments();
1162
+ process.exit(0);
1163
+ }, 100);
1164
+ }
1165
+ });
1166
+ });
1167
+ // Always runs in production mode when distributed as a CLI tool
1168
+ const isProduction = process.env.NODE_ENV === 'production' || process.env.NODE_ENV !== 'development';
1169
+ if (isProduction) {
1170
+ // Find client files relative to the CLI executable location
1171
+ const distPath = join(__dirname, '..', 'client');
1172
+ app.use(express.static(distPath));
1173
+ }
1174
+ else {
1175
+ app.get('/', (_req, res) => {
1176
+ res.send(`
1177
+ <!DOCTYPE html>
1178
+ <html>
1179
+ <head>
1180
+ <title>difit - Dev Mode</title>
1181
+ </head>
1182
+ <body>
1183
+ <div id="root"></div>
1184
+ <script>
1185
+ console.log('difit development mode');
1186
+ console.log('Diff data available at /api/diff');
1187
+ </script>
1188
+ </body>
1189
+ </html>
1190
+ `);
1191
+ });
1192
+ }
1193
+ const { port, url, server } = await startServerWithFallback(app, options.preferredPort || 4966, options.host || 'localhost');
1194
+ // diff-easy: advertise this server's port so the capture hook can ping it for
1195
+ // live refresh. Hub mode advertises globally (hub.json); a fixed server
1196
+ // advertises per-repo (server.json). Best-effort; capture works regardless.
1197
+ try {
1198
+ const info = { port, pid: process.pid, startedAt: Date.now() };
1199
+ if (options.hub)
1200
+ writeHubInfo(info);
1201
+ else if (currentWorkspace)
1202
+ writeServerInfo(currentWorkspace.repoPath, info);
1203
+ }
1204
+ catch {
1205
+ // ignore — live refresh is optional
1206
+ }
1207
+ // A keep-alive server never auto-shuts-down on tab close, so a Ctrl+C / kill
1208
+ // is its normal exit. Clean up its advertised port on the way out so a later
1209
+ // process reusing that port doesn't receive stray turn-complete pings.
1210
+ // (Non-keep-alive servers clean up in the heartbeat-close path above; only
1211
+ // keep-alive registers signal handlers, to avoid accumulating listeners when
1212
+ // many servers are started in one process, e.g. tests.)
1213
+ if (options.keepAlive) {
1214
+ const shutdown = () => {
1215
+ if (options.hub)
1216
+ deleteHubInfo();
1217
+ else if (currentWorkspace)
1218
+ deleteServerInfo(currentWorkspace.repoPath);
1219
+ void fileWatcher.stop().finally(() => process.exit(0));
1220
+ };
1221
+ process.once('SIGINT', shutdown);
1222
+ process.once('SIGTERM', shutdown);
1223
+ }
1224
+ // Security warning for non-localhost binding
1225
+ if (options.host && options.host !== '127.0.0.1' && options.host !== 'localhost') {
1226
+ console.warn('\n⚠️ WARNING: Server is accessible from external network!');
1227
+ console.warn(` Binding to: ${options.host}:${port}`);
1228
+ console.warn(' Make sure this is intended and your network is secure.\n');
1229
+ }
1230
+ // Start file watcher (only when a repo workspace is active)
1231
+ if (options.diffMode && currentWorkspace) {
1232
+ try {
1233
+ await fileWatcher.start(options.diffMode, currentWorkspace.repoPath, 300, invalidateCache);
1234
+ }
1235
+ catch (error) {
1236
+ console.warn('⚠️ File watcher failed to start:', error);
1237
+ console.warn(' Continuing without file watching...');
1238
+ }
1239
+ }
1240
+ // Open the browser. In hub mode there's no diff yet (the picker loads), so
1241
+ // open regardless; otherwise skip when the diff is empty.
1242
+ const shouldOpen = options.openBrowser && (options.hub || (initialDiffData != null && !initialDiffData.isEmpty));
1243
+ if (shouldOpen) {
1244
+ try {
1245
+ await open(url);
1246
+ }
1247
+ catch {
1248
+ console.warn('Failed to open browser automatically');
1249
+ }
1250
+ }
1251
+ return { port, url, isEmpty: initialDiffData?.isEmpty ?? true, server };
1252
+ }
1253
+ async function startServerWithFallback(app, preferredPort, host) {
1254
+ return new Promise((resolve, reject) => {
1255
+ // express's listen() method uses listen() method in node:net Server instance internally
1256
+ // https://expressjs.com/en/5x/api.html#app.listen
1257
+ // so, an error will be an instance of NodeJS.ErrnoException
1258
+ const server = app.listen(preferredPort, host, (err) => {
1259
+ const displayHost = host === '0.0.0.0' ? 'localhost' : host;
1260
+ const url = `http://${displayHost}:${preferredPort}`;
1261
+ if (!err) {
1262
+ resolve({ port: preferredPort, url, server });
1263
+ return;
1264
+ }
1265
+ // Handling errors when failed to launch a server
1266
+ switch (err.code) {
1267
+ // Try another port until it succeeds
1268
+ case 'EADDRINUSE': {
1269
+ console.log(`Port ${preferredPort} is busy, trying ${preferredPort + 1}...`);
1270
+ return startServerWithFallback(app, preferredPort + 1, host)
1271
+ .then(({ port, url, server }) => {
1272
+ resolve({ port, url, server });
1273
+ })
1274
+ .catch(reject);
1275
+ }
1276
+ // Unexpected error
1277
+ default: {
1278
+ reject(new Error(`Failed to launch a server: ${err.message}`));
1279
+ }
1280
+ }
1281
+ });
1282
+ });
1283
+ }