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.
- package/LICENSE +22 -0
- package/README.md +142 -0
- package/dist/cli/cmux.d.ts +9 -0
- package/dist/cli/cmux.js +274 -0
- package/dist/cli/comment.d.ts +2 -0
- package/dist/cli/comment.js +91 -0
- package/dist/cli/github.d.ts +84 -0
- package/dist/cli/github.js +364 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +397 -0
- package/dist/cli/turns.d.ts +14 -0
- package/dist/cli/turns.js +333 -0
- package/dist/cli/utils.d.ts +27 -0
- package/dist/cli/utils.js +204 -0
- package/dist/client/assets/arc-CHYjdFTk.js +1 -0
- package/dist/client/assets/architecture-7EHR7CIX-weHBYDgM.js +1 -0
- package/dist/client/assets/architectureDiagram-3BPJPVTR-DRa2msuJ.js +36 -0
- package/dist/client/assets/array-BifhSqXX.js +1 -0
- package/dist/client/assets/blockDiagram-GPEHLZMM-BTdKsXy5.js +132 -0
- package/dist/client/assets/c4Diagram-AAUBKEIU-CwTvCkOg.js +10 -0
- package/dist/client/assets/channel-Cv40nQW1.js +1 -0
- package/dist/client/assets/chunk-2J33WTMH-BFsjrTtc.js +1 -0
- package/dist/client/assets/chunk-3OPIFGDE-V_xVw1dq.js +62 -0
- package/dist/client/assets/chunk-4BX2VUAB-DoLO6LLy.js +1 -0
- package/dist/client/assets/chunk-55IACEB6-CHgBwbdB.js +1 -0
- package/dist/client/assets/chunk-5ZQYHXKU-DMixAgQG.js +2 -0
- package/dist/client/assets/chunk-727SXJPM-C-3FZTmm.js +206 -0
- package/dist/client/assets/chunk-AQP2D5EJ-DpQZhXt-.js +231 -0
- package/dist/client/assets/chunk-BSJP7CBP-BnO9E_G4.js +1 -0
- package/dist/client/assets/chunk-CSCIHK7Q-BxItphyO.js +124 -0
- package/dist/client/assets/chunk-FMBD7UC4-4hEcfvfT.js +15 -0
- package/dist/client/assets/chunk-KSCS5N6A-DnArkpOI.js +10 -0
- package/dist/client/assets/chunk-L5ZTLDWV-DQoYJ1BW.js +1 -0
- package/dist/client/assets/chunk-LZXEDZCA-CdCevBX-.js +2 -0
- package/dist/client/assets/chunk-ND2GUHAM-Dt89U_nU.js +1 -0
- package/dist/client/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
- package/dist/client/assets/chunk-NZK2D7GU-C2Ev_f_w.js +1 -0
- package/dist/client/assets/chunk-O5CBEL6O-BeZ3MFnJ.js +70 -0
- package/dist/client/assets/chunk-QZHKN3VN-BmDOSWHp.js +1 -0
- package/dist/client/assets/chunk-WU5MYG2G-gLuInTk4.js +1 -0
- package/dist/client/assets/chunk-XPW4576I-DPbDc8_T.js +32 -0
- package/dist/client/assets/classDiagram-4FO5ZUOK-DOpVKUrb.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-DOpVKUrb.js +1 -0
- package/dist/client/assets/cose-bilkent-S5V4N54A-CCPyalms.js +1 -0
- package/dist/client/assets/cytoscape.esm-FqbQrHcz.js +321 -0
- package/dist/client/assets/dagre-BM42HDAG-6jrYMjX3.js +4 -0
- package/dist/client/assets/dagre-Bx709z4p.js +1 -0
- package/dist/client/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/dist/client/assets/diagram-2AECGRRQ-dCQ_KoXl.js +43 -0
- package/dist/client/assets/diagram-5GNKFQAL-B3Xj4-XE.js +10 -0
- package/dist/client/assets/diagram-KO2AKTUF-DaGgF2yL.js +3 -0
- package/dist/client/assets/diagram-LMA3HP47-CF_c4tPT.js +24 -0
- package/dist/client/assets/diagram-OG6HWLK6-BgBHbJkT.js +24 -0
- package/dist/client/assets/dist-7wVkzZne.js +1 -0
- package/dist/client/assets/erDiagram-TEJ5UH35-BZgX1grR.js +85 -0
- package/dist/client/assets/eventmodeling-FCH6USID-BaI6RNKr.js +1 -0
- package/dist/client/assets/flowDiagram-I6XJVG4X-BFNUa12V.js +162 -0
- package/dist/client/assets/ganttDiagram-6RSMTGT7-C8t6BThI.js +292 -0
- package/dist/client/assets/gitGraph-WXDBUCRP-BlicfDkM.js +1 -0
- package/dist/client/assets/gitGraphDiagram-PVQCEYII-BmnIXuXP.js +106 -0
- package/dist/client/assets/graphlib-B8gBHxth.js +1 -0
- package/dist/client/assets/index-BfVtbKTZ.css +2 -0
- package/dist/client/assets/index-p8zzfjdu.js +87 -0
- package/dist/client/assets/info-J43DQDTF-DHLG38N3.js +1 -0
- package/dist/client/assets/infoDiagram-5YYISTIA-oQ-lq-45.js +2 -0
- package/dist/client/assets/init-D6jRqBbL.js +1 -0
- package/dist/client/assets/ishikawaDiagram-YF4QCWOH-DrODlBLD.js +70 -0
- package/dist/client/assets/journeyDiagram-JHISSGLW-Bxg5wCQz.js +139 -0
- package/dist/client/assets/kanban-definition-UN3LZRKU-DAdvUDIB.js +89 -0
- package/dist/client/assets/katex-Vhh-h91d.js +257 -0
- package/dist/client/assets/line-D8kNd46u.js +1 -0
- package/dist/client/assets/linear-jwGZSrg4.js +1 -0
- package/dist/client/assets/mermaid-parser.core-PVPfUfyU.js +4 -0
- package/dist/client/assets/mermaid.core-DwdP6CFg.js +9 -0
- package/dist/client/assets/mindmap-definition-RKZ34NQL-DeOoL56Q.js +96 -0
- package/dist/client/assets/ordinal-hYBb2elL.js +1 -0
- package/dist/client/assets/packet-YPE3B663-BBEaxdJP.js +1 -0
- package/dist/client/assets/path-BWPyau1x.js +1 -0
- package/dist/client/assets/pie-LRSECV5Y-fvTRFm-2.js +1 -0
- package/dist/client/assets/pieDiagram-4H26LBE5-oep1px2r.js +30 -0
- package/dist/client/assets/prism-bash-D6zCJ74D.js +1 -0
- package/dist/client/assets/prism-clojure-_z-zoNcE.js +1 -0
- package/dist/client/assets/prism-csharp-XPn2bw0d.js +1 -0
- package/dist/client/assets/prism-dart-CDv16Fbu.js +1 -0
- package/dist/client/assets/prism-elixir-DhHhV5Rj.js +1 -0
- package/dist/client/assets/prism-haskell-5hq1Nh9c.js +1 -0
- package/dist/client/assets/prism-hcl-B6Z0Ouoo.js +1 -0
- package/dist/client/assets/prism-java-6wtusvIl.js +1 -0
- package/dist/client/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
- package/dist/client/assets/prism-nix-C88dTX93.js +1 -0
- package/dist/client/assets/prism-perl-Lsbi34sH.js +1 -0
- package/dist/client/assets/prism-php-Co4fM40K.js +1 -0
- package/dist/client/assets/prism-protobuf-yzSHlhwy.js +1 -0
- package/dist/client/assets/prism-ruby-DFCASkuD.js +1 -0
- package/dist/client/assets/prism-scala-VkM3wCOL.js +1 -0
- package/dist/client/assets/prism-solidity-DTom4k5p.js +1 -0
- package/dist/client/assets/prism-sql-CYhVDMEE.js +1 -0
- package/dist/client/assets/prism-vim-BgpkjCK5.js +1 -0
- package/dist/client/assets/quadrantDiagram-W4KKPZXB-D9l66CHb.js +7 -0
- package/dist/client/assets/radar-GUYGQ44K-DAU5EPVd.js +1 -0
- package/dist/client/assets/requirementDiagram-4Y6WPE33-helpQlwb.js +84 -0
- package/dist/client/assets/rough.esm-CSKSodPl.js +1 -0
- package/dist/client/assets/sankeyDiagram-5OEKKPKP-C9sNuuvA.js +40 -0
- package/dist/client/assets/sequenceDiagram-3UESZ5HK-BP0bhCwQ.js +162 -0
- package/dist/client/assets/src-CDAPGf2l.js +1 -0
- package/dist/client/assets/stateDiagram-AJRCARHV-CsFizpla.js +1 -0
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Bvn8tanl.js +1 -0
- package/dist/client/assets/timeline-definition-PNZ67QCA-BnkmHs1F.js +120 -0
- package/dist/client/assets/treeView-BLDUP644-DD9HZO5W.js +1 -0
- package/dist/client/assets/treemap-LRROVOQU-BBKuQ1lo.js +1 -0
- package/dist/client/assets/vennDiagram-CIIHVFJN-BPFTHflF.js +34 -0
- package/dist/client/assets/wardley-L42UT6IY-y88gDkwb.js +1 -0
- package/dist/client/assets/wardleyDiagram-YWT4CUSO-BlcA7IVh.js +78 -0
- package/dist/client/assets/xychartDiagram-2RQKCTM6-DX5vUCRH.js +7 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/index.html +14 -0
- package/dist/client/logo.png +0 -0
- package/dist/client/site-data/blobs/080c0e6/cHVibGljL3NpdGUtZGF0YS9vZy1pbWFnZS5wbmc.png +0 -0
- package/dist/client/site-data/blobs/55f23a1/bGFuZGluZy9wdWJsaWMvZGlmaXQvbG9nby5wbmc.png +0 -0
- package/dist/client/site-data/blobs/66ff7c6/cHVibGljL2xvZ28ucG5n.png +0 -0
- package/dist/client/site-data/blobs/e6977fe/cHVibGljL2xvZ28ucG5n.png +0 -0
- package/dist/client/site-data/manifest.json +1 -0
- package/dist/client/site-data/og-image.png +0 -0
- package/dist/client/site-data/snapshots/55f23a1...080c0e6.json +1 -0
- package/dist/client/site-data/snapshots/66ff7c6...e6977fe.json +1 -0
- package/dist/client/site-data/snapshots/7d40fd4...a72112f-comments.json +1 -0
- package/dist/client/site-data/snapshots/7d40fd4...a72112f.json +1 -0
- package/dist/server/file-watcher.d.ts +33 -0
- package/dist/server/file-watcher.js +340 -0
- package/dist/server/generated-file-check.d.ts +17 -0
- package/dist/server/generated-file-check.js +280 -0
- package/dist/server/git-diff.d.ts +94 -0
- package/dist/server/git-diff.js +909 -0
- package/dist/server/grouping/cache.d.ts +3 -0
- package/dist/server/grouping/cache.js +22 -0
- package/dist/server/grouping/claudeCli.d.ts +10 -0
- package/dist/server/grouping/claudeCli.js +95 -0
- package/dist/server/grouping/digest.d.ts +15 -0
- package/dist/server/grouping/digest.js +38 -0
- package/dist/server/grouping/reconcile.d.ts +6 -0
- package/dist/server/grouping/reconcile.js +34 -0
- package/dist/server/grouping/types.d.ts +9 -0
- package/dist/server/grouping/types.js +1 -0
- package/dist/server/server.d.ts +37 -0
- package/dist/server/server.js +1283 -0
- package/dist/shared/grouping.d.ts +4 -0
- package/dist/shared/grouping.js +170 -0
- package/dist/types/diff.d.ts +263 -0
- package/dist/types/diff.js +1 -0
- package/dist/types/watch.d.ts +59 -0
- package/dist/types/watch.js +8 -0
- package/dist/utils/commentFormatting.d.ts +6 -0
- package/dist/utils/commentFormatting.js +90 -0
- package/dist/utils/commentImports.d.ts +11 -0
- package/dist/utils/commentImports.js +386 -0
- package/dist/utils/createId.d.ts +1 -0
- package/dist/utils/createId.js +5 -0
- package/dist/utils/diffMode.d.ts +3 -0
- package/dist/utils/diffMode.js +13 -0
- package/dist/utils/diffSelection.d.ts +13 -0
- package/dist/utils/diffSelection.js +39 -0
- package/dist/utils/editorOptions.d.ts +60 -0
- package/dist/utils/editorOptions.js +171 -0
- package/dist/utils/fileUtils.d.ts +12 -0
- package/dist/utils/fileUtils.js +21 -0
- package/dist/utils/hubConfig.d.ts +32 -0
- package/dist/utils/hubConfig.js +51 -0
- package/dist/utils/suggestionUtils.d.ts +19 -0
- package/dist/utils/suggestionUtils.js +31 -0
- package/dist/utils/turnLog.d.ts +47 -0
- package/dist/utils/turnLog.js +133 -0
- 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
|
+
}
|