difit 4.0.2 → 4.0.3

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 (110) hide show
  1. package/README.ja.md +1 -0
  2. package/README.ko.md +1 -0
  3. package/README.md +1 -0
  4. package/README.zh.md +1 -0
  5. package/dist/cli/index.js +38 -28
  6. package/dist/cli/index.test.js +162 -77
  7. package/dist/client/assets/architecture-PBZL5I3N-DUNTzy9d.js +1 -0
  8. package/dist/client/assets/{architectureDiagram-2XIMDMQ5-CXJTJFYJ.js → architectureDiagram-2XIMDMQ5-BOmef_aT.js} +1 -1
  9. package/dist/client/assets/{blockDiagram-WCTKOSBZ-B60owdAn.js → blockDiagram-WCTKOSBZ-CuovjbLp.js} +1 -1
  10. package/dist/client/assets/{c4Diagram-IC4MRINW-4tg2D_Vt.js → c4Diagram-IC4MRINW-l2hgU0UG.js} +1 -1
  11. package/dist/client/assets/channel-BBMOf_Bn.js +1 -0
  12. package/dist/client/assets/{chunk-4BX2VUAB-CW45MZFx.js → chunk-4BX2VUAB-Bh2XMPGo.js} +1 -1
  13. package/dist/client/assets/{chunk-55IACEB6-Busc3sfI.js → chunk-55IACEB6-r9BRoqNs.js} +1 -1
  14. package/dist/client/assets/{chunk-7E7YKBS2-BVR-8Pma.js → chunk-7E7YKBS2-BUy3or4g.js} +1 -1
  15. package/dist/client/assets/{chunk-7R4GIKGN-DneC7PwP.js → chunk-7R4GIKGN-C7ClNgvP.js} +1 -1
  16. package/dist/client/assets/{chunk-C72U2L5F-CJr98gus.js → chunk-C72U2L5F-_P9RrDdo.js} +1 -1
  17. package/dist/client/assets/{chunk-EGIJ26TM-iD_CSqpR.js → chunk-EGIJ26TM-D-xQ2sZ-.js} +1 -1
  18. package/dist/client/assets/{chunk-FMBD7UC4-BSsJVlRg.js → chunk-FMBD7UC4-OuP8NjEM.js} +1 -1
  19. package/dist/client/assets/{chunk-GEFDOKGD-eDUrsRgt.js → chunk-GEFDOKGD-noJ9o8LM.js} +1 -1
  20. package/dist/client/assets/chunk-GLR3WWYH-DVK9OjRZ.js +2 -0
  21. package/dist/client/assets/chunk-HHEYEP7N-C1QyKuQs.js +1 -0
  22. package/dist/client/assets/{chunk-JSJVCQXG-UCJub_Eo.js → chunk-JSJVCQXG-2AjhqYcu.js} +1 -1
  23. package/dist/client/assets/{chunk-KX2RTZJC-DrhxxMOx.js → chunk-KX2RTZJC-tMPTaDcx.js} +1 -1
  24. package/dist/client/assets/{chunk-KYZI473N-Brv52ZeO.js → chunk-KYZI473N-CGwu81pT.js} +1 -1
  25. package/dist/client/assets/{chunk-L3YUKLVL-BkBigLhQ.js → chunk-L3YUKLVL-DFNfIVVw.js} +1 -1
  26. package/dist/client/assets/{chunk-MX3YWQON-DHRoNbgW.js → chunk-MX3YWQON-BnSlIBhe.js} +1 -1
  27. package/dist/client/assets/{chunk-NQ4KR5QH-BZ86r2qK.js → chunk-NQ4KR5QH-CpfTlpaZ.js} +1 -1
  28. package/dist/client/assets/{chunk-O4XLMI2P-Sr33dk8c.js → chunk-O4XLMI2P-BDGKGscp.js} +1 -1
  29. package/dist/client/assets/{chunk-OZEHJAEY-3F2ff7sj.js → chunk-OZEHJAEY-3OAEqm17.js} +1 -1
  30. package/dist/client/assets/{chunk-PQ6SQG4A-C9acTu_E.js → chunk-PQ6SQG4A-DfQjNfPX.js} +1 -1
  31. package/dist/client/assets/{chunk-PU5JKC2W-PQmA4K_y.js → chunk-PU5JKC2W-DRiL1iN6.js} +1 -1
  32. package/dist/client/assets/chunk-QZHKN3VN-DBD5yPlw.js +1 -0
  33. package/dist/client/assets/{chunk-R5LLSJPH-ChexuO_S.js → chunk-R5LLSJPH-dkcbq1pR.js} +1 -1
  34. package/dist/client/assets/{chunk-WL4C6EOR-oxNV_hhM.js → chunk-WL4C6EOR-BeCB6d6F.js} +1 -1
  35. package/dist/client/assets/{chunk-XIRO2GV7-C9gOnffv.js → chunk-XIRO2GV7-BxmEO1Vi.js} +1 -1
  36. package/dist/client/assets/{chunk-XPW4576I-CcqR6BsE.js → chunk-XPW4576I-jm7TiixU.js} +1 -1
  37. package/dist/client/assets/{chunk-XZSTWKYB-C5JJ0TZR.js → chunk-XZSTWKYB-BGKYCy46.js} +1 -1
  38. package/dist/client/assets/{chunk-YBOYWFTD-B6kAkNgH.js → chunk-YBOYWFTD-C9faLjdm.js} +1 -1
  39. package/dist/client/assets/classDiagram-VBA2DB6C-Depk8rxx.js +1 -0
  40. package/dist/client/assets/classDiagram-v2-RAHNMMFH-DHvQPm8y.js +1 -0
  41. package/dist/client/assets/{cose-bilkent-S5V4N54A-hlDud6Ym.js → cose-bilkent-S5V4N54A-BWD5TWFn.js} +1 -1
  42. package/dist/client/assets/{dagre-BwDYerGQ.js → dagre-Dd1VxucU.js} +1 -1
  43. package/dist/client/assets/{dagre-KLK3FWXG-KnkMUlUE.js → dagre-KLK3FWXG-BJFTyMud.js} +1 -1
  44. package/dist/client/assets/{diagram-E7M64L7V-DcTCIFUG.js → diagram-E7M64L7V-eWdHIl72.js} +1 -1
  45. package/dist/client/assets/{diagram-IFDJBPK2-COcDQunj.js → diagram-IFDJBPK2-C1-sqK0o.js} +1 -1
  46. package/dist/client/assets/{diagram-P4PSJMXO-DmgET9pD.js → diagram-P4PSJMXO-DHeUNvSg.js} +1 -1
  47. package/dist/client/assets/{dist-v55TM3-O.js → dist-FLbYR5UU.js} +1 -1
  48. package/dist/client/assets/{erDiagram-INFDFZHY-ByL02DP-.js → erDiagram-INFDFZHY-CX8FAWmU.js} +1 -1
  49. package/dist/client/assets/{flowDiagram-PKNHOUZH-CW-lseYE.js → flowDiagram-PKNHOUZH-DgBnUaHH.js} +1 -1
  50. package/dist/client/assets/{ganttDiagram-A5KZAMGK-BxLjKRld.js → ganttDiagram-A5KZAMGK-C331HQ-y.js} +1 -1
  51. package/dist/client/assets/gitGraph-HDMCJU4V-DPGoIMlm.js +1 -0
  52. package/dist/client/assets/{gitGraphDiagram-K3NZZRJ6-DLEDjokx.js → gitGraphDiagram-K3NZZRJ6-zEXLThxN.js} +1 -1
  53. package/dist/client/assets/index-BGPkswtu.js +79 -0
  54. package/dist/client/assets/{index-Cn4K2uvR.css → index-Cq_APK7Y.css} +1 -1
  55. package/dist/client/assets/info-3K5VOQVL-CYdIfRwG.js +1 -0
  56. package/dist/client/assets/{infoDiagram-LFFYTUFH-CnmYkyCb.js → infoDiagram-LFFYTUFH-CKx11_2a.js} +1 -1
  57. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-zycn1mVK.js → ishikawaDiagram-PHBUUO56-BlZMQgOe.js} +1 -1
  58. package/dist/client/assets/{journeyDiagram-4ABVD52K-aRoH36nV.js → journeyDiagram-4ABVD52K-C3p_p4rn.js} +1 -1
  59. package/dist/client/assets/{kanban-definition-K7BYSVSG-BGtGv5yb.js → kanban-definition-K7BYSVSG-4XJPQF50.js} +1 -1
  60. package/dist/client/assets/{line-Cm3ZuldI.js → line-Bb6xn3n_.js} +1 -1
  61. package/dist/client/assets/{linear-HJOLPv7E.js → linear-BPttYRJr.js} +1 -1
  62. package/dist/client/assets/{mermaid-parser.core-BvMqHn4b.js → mermaid-parser.core-CjY9NqXx.js} +2 -2
  63. package/dist/client/assets/{mermaid.core-C4SvQTx9.js → mermaid.core-B0ynITdC.js} +3 -3
  64. package/dist/client/assets/{mindmap-definition-YRQLILUH-B8jMe7ir.js → mindmap-definition-YRQLILUH-Dya2e4tr.js} +1 -1
  65. package/dist/client/assets/packet-RMMSAZCW-D7vTTuAT.js +1 -0
  66. package/dist/client/assets/pie-UPGHQEXC-ptFuye_f.js +1 -0
  67. package/dist/client/assets/{pieDiagram-SKSYHLDU-CGWbtgxq.js → pieDiagram-SKSYHLDU-MZ74L9cN.js} +1 -1
  68. package/dist/client/assets/{quadrantDiagram-337W2JSQ-CQ1QKsru.js → quadrantDiagram-337W2JSQ-Da_T39nG.js} +1 -1
  69. package/dist/client/assets/radar-KQ55EAFF-BR1_ZPLF.js +1 -0
  70. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-Co1LyL5T.js → requirementDiagram-Z7DCOOCP-DzlKWGt3.js} +1 -1
  71. package/dist/client/assets/{sankeyDiagram-WA2Y5GQK-BQVbT6bS.js → sankeyDiagram-WA2Y5GQK-BPketwK-.js} +1 -1
  72. package/dist/client/assets/{sequenceDiagram-2WXFIKYE-DGIEkdPm.js → sequenceDiagram-2WXFIKYE-geDrMLZ_.js} +1 -1
  73. package/dist/client/assets/{src-DsmFf7gO.js → src-BuTVwZtT.js} +1 -1
  74. package/dist/client/assets/{stateDiagram-RAJIS63D-DgjKbXnG.js → stateDiagram-RAJIS63D-DOLTjnid.js} +1 -1
  75. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-BIjVI5d6.js +1 -0
  76. package/dist/client/assets/{timeline-definition-YZTLITO2-Dz2dVWjY.js → timeline-definition-YZTLITO2-Cy6Qm4Pd.js} +1 -1
  77. package/dist/client/assets/treemap-KZPCXAKY-Bw93Vsua.js +1 -0
  78. package/dist/client/assets/{vennDiagram-LZ73GAT5-IIH5S1B6.js → vennDiagram-LZ73GAT5-UUQN9akd.js} +1 -1
  79. package/dist/client/assets/{xychartDiagram-JWTSCODW-DeYZhM2j.js → xychartDiagram-JWTSCODW-DiTicxdS.js} +1 -1
  80. package/dist/client/index.html +2 -2
  81. package/dist/server/git-diff-tui.d.ts +2 -2
  82. package/dist/server/git-diff-tui.js +12 -7
  83. package/dist/server/git-diff-tui.test.js +18 -2
  84. package/dist/server/git-diff.d.ts +3 -2
  85. package/dist/server/git-diff.js +29 -6
  86. package/dist/server/git-diff.test.js +52 -3
  87. package/dist/server/server.d.ts +2 -3
  88. package/dist/server/server.js +80 -55
  89. package/dist/server/server.test.js +110 -60
  90. package/dist/tui/App.d.ts +2 -2
  91. package/dist/tui/App.js +4 -3
  92. package/dist/types/diff.d.ts +8 -0
  93. package/dist/utils/diffSelection.d.ts +6 -0
  94. package/dist/utils/diffSelection.js +30 -0
  95. package/package.json +1 -1
  96. package/dist/client/assets/architecture-PBZL5I3N-DFdrPtRG.js +0 -1
  97. package/dist/client/assets/channel-DogeU0Wo.js +0 -1
  98. package/dist/client/assets/chunk-GLR3WWYH-NUOKNaxd.js +0 -2
  99. package/dist/client/assets/chunk-HHEYEP7N-DhuxpkmW.js +0 -1
  100. package/dist/client/assets/chunk-QZHKN3VN-DMRW-mur.js +0 -1
  101. package/dist/client/assets/classDiagram-VBA2DB6C-DlDUg6JI.js +0 -1
  102. package/dist/client/assets/classDiagram-v2-RAHNMMFH-BxzJfV1S.js +0 -1
  103. package/dist/client/assets/gitGraph-HDMCJU4V-CjAGJiCH.js +0 -1
  104. package/dist/client/assets/index-CizZxdOT.js +0 -79
  105. package/dist/client/assets/info-3K5VOQVL-CB6KpH1K.js +0 -1
  106. package/dist/client/assets/packet-RMMSAZCW-CzbC-tXD.js +0 -1
  107. package/dist/client/assets/pie-UPGHQEXC-CmhYIo8p.js +0 -1
  108. package/dist/client/assets/radar-KQ55EAFF-BCa9lsCc.js +0 -1
  109. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-gPrpjL74.js +0 -1
  110. package/dist/client/assets/treemap-KZPCXAKY-DXiPfAB6.js +0 -1
@@ -13,23 +13,56 @@ import { resolveEditorOption } from '../utils/editorOptions.js';
13
13
  import { getFileExtension } from '../utils/fileUtils.js';
14
14
  import { FileWatcherService } from './file-watcher.js';
15
15
  import { GitDiffParser } from './git-diff.js';
16
+ import { createDiffSelection, diffSelectionsEqual, getDiffSelectionKey, } from '../utils/diffSelection.js';
16
17
  const GENERATED_STATUS_CACHE_TTL_MS = 60_000;
18
+ const MAX_DIFF_CACHE_ENTRIES = 8;
19
+ function createDiffCacheKey(selection, ignoreWhitespace) {
20
+ return `${getDiffSelectionKey(selection)}\u0000${ignoreWhitespace ? '1' : '0'}`;
21
+ }
22
+ function getCachedDiffResponse(cache, key) {
23
+ const cached = cache.get(key);
24
+ if (!cached) {
25
+ return undefined;
26
+ }
27
+ // Refresh insertion order to keep the most recently used entry.
28
+ cache.delete(key);
29
+ cache.set(key, cached);
30
+ return cached;
31
+ }
32
+ function setCachedDiffResponse(cache, key, value) {
33
+ if (cache.has(key)) {
34
+ cache.delete(key);
35
+ }
36
+ cache.set(key, value);
37
+ while (cache.size > MAX_DIFF_CACHE_ENTRIES) {
38
+ const oldestKey = cache.keys().next().value;
39
+ if (typeof oldestKey !== 'string') {
40
+ break;
41
+ }
42
+ cache.delete(oldestKey);
43
+ }
44
+ }
17
45
  export async function startServer(options) {
18
46
  const app = express();
19
47
  const repositoryPath = resolve(options.repoPath ?? process.cwd());
20
48
  const repositoryId = createHash('sha256').update(repositoryPath).digest('hex');
21
49
  const initialCommentImports = options.commentImports || [];
22
- const initialBaseCommitish = options.baseCommitish ?? '';
23
- const initialTargetCommitish = options.targetCommitish ?? '';
50
+ const initialSelection = options.selection ?? createDiffSelection('', '');
24
51
  const commentImportId = initialCommentImports.length > 0
25
52
  ? createHash('sha256').update(serializeCommentImports(initialCommentImports)).digest('hex')
26
53
  : undefined;
27
54
  const parser = new GitDiffParser(repositoryPath);
28
55
  const fileWatcher = new FileWatcherService();
29
56
  const generatedStatusCache = new Map();
30
- let diffDataCache = null;
31
- let currentIgnoreWhitespace = options.ignoreWhitespace || false;
57
+ const diffDataCache = new Map();
58
+ const initialIgnoreWhitespace = options.ignoreWhitespace || false;
32
59
  const diffMode = normalizeDiffViewMode(options.mode);
60
+ const parseBaseMode = (value) => {
61
+ if (value === 'merge-base') {
62
+ return 'merge-base';
63
+ }
64
+ return undefined;
65
+ };
33
66
  app.use(express.json());
34
67
  app.use(express.text()); // For sendBeacon text/plain requests
35
68
  app.use((_req, res, next) => {
@@ -40,28 +73,29 @@ export async function startServer(options) {
40
73
  });
41
74
  // Skip validation if using stdin diff
42
75
  if (!options.stdinDiff) {
43
- const isValidCommit = await parser.validateCommit(options.targetCommitish ?? '');
76
+ const isValidCommit = await parser.validateCommit(initialSelection.targetCommitish);
44
77
  if (!isValidCommit) {
45
- throw new Error(`Invalid or non-existent commit: ${options.targetCommitish}`);
78
+ throw new Error(`Invalid or non-existent commit: ${initialSelection.targetCommitish}`);
46
79
  }
47
80
  }
48
81
  // Generate initial diff data for isEmpty check
82
+ let initialDiffData;
49
83
  if (options.stdinDiff) {
50
84
  // Parse stdin diff directly
51
- diffDataCache = parser.parseStdinDiff(options.stdinDiff);
85
+ initialDiffData = parser.parseStdinDiff(options.stdinDiff);
52
86
  }
53
87
  else {
54
- diffDataCache = await parser.parseDiff(options.targetCommitish ?? '', options.baseCommitish ?? '', currentIgnoreWhitespace, options.contextLines);
88
+ initialDiffData = await parser.parseDiff(initialSelection, initialIgnoreWhitespace, options.contextLines);
89
+ setCachedDiffResponse(diffDataCache, createDiffCacheKey(initialSelection, initialIgnoreWhitespace), initialDiffData);
55
90
  }
56
91
  // Function to invalidate cache when file changes are detected
57
92
  const invalidateCache = () => {
58
- diffDataCache = null;
93
+ diffDataCache.clear();
59
94
  generatedStatusCache.clear();
60
95
  parser.clearResolvedCommitCache();
61
96
  };
62
97
  // Track current revisions for cache invalidation
63
- let currentBaseCommitish = options.baseCommitish ?? '';
64
- let currentTargetCommitish = options.targetCommitish ?? '';
98
+ let currentSelection = initialSelection;
65
99
  function parseRepositoryRelativePath(filepath) {
66
100
  if (typeof filepath !== 'string' || filepath.length === 0) {
67
101
  return { ok: false, error: 'Invalid file path' };
@@ -79,56 +113,47 @@ export async function startServer(options) {
79
113
  }
80
114
  app.get('/api/diff', async (req, res) => {
81
115
  const ignoreWhitespace = req.query.ignoreWhitespace === 'true';
82
- const requestedBase = req.query.base || options.baseCommitish || '';
83
- const requestedTarget = req.query.target || options.targetCommitish || '';
116
+ const hasBase = typeof req.query.base === 'string';
117
+ const hasTarget = typeof req.query.target === 'string';
118
+ const hasBaseMode = typeof req.query.baseMode === 'string';
119
+ const requestedSelection = createDiffSelection(hasBase ? req.query.base : currentSelection.baseCommitish, hasTarget ? req.query.target : currentSelection.targetCommitish, hasBaseMode
120
+ ? parseBaseMode(req.query.baseMode)
121
+ : hasBase || hasTarget
122
+ ? undefined
123
+ : currentSelection.baseMode);
84
124
  const shouldIncludeCommentImports = initialCommentImports.length > 0 &&
85
- (Boolean(options.stdinDiff) ||
86
- (requestedBase === initialBaseCommitish && requestedTarget === initialTargetCommitish));
87
- // Check if revisions or whitespace setting changed
88
- const revisionsChanged = requestedBase !== currentBaseCommitish || requestedTarget !== currentTargetCommitish;
89
- const whitespaceChanged = ignoreWhitespace !== currentIgnoreWhitespace;
90
- // Regenerate diff data if cache is invalid or settings changed
91
- if (!diffDataCache || ((revisionsChanged || whitespaceChanged) && !options.stdinDiff)) {
92
- currentIgnoreWhitespace = ignoreWhitespace;
93
- currentBaseCommitish = requestedBase;
94
- currentTargetCommitish = requestedTarget;
95
- diffDataCache = await parser.parseDiff(requestedTarget, requestedBase, ignoreWhitespace, options.contextLines);
96
- generatedStatusCache.clear();
97
- }
98
- // Resolve symbolic refs like HEAD/HEAD^ to actual hashes for the UI
99
- let resolvedBase = currentBaseCommitish || 'stdin';
100
- let resolvedTarget = currentTargetCommitish || 'stdin';
101
- if (!options.stdinDiff &&
102
- currentBaseCommitish &&
103
- !['working', 'staged', '.'].includes(currentBaseCommitish)) {
104
- try {
105
- resolvedBase = await parser.resolveCommitish(currentBaseCommitish);
106
- }
107
- catch {
108
- // If resolution fails, keep original value
125
+ (Boolean(options.stdinDiff) || diffSelectionsEqual(requestedSelection, initialSelection));
126
+ currentSelection = requestedSelection;
127
+ let responseDiffData = initialDiffData;
128
+ if (!options.stdinDiff) {
129
+ const cacheKey = createDiffCacheKey(requestedSelection, ignoreWhitespace);
130
+ const cached = getCachedDiffResponse(diffDataCache, cacheKey);
131
+ if (cached) {
132
+ responseDiffData = cached;
109
133
  }
110
- }
111
- if (!options.stdinDiff &&
112
- currentTargetCommitish &&
113
- !['working', 'staged', '.'].includes(currentTargetCommitish)) {
114
- try {
115
- resolvedTarget = await parser.resolveCommitish(currentTargetCommitish);
116
- }
117
- catch {
118
- // If resolution fails, keep original value
134
+ else {
135
+ responseDiffData = await parser.parseDiff(requestedSelection, ignoreWhitespace, options.contextLines);
136
+ setCachedDiffResponse(diffDataCache, cacheKey, responseDiffData);
137
+ generatedStatusCache.clear();
119
138
  }
120
139
  }
121
- const requestedBaseCommitish = currentBaseCommitish || 'stdin';
122
- const requestedTargetCommitish = currentTargetCommitish || 'stdin';
140
+ const baseCommitish = responseDiffData.baseCommitish ?? (options.stdinDiff ? 'stdin' : undefined);
141
+ const targetCommitish = responseDiffData.targetCommitish ?? (options.stdinDiff ? 'stdin' : undefined);
142
+ const requestedBaseCommitish = responseDiffData.requestedBaseCommitish ??
143
+ (requestedSelection.baseCommitish || (options.stdinDiff ? 'stdin' : undefined));
144
+ const requestedTargetCommitish = responseDiffData.requestedTargetCommitish ??
145
+ (requestedSelection.targetCommitish || (options.stdinDiff ? 'stdin' : undefined));
146
+ const requestedBaseMode = responseDiffData.requestedBaseMode ?? requestedSelection.baseMode;
123
147
  res.json({
124
- ...diffDataCache,
148
+ ...responseDiffData,
125
149
  ignoreWhitespace,
126
150
  mode: diffMode,
127
151
  openInEditorAvailable: !options.stdinDiff,
128
- baseCommitish: resolvedBase,
129
- targetCommitish: resolvedTarget,
152
+ baseCommitish,
153
+ targetCommitish,
130
154
  requestedBaseCommitish,
131
155
  requestedTargetCommitish,
156
+ requestedBaseMode,
132
157
  clearComments: options.clearComments,
133
158
  repositoryId,
134
159
  commentImports: shouldIncludeCommentImports ? initialCommentImports : undefined,
@@ -147,7 +172,7 @@ export async function startServer(options) {
147
172
  return;
148
173
  }
149
174
  const normalizedFilepath = filepathResult.path;
150
- const ref = req.query.ref || currentTargetCommitish || 'HEAD';
175
+ const ref = req.query.ref || currentSelection.targetCommitish || 'HEAD';
151
176
  const cacheKey = `${ref}:${normalizedFilepath}`;
152
177
  const now = Date.now();
153
178
  const cached = generatedStatusCache.get(cacheKey);
@@ -179,7 +204,7 @@ export async function startServer(options) {
179
204
  return;
180
205
  }
181
206
  try {
182
- const { branches, commits, originDefaultBranch, resolvedBase, resolvedTarget } = await parser.getRevisionOptions(currentBaseCommitish, currentTargetCommitish);
207
+ const { branches, commits, originDefaultBranch, resolvedBase, resolvedTarget } = await parser.getRevisionOptions(currentSelection.baseCommitish, currentSelection.targetCommitish);
183
208
  const response = {
184
209
  specialOptions: [
185
210
  { value: '.', label: 'All Uncommitted Changes' },
@@ -528,7 +553,7 @@ export async function startServer(options) {
528
553
  }
529
554
  }
530
555
  // Check if diff is empty and skip browser opening
531
- if (diffDataCache?.isEmpty) {
556
+ if (initialDiffData.isEmpty) {
532
557
  // Don't open browser if no differences found
533
558
  }
534
559
  else if (options.openBrowser) {
@@ -539,7 +564,7 @@ export async function startServer(options) {
539
564
  console.warn('Failed to open browser automatically');
540
565
  }
541
566
  }
542
- return { port, url, isEmpty: diffDataCache?.isEmpty || false, server };
567
+ return { port, url, isEmpty: initialDiffData.isEmpty || false, server };
543
568
  }
544
569
  async function startServerWithFallback(app, preferredPort, host) {
545
570
  return new Promise((resolve, reject) => {
@@ -33,6 +33,11 @@ vi.mock('./git-diff.js', () => {
33
33
  parseDiff = vi.fn().mockResolvedValue({
34
34
  targetCommit: 'abc123',
35
35
  baseCommit: 'def456',
36
+ baseCommitish: 'def4567',
37
+ targetCommitish: 'abc1234',
38
+ requestedBaseCommitish: 'HEAD^',
39
+ requestedTargetCommitish: 'HEAD',
40
+ requestedBaseMode: undefined,
36
41
  targetMessage: 'Test commit',
37
42
  baseMessage: 'Previous commit',
38
43
  files: [
@@ -192,8 +197,7 @@ describe('Server Integration Tests', () => {
192
197
  // Use a high port number to avoid conflicts
193
198
  const preferredPort = 9000;
194
199
  const result = await startServer({
195
- targetCommitish: 'HEAD',
196
- baseCommitish: 'HEAD^',
200
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
197
201
  preferredPort,
198
202
  });
199
203
  servers.push(result.server); // Track for cleanup
@@ -206,15 +210,13 @@ describe('Server Integration Tests', () => {
206
210
  const preferredPort = 9010;
207
211
  // Start server on port 9010
208
212
  const firstServer = await startServer({
209
- targetCommitish: 'HEAD',
210
- baseCommitish: 'HEAD^',
213
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
211
214
  preferredPort,
212
215
  });
213
216
  servers.push(firstServer.server);
214
217
  // Try to start another server on the same port
215
218
  const secondServer = await startServer({
216
- targetCommitish: 'HEAD',
217
- baseCommitish: 'HEAD^',
219
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
218
220
  preferredPort,
219
221
  });
220
222
  servers.push(secondServer.server);
@@ -224,8 +226,7 @@ describe('Server Integration Tests', () => {
224
226
  });
225
227
  it('binds to specified host', async () => {
226
228
  const result = await startServer({
227
- targetCommitish: 'HEAD',
228
- baseCommitish: 'HEAD^',
229
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
229
230
  host: '0.0.0.0',
230
231
  preferredPort: 9020,
231
232
  });
@@ -234,22 +235,20 @@ describe('Server Integration Tests', () => {
234
235
  });
235
236
  it('passes context lines to the initial diff load', async () => {
236
237
  const result = await startServer({
237
- targetCommitish: 'HEAD',
238
- baseCommitish: 'HEAD^',
238
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
239
239
  preferredPort: 9025,
240
240
  contextLines: 4,
241
241
  });
242
242
  servers.push(result.server);
243
243
  const parser = parserInstances.at(-1);
244
- expect(parser?.parseDiff).toHaveBeenCalledWith('HEAD', 'HEAD^', false, 4);
244
+ expect(parser?.parseDiff).toHaveBeenCalledWith({ targetCommitish: 'HEAD', baseCommitish: 'HEAD^' }, false, 4);
245
245
  });
246
246
  });
247
247
  describe('API endpoints', () => {
248
248
  let port;
249
249
  beforeEach(async () => {
250
250
  const result = await startServer({
251
- targetCommitish: 'HEAD',
252
- baseCommitish: 'HEAD^',
251
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
253
252
  preferredPort: 9030,
254
253
  });
255
254
  servers.push(result.server);
@@ -266,6 +265,8 @@ describe('Server Integration Tests', () => {
266
265
  expect(data.files[0]).toHaveProperty('path', 'test.js');
267
266
  expect(data).toHaveProperty('ignoreWhitespace', false);
268
267
  expect(data).toHaveProperty('openInEditorAvailable', true);
268
+ expect(data).toHaveProperty('requestedBaseCommitish', 'HEAD^');
269
+ expect(data).toHaveProperty('requestedTargetCommitish', 'HEAD');
269
270
  });
270
271
  it('GET /api/diff?ignoreWhitespace=true handles whitespace ignore', async () => {
271
272
  const response = await fetch(`http://localhost:${port}/api/diff?ignoreWhitespace=true`);
@@ -275,8 +276,7 @@ describe('Server Integration Tests', () => {
275
276
  });
276
277
  it('GET /api/diff preserves context lines when recalculating revisions', async () => {
277
278
  const result = await startServer({
278
- targetCommitish: 'HEAD',
279
- baseCommitish: 'HEAD^',
279
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
280
280
  preferredPort: 9031,
281
281
  contextLines: 2,
282
282
  });
@@ -285,7 +285,78 @@ describe('Server Integration Tests', () => {
285
285
  parser?.parseDiff.mockClear();
286
286
  const response = await fetch(`http://localhost:${result.port}/api/diff?base=main&target=feature&ignoreWhitespace=true`);
287
287
  expect(response.ok).toBe(true);
288
- expect(parser?.parseDiff).toHaveBeenCalledWith('feature', 'main', true, 2);
288
+ expect(parser?.parseDiff).toHaveBeenCalledWith({ targetCommitish: 'feature', baseCommitish: 'main' }, true, 2);
289
+ });
290
+ it('GET /api/diff passes baseMode through to the parser', async () => {
291
+ const parser = parserInstances.at(-1);
292
+ parser?.parseDiff.mockClear();
293
+ parser?.parseDiff.mockResolvedValueOnce({
294
+ targetCommit: 'abc123',
295
+ baseCommit: 'def456',
296
+ baseCommitish: 'fedcba9',
297
+ targetCommitish: '.',
298
+ requestedBaseCommitish: 'origin/main',
299
+ requestedTargetCommitish: '.',
300
+ requestedBaseMode: 'merge-base',
301
+ files: [],
302
+ isEmpty: true,
303
+ });
304
+ const response = await fetch(`http://localhost:${port}/api/diff?base=origin%2Fmain&target=.&baseMode=merge-base`);
305
+ const data = (await response.json());
306
+ expect(response.ok).toBe(true);
307
+ expect(parser?.parseDiff).toHaveBeenCalledWith({
308
+ targetCommitish: '.',
309
+ baseCommitish: 'origin/main',
310
+ baseMode: 'merge-base',
311
+ }, false, undefined);
312
+ expect(data.requestedBaseMode).toBe('merge-base');
313
+ expect(data.baseCommitish).toBe('fedcba9');
314
+ expect(data.requestedBaseCommitish).toBe('origin/main');
315
+ });
316
+ it('GET /api/diff caches results per revision pair instead of reusing the last request', async () => {
317
+ const result = await startServer({
318
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
319
+ preferredPort: 9032,
320
+ });
321
+ servers.push(result.server);
322
+ const parser = parserInstances.at(-1);
323
+ parser?.parseDiff.mockClear();
324
+ const firstResponse = await fetch(`http://localhost:${result.port}/api/diff?base=main&target=feature`);
325
+ expect(firstResponse.ok).toBe(true);
326
+ const secondResponse = await fetch(`http://localhost:${result.port}/api/diff?base=HEAD%5E&target=HEAD`);
327
+ expect(secondResponse.ok).toBe(true);
328
+ const thirdResponse = await fetch(`http://localhost:${result.port}/api/diff?base=main&target=feature`);
329
+ expect(thirdResponse.ok).toBe(true);
330
+ expect(parser?.parseDiff).toHaveBeenCalledTimes(1);
331
+ expect(parser?.parseDiff).toHaveBeenNthCalledWith(1, { targetCommitish: 'feature', baseCommitish: 'main' }, false, undefined);
332
+ });
333
+ it('GET /api/diff evicts least recently used cached diff responses', async () => {
334
+ const result = await startServer({
335
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
336
+ preferredPort: 9033,
337
+ });
338
+ servers.push(result.server);
339
+ const parser = parserInstances.at(-1);
340
+ parser?.parseDiff.mockClear();
341
+ const revisionPairs = [
342
+ ['base-a', 'target-a'],
343
+ ['base-b', 'target-b'],
344
+ ['base-c', 'target-c'],
345
+ ['base-d', 'target-d'],
346
+ ['base-e', 'target-e'],
347
+ ['base-f', 'target-f'],
348
+ ['base-g', 'target-g'],
349
+ ['base-h', 'target-h'],
350
+ ['base-i', 'target-i'],
351
+ ];
352
+ for (const [base, target] of revisionPairs) {
353
+ const response = await fetch(`http://localhost:${result.port}/api/diff?base=${base}&target=${target}`);
354
+ expect(response.ok).toBe(true);
355
+ }
356
+ const revisitedResponse = await fetch(`http://localhost:${result.port}/api/diff?base=base-a&target=target-a`);
357
+ expect(revisitedResponse.ok).toBe(true);
358
+ expect(parser?.parseDiff).toHaveBeenCalledTimes(10);
359
+ expect(parser?.parseDiff).toHaveBeenLastCalledWith({ targetCommitish: 'target-a', baseCommitish: 'base-a' }, false, undefined);
289
360
  });
290
361
  it('GET /api/diff returns comment import payload when configured', async () => {
291
362
  const importedComments = [
@@ -297,8 +368,7 @@ describe('Server Integration Tests', () => {
297
368
  },
298
369
  ];
299
370
  const importServer = await startServer({
300
- targetCommitish: 'HEAD',
301
- baseCommitish: 'HEAD^',
371
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
302
372
  preferredPort: 9034,
303
373
  commentImports: importedComments,
304
374
  });
@@ -319,8 +389,7 @@ describe('Server Integration Tests', () => {
319
389
  },
320
390
  ];
321
391
  const importServer = await startServer({
322
- targetCommitish: 'HEAD',
323
- baseCommitish: 'HEAD^',
392
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
324
393
  preferredPort: 9037,
325
394
  clearComments: true,
326
395
  commentImports: importedComments,
@@ -343,8 +412,7 @@ describe('Server Integration Tests', () => {
343
412
  },
344
413
  ];
345
414
  const importServer = await startServer({
346
- targetCommitish: 'HEAD',
347
- baseCommitish: 'HEAD^',
415
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
348
416
  preferredPort: 9038,
349
417
  commentImports: importedComments,
350
418
  });
@@ -502,8 +570,7 @@ describe('Server Integration Tests', () => {
502
570
  it('serves dev mode HTML in development', async () => {
503
571
  process.env.NODE_ENV = 'development';
504
572
  const result = await startServer({
505
- targetCommitish: 'HEAD',
506
- baseCommitish: 'HEAD^',
573
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
507
574
  preferredPort: 9040,
508
575
  });
509
576
  servers.push(result.server);
@@ -516,8 +583,7 @@ describe('Server Integration Tests', () => {
516
583
  it('serves static files in production mode', async () => {
517
584
  process.env.NODE_ENV = 'production';
518
585
  const result = await startServer({
519
- targetCommitish: 'HEAD',
520
- baseCommitish: 'HEAD^',
586
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
521
587
  preferredPort: 9050,
522
588
  });
523
589
  servers.push(result.server);
@@ -531,8 +597,7 @@ describe('Server Integration Tests', () => {
531
597
  it('returns 404 for unknown paths in production mode', async () => {
532
598
  process.env.NODE_ENV = 'production';
533
599
  const result = await startServer({
534
- targetCommitish: 'HEAD',
535
- baseCommitish: 'HEAD^',
600
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
536
601
  preferredPort: 9055,
537
602
  });
538
603
  servers.push(result.server);
@@ -544,8 +609,7 @@ describe('Server Integration Tests', () => {
544
609
  it('accepts mode option in server configuration', async () => {
545
610
  // Test that mode option is accepted without error
546
611
  const result = await startServer({
547
- targetCommitish: 'HEAD',
548
- baseCommitish: 'HEAD^',
612
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
549
613
  mode: 'unified',
550
614
  });
551
615
  servers.push(result.server);
@@ -554,14 +618,12 @@ describe('Server Integration Tests', () => {
554
618
  });
555
619
  it('accepts different mode values', async () => {
556
620
  const inlineResult = await startServer({
557
- targetCommitish: 'HEAD',
558
- baseCommitish: 'HEAD^',
621
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
559
622
  mode: 'unified',
560
623
  });
561
624
  servers.push(inlineResult.server);
562
625
  const sideBySideResult = await startServer({
563
- targetCommitish: 'HEAD',
564
- baseCommitish: 'HEAD^',
626
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
565
627
  mode: 'split',
566
628
  });
567
629
  servers.push(sideBySideResult.server);
@@ -570,8 +632,7 @@ describe('Server Integration Tests', () => {
570
632
  });
571
633
  it('mode option should be included in diff response', async () => {
572
634
  const result = await startServer({
573
- targetCommitish: 'HEAD',
574
- baseCommitish: 'HEAD^',
635
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
575
636
  mode: 'inline',
576
637
  });
577
638
  servers.push(result.server);
@@ -584,14 +645,14 @@ describe('Server Integration Tests', () => {
584
645
  describe('Revision options API', () => {
585
646
  it('returns available revisions', async () => {
586
647
  const result = await startServer({
587
- targetCommitish: 'HEAD',
588
- baseCommitish: 'HEAD^',
648
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
589
649
  });
590
650
  servers.push(result.server);
591
651
  const response = await fetch(`http://localhost:${result.port}/api/revisions`);
592
652
  const data = (await response.json());
593
653
  expect(response.ok).toBe(true);
594
654
  expect(data.specialOptions).toHaveLength(3);
655
+ expect(data.specialOptions).not.toContainEqual({ value: 'merge-base', label: 'Merge Base' });
595
656
  expect(data.branches).toEqual([{ name: 'main', current: true }]);
596
657
  expect(data.commits).toEqual([
597
658
  { hash: 'abc1234', shortHash: 'abc1234', message: 'Test commit' },
@@ -609,8 +670,7 @@ describe('Server Integration Tests', () => {
609
670
  });
610
671
  it('handles malformed comment data', async () => {
611
672
  const result = await startServer({
612
- targetCommitish: 'HEAD',
613
- baseCommitish: 'HEAD^',
673
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
614
674
  });
615
675
  servers.push(result.server);
616
676
  const response = await fetch(`http://localhost:${result.port}/api/comments`, {
@@ -632,8 +692,7 @@ describe('Server Integration Tests', () => {
632
692
  describe('CORS configuration', () => {
633
693
  it('sets correct CORS headers', async () => {
634
694
  const result = await startServer({
635
- targetCommitish: 'HEAD',
636
- baseCommitish: 'HEAD^',
695
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
637
696
  });
638
697
  servers.push(result.server);
639
698
  const response = await fetch(`http://localhost:${result.port}/api/diff`);
@@ -646,8 +705,7 @@ describe('Server Integration Tests', () => {
646
705
  let port;
647
706
  beforeEach(async () => {
648
707
  const result = await startServer({
649
- targetCommitish: 'HEAD',
650
- baseCommitish: 'HEAD^',
708
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
651
709
  preferredPort: 9050,
652
710
  });
653
711
  servers.push(result.server);
@@ -679,8 +737,7 @@ describe('Server Integration Tests', () => {
679
737
  let port;
680
738
  beforeEach(async () => {
681
739
  const result = await startServer({
682
- targetCommitish: 'HEAD',
683
- baseCommitish: 'HEAD^',
740
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
684
741
  preferredPort: 9060,
685
742
  });
686
743
  servers.push(result.server);
@@ -765,8 +822,7 @@ describe('Server Integration Tests', () => {
765
822
  describe('Keep-alive option', () => {
766
823
  it('accepts keepAlive option without error', async () => {
767
824
  const { port, server } = await startServer({
768
- targetCommitish: 'HEAD',
769
- baseCommitish: 'HEAD^',
825
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
770
826
  keepAlive: true,
771
827
  });
772
828
  servers.push(server);
@@ -774,16 +830,14 @@ describe('Server Integration Tests', () => {
774
830
  });
775
831
  it('starts normally without keepAlive option', async () => {
776
832
  const { port, server } = await startServer({
777
- targetCommitish: 'HEAD',
778
- baseCommitish: 'HEAD^',
833
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
779
834
  });
780
835
  servers.push(server);
781
836
  expect(port).toBeGreaterThanOrEqual(4966);
782
837
  });
783
838
  it('does not call process.exit on client disconnect when keepAlive is true', async () => {
784
839
  const { port, server } = await startServer({
785
- targetCommitish: 'HEAD',
786
- baseCommitish: 'HEAD^',
840
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
787
841
  keepAlive: true,
788
842
  preferredPort: 9070,
789
843
  });
@@ -811,8 +865,7 @@ describe('Server Integration Tests', () => {
811
865
  });
812
866
  it('calls process.exit on client disconnect when keepAlive is false', async () => {
813
867
  const { port, server } = await startServer({
814
- targetCommitish: 'HEAD',
815
- baseCommitish: 'HEAD^',
868
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
816
869
  keepAlive: false,
817
870
  preferredPort: 9080,
818
871
  });
@@ -841,8 +894,7 @@ describe('Server Integration Tests', () => {
841
894
  describe('Clear Comments functionality', () => {
842
895
  it('includes clearComments flag in diff response when provided', async () => {
843
896
  const { port, server } = await startServer({
844
- targetCommitish: 'HEAD',
845
- baseCommitish: 'HEAD^',
897
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
846
898
  clearComments: true,
847
899
  });
848
900
  servers.push(server);
@@ -853,8 +905,7 @@ describe('Server Integration Tests', () => {
853
905
  });
854
906
  it('does not include clearComments flag when not provided', async () => {
855
907
  const { port, server } = await startServer({
856
- targetCommitish: 'HEAD',
857
- baseCommitish: 'HEAD^',
908
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
858
909
  });
859
910
  servers.push(server);
860
911
  const response = await fetch(`http://localhost:${port}/api/diff`);
@@ -864,8 +915,7 @@ describe('Server Integration Tests', () => {
864
915
  });
865
916
  it('preserves clearComments flag across diff requests', async () => {
866
917
  const { port, server } = await startServer({
867
- targetCommitish: 'HEAD',
868
- baseCommitish: 'HEAD^',
918
+ selection: { targetCommitish: 'HEAD', baseCommitish: 'HEAD^' },
869
919
  clearComments: true,
870
920
  });
871
921
  servers.push(server);
package/dist/tui/App.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
+ import { type DiffSelection } from '../types/diff.js';
2
3
  interface AppProps {
3
- targetCommitish: string;
4
- baseCommitish: string;
4
+ selection: DiffSelection;
5
5
  mode?: string;
6
6
  repoPath?: string;
7
7
  contextLines?: number;
package/dist/tui/App.js CHANGED
@@ -6,7 +6,8 @@ import DiffViewer from './components/DiffViewer.js';
6
6
  import FileList from './components/FileList.js';
7
7
  import SideBySideDiffViewer from './components/SideBySideDiffViewer.js';
8
8
  import StatusBar from './components/StatusBar.js';
9
- const App = ({ targetCommitish, baseCommitish, mode, repoPath, contextLines, }) => {
9
+ const App = ({ selection, mode, repoPath, contextLines }) => {
10
+ const { targetCommitish, baseCommitish } = selection;
10
11
  const [files, setFiles] = useState([]);
11
12
  const [selectedFileIndex, setSelectedFileIndex] = useState(0);
12
13
  const [loading, setLoading] = useState(true);
@@ -17,7 +18,7 @@ const App = ({ targetCommitish, baseCommitish, mode, repoPath, contextLines, })
17
18
  setLoading(true);
18
19
  setError(null);
19
20
  try {
20
- const fileDiffs = await loadGitDiff(targetCommitish, baseCommitish, repoPath, contextLines);
21
+ const fileDiffs = await loadGitDiff(selection, repoPath, contextLines);
21
22
  setFiles(fileDiffs);
22
23
  setLoading(false);
23
24
  }
@@ -30,7 +31,7 @@ const App = ({ targetCommitish, baseCommitish, mode, repoPath, contextLines, })
30
31
  // oxlint-disable-next-line react-hooks-js/set-state-in-effect -- intentional: trigger initial diff load when revisions change
31
32
  void loadDiff();
32
33
  // oxlint-disable-next-line react/exhaustive-deps
33
- }, [targetCommitish, baseCommitish]);
34
+ }, [baseCommitish, targetCommitish]);
34
35
  useInput((input, key) => {
35
36
  if (input === 'q' || (key.ctrl && input === 'c')) {
36
37
  exit();