diffstalker 0.2.1 → 0.2.2

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.
@@ -1,53 +1,5 @@
1
1
  import { categorizeFiles } from '../../utils/fileCategories.js';
2
- import { shortenPath } from '../../utils/formatPath.js';
3
- function getStatusChar(status) {
4
- switch (status) {
5
- case 'modified':
6
- return 'M';
7
- case 'added':
8
- return 'A';
9
- case 'deleted':
10
- return 'D';
11
- case 'untracked':
12
- return '?';
13
- case 'renamed':
14
- return 'R';
15
- case 'copied':
16
- return 'C';
17
- default:
18
- return ' ';
19
- }
20
- }
21
- function getStatusColor(status) {
22
- switch (status) {
23
- case 'modified':
24
- return 'yellow';
25
- case 'added':
26
- return 'green';
27
- case 'deleted':
28
- return 'red';
29
- case 'untracked':
30
- return 'gray';
31
- case 'renamed':
32
- return 'blue';
33
- case 'copied':
34
- return 'cyan';
35
- default:
36
- return 'white';
37
- }
38
- }
39
- function formatStats(insertions, deletions) {
40
- if (insertions === undefined && deletions === undefined)
41
- return '';
42
- const parts = [];
43
- if (insertions !== undefined && insertions > 0) {
44
- parts.push(`{green-fg}+${insertions}{/green-fg}`);
45
- }
46
- if (deletions !== undefined && deletions > 0) {
47
- parts.push(`{red-fg}-${deletions}{/red-fg}`);
48
- }
49
- return parts.length > 0 ? ' ' + parts.join(' ') : '';
50
- }
2
+ import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
51
3
  /**
52
4
  * Build the list of row items for the file list.
53
5
  */
@@ -81,10 +33,52 @@ export function buildFileListRows(files) {
81
33
  }
82
34
  return rows;
83
35
  }
36
+ /**
37
+ * Format a single file row as blessed-compatible tagged string.
38
+ */
39
+ /**
40
+ * Format hunk count indicator for a file, e.g. "@2/3".
41
+ * Returns empty string if not applicable.
42
+ */
43
+ function formatHunkIndicator(file, hunkCounts) {
44
+ if (!hunkCounts)
45
+ return '';
46
+ const stagedHunks = hunkCounts.staged.get(file.path) ?? 0;
47
+ const unstagedHunks = hunkCounts.unstaged.get(file.path) ?? 0;
48
+ const total = stagedHunks + unstagedHunks;
49
+ if (total === 0)
50
+ return '';
51
+ const thisCount = file.staged ? stagedHunks : unstagedHunks;
52
+ // Show just @total when all hunks are in this state, otherwise @n/total
53
+ if (thisCount === total)
54
+ return ` {cyan-fg}@${total}{/cyan-fg}`;
55
+ return ` {cyan-fg}@${thisCount}/${total}{/cyan-fg}`;
56
+ }
57
+ function formatFileRow(file, fileIndex, selectedIndex, isFocused, maxPathLength, hunkCounts) {
58
+ const isSelected = fileIndex === selectedIndex;
59
+ const statusChar = getStatusChar(file.status);
60
+ const statusColor = getStatusColor(file.status);
61
+ const actionButton = file.staged ? '[-]' : '[+]';
62
+ const buttonColor = file.staged ? 'red' : 'green';
63
+ // Calculate available space for path
64
+ const stats = formatStats(file.insertions, file.deletions);
65
+ const hunkIndicator = formatHunkIndicator(file, hunkCounts);
66
+ const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
67
+ const hunkLength = hunkIndicator.replace(/\{[^}]+\}/g, '').length;
68
+ const availableForPath = maxPathLength - statsLength - hunkLength;
69
+ let line = formatSelectionIndicator(isSelected, isFocused);
70
+ line += `{${buttonColor}-fg}${actionButton}{/${buttonColor}-fg} `;
71
+ line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
72
+ line += formatFilePath(file.path, isSelected, isFocused, availableForPath);
73
+ line += formatOriginalPath(file.originalPath);
74
+ line += stats;
75
+ line += hunkIndicator;
76
+ return line;
77
+ }
84
78
  /**
85
79
  * Format the file list as blessed-compatible tagged string.
86
80
  */
87
- export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
81
+ export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, hunkCounts) {
88
82
  if (files.length === 0) {
89
83
  return '{gray-fg} No changes{/gray-fg}';
90
84
  }
@@ -95,53 +89,26 @@ export function formatFileList(files, selectedIndex, isFocused, width, scrollOff
95
89
  ? rows.slice(scrollOffset, scrollOffset + maxHeight)
96
90
  : rows.slice(scrollOffset);
97
91
  const lines = [];
92
+ let seenFirstHeader = false;
98
93
  for (const row of visibleRows) {
99
- if (row.type === 'header') {
100
- lines.push(`{bold}{${row.headerColor}-fg}${row.content}{/${row.headerColor}-fg}{/bold}`);
101
- }
102
- else if (row.type === 'spacer') {
103
- lines.push('');
104
- }
105
- else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
106
- const file = row.file;
107
- const isSelected = row.fileIndex === selectedIndex;
108
- const isHighlighted = isSelected && isFocused;
109
- const statusChar = getStatusChar(file.status);
110
- const statusColor = getStatusColor(file.status);
111
- const actionButton = file.staged ? '[-]' : '[+]';
112
- const buttonColor = file.staged ? 'red' : 'green';
113
- // Calculate available space for path
114
- const stats = formatStats(file.insertions, file.deletions);
115
- const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
116
- const availableForPath = maxPathLength - statsLength;
117
- const displayPath = shortenPath(file.path, availableForPath);
118
- // Build the line
119
- let line = '';
120
- // Selection indicator
121
- if (isHighlighted) {
122
- line += '{cyan-fg}{bold}\u25b8 {/bold}{/cyan-fg}';
123
- }
124
- else {
125
- line += ' ';
126
- }
127
- // Action button
128
- line += `{${buttonColor}-fg}${actionButton}{/${buttonColor}-fg} `;
129
- // Status character
130
- line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
131
- // File path (with highlighting)
132
- if (isHighlighted) {
133
- line += `{cyan-fg}{inverse}${displayPath}{/inverse}{/cyan-fg}`;
134
- }
135
- else {
136
- line += displayPath;
137
- }
138
- // Original path for renames
139
- if (file.originalPath) {
140
- line += ` {gray-fg}\u2190 ${shortenPath(file.originalPath, 30)}{/gray-fg}`;
94
+ switch (row.type) {
95
+ case 'header': {
96
+ let headerLine = `{bold}{${row.headerColor}-fg}${row.content}{/${row.headerColor}-fg}{/bold}`;
97
+ if (!seenFirstHeader) {
98
+ seenFirstHeader = true;
99
+ headerLine += ' {gray-fg}(h:flat){/gray-fg}';
100
+ }
101
+ lines.push(headerLine);
102
+ break;
141
103
  }
142
- // Stats
143
- line += stats;
144
- lines.push(line);
104
+ case 'spacer':
105
+ lines.push('');
106
+ break;
107
+ case 'file':
108
+ if (row.file && row.fileIndex !== undefined) {
109
+ lines.push(formatFileRow(row.file, row.fileIndex, selectedIndex, isFocused, maxPathLength, hunkCounts ?? null));
110
+ }
111
+ break;
145
112
  }
146
113
  }
147
114
  return lines.join('\n');
@@ -0,0 +1,65 @@
1
+ import { getStatusChar, getStatusColor, formatStats, formatSelectionIndicator, formatFilePath, formatOriginalPath, } from './fileRowFormatters.js';
2
+ function getStagingButton(state) {
3
+ switch (state) {
4
+ case 'unstaged':
5
+ return { text: '[+]', color: 'green' };
6
+ case 'staged':
7
+ return { text: '[-]', color: 'red' };
8
+ case 'partial':
9
+ return { text: '[~]', color: 'yellow' };
10
+ }
11
+ }
12
+ function formatFlatHunkIndicator(entry) {
13
+ if (entry.totalHunks === 0)
14
+ return '';
15
+ // Always show staged/total in flat view
16
+ return ` {cyan-fg}@${entry.stagedHunks}/${entry.totalHunks}{/cyan-fg}`;
17
+ }
18
+ function formatFlatFileRow(entry, index, selectedIndex, isFocused, maxPathLength) {
19
+ const isSelected = index === selectedIndex;
20
+ const statusChar = getStatusChar(entry.status);
21
+ const statusColor = getStatusColor(entry.status);
22
+ const button = getStagingButton(entry.stagingState);
23
+ const stats = formatStats(entry.insertions, entry.deletions);
24
+ const hunkIndicator = formatFlatHunkIndicator(entry);
25
+ const statsLength = stats.replace(/\{[^}]+\}/g, '').length;
26
+ const hunkLength = hunkIndicator.replace(/\{[^}]+\}/g, '').length;
27
+ const availableForPath = maxPathLength - statsLength - hunkLength;
28
+ let line = formatSelectionIndicator(isSelected, isFocused);
29
+ line += `{${button.color}-fg}${button.text}{/${button.color}-fg} `;
30
+ line += `{${statusColor}-fg}${statusChar}{/${statusColor}-fg} `;
31
+ line += formatFilePath(entry.path, isSelected, isFocused, availableForPath);
32
+ line += formatOriginalPath(entry.originalPath);
33
+ line += stats;
34
+ line += hunkIndicator;
35
+ return line;
36
+ }
37
+ /**
38
+ * Format the flat file list as blessed-compatible tagged string.
39
+ * Row 0 is a header; files start at row 1.
40
+ */
41
+ export function formatFlatFileList(flatFiles, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
42
+ if (flatFiles.length === 0) {
43
+ return '{gray-fg} No changes{/gray-fg}';
44
+ }
45
+ const maxPathLength = width - 12;
46
+ // Build all rows: header + file rows
47
+ const allRows = [];
48
+ allRows.push('{bold}{gray-fg}All files (h):{/gray-fg}{/bold}');
49
+ for (let i = 0; i < flatFiles.length; i++) {
50
+ allRows.push(formatFlatFileRow(flatFiles[i], i, selectedIndex, isFocused, maxPathLength));
51
+ }
52
+ // Apply scroll offset and max height
53
+ const visibleRows = maxHeight
54
+ ? allRows.slice(scrollOffset, scrollOffset + maxHeight)
55
+ : allRows.slice(scrollOffset);
56
+ return visibleRows.join('\n');
57
+ }
58
+ /**
59
+ * Total rows in the flat file list (header + files).
60
+ */
61
+ export function getFlatFileListTotalRows(flatFiles) {
62
+ if (flatFiles.length === 0)
63
+ return 0;
64
+ return flatFiles.length + 1; // +1 for header
65
+ }
@@ -4,26 +4,36 @@
4
4
  function calculateVisibleLength(content) {
5
5
  return content.replace(/\{[^}]+\}/g, '').length;
6
6
  }
7
+ /**
8
+ * Format a toggle indicator: blue when on, gray when off.
9
+ */
10
+ function toggleIndicator(label, enabled) {
11
+ return enabled ? `{blue-fg}[${label}]{/blue-fg}` : `{gray-fg}[${label}]{/gray-fg}`;
12
+ }
13
+ /**
14
+ * Build the left-side indicators for the standard (non-hunk) footer.
15
+ */
16
+ function buildStandardIndicators(mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, activeTab) {
17
+ const parts = [];
18
+ parts.push(mouseEnabled ? '{yellow-fg}[scroll]{/yellow-fg}' : '{yellow-fg}m:[select]{/yellow-fg}');
19
+ parts.push(toggleIndicator('auto', autoTabEnabled));
20
+ parts.push(toggleIndicator('wrap', wrapMode));
21
+ parts.push(toggleIndicator('follow', followEnabled));
22
+ if (activeTab === 'explorer') {
23
+ parts.push(toggleIndicator('changes', showOnlyChanges));
24
+ }
25
+ return parts.join(' ');
26
+ }
7
27
  /**
8
28
  * Format footer content as blessed-compatible tagged string.
9
29
  */
10
- export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, width) {
30
+ export function formatFooter(activeTab, mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, width, currentPane) {
11
31
  // Left side: indicators
12
32
  let leftContent = '{gray-fg}?{/gray-fg} ';
13
- leftContent += mouseEnabled
14
- ? '{yellow-fg}[scroll]{/yellow-fg}'
15
- : '{yellow-fg}m:[select]{/yellow-fg}';
16
- leftContent += ' ';
17
- leftContent += autoTabEnabled ? '{blue-fg}[auto]{/blue-fg}' : '{gray-fg}[auto]{/gray-fg}';
18
- leftContent += ' ';
19
- leftContent += wrapMode ? '{blue-fg}[wrap]{/blue-fg}' : '{gray-fg}[wrap]{/gray-fg}';
20
- leftContent += ' ';
21
- leftContent += followEnabled ? '{blue-fg}[follow]{/blue-fg}' : '{gray-fg}[follow]{/gray-fg}';
22
- if (activeTab === 'explorer') {
23
- leftContent += ' ';
24
- leftContent += showOnlyChanges
25
- ? '{blue-fg}[changes]{/blue-fg}'
26
- : '{gray-fg}[changes]{/gray-fg}';
33
+ leftContent += buildStandardIndicators(mouseEnabled, autoTabEnabled, wrapMode, followEnabled, showOnlyChanges, activeTab);
34
+ // Show hunk key hints when diff pane is focused on diff tab
35
+ if (activeTab === 'diff' && currentPane === 'diff') {
36
+ leftContent += ' {gray-fg}n/N:hunk s:toggle{/gray-fg}';
27
37
  }
28
38
  // Right side: tabs
29
39
  const tabs = [
@@ -22,6 +22,19 @@ function formatBranch(branch) {
22
22
  }
23
23
  return result;
24
24
  }
25
+ function computeBranchVisibleLength(branch) {
26
+ let len = branch.current.length;
27
+ if (branch.tracking) {
28
+ len += 3 + branch.tracking.length;
29
+ }
30
+ if (branch.ahead > 0) {
31
+ len += 3 + String(branch.ahead).length;
32
+ }
33
+ if (branch.behind > 0) {
34
+ len += 3 + String(branch.behind).length;
35
+ }
36
+ return len;
37
+ }
25
38
  /**
26
39
  * Format header content as blessed-compatible tagged string.
27
40
  */
@@ -55,12 +68,7 @@ export function formatHeader(repoPath, branch, isLoading, error, width) {
55
68
  else if (error) {
56
69
  leftLen += error.length + 3; // " (error)"
57
70
  }
58
- const rightLen = branch
59
- ? branch.current.length +
60
- (branch.tracking ? 3 + branch.tracking.length : 0) +
61
- (branch.ahead > 0 ? 3 + String(branch.ahead).length : 0) +
62
- (branch.behind > 0 ? 3 + String(branch.behind).length : 0)
63
- : 0;
71
+ const rightLen = branch ? computeBranchVisibleLength(branch) : 0;
64
72
  const padding = Math.max(1, width - leftLen - rightLen - 2);
65
73
  return leftContent + ' '.repeat(padding) + rightContent;
66
74
  }
@@ -0,0 +1,73 @@
1
+ import { shortenPath } from '../../utils/formatPath.js';
2
+ export function getStatusChar(status) {
3
+ switch (status) {
4
+ case 'modified':
5
+ return 'M';
6
+ case 'added':
7
+ return 'A';
8
+ case 'deleted':
9
+ return 'D';
10
+ case 'untracked':
11
+ return '?';
12
+ case 'renamed':
13
+ return 'R';
14
+ case 'copied':
15
+ return 'C';
16
+ default:
17
+ return ' ';
18
+ }
19
+ }
20
+ export function getStatusColor(status) {
21
+ switch (status) {
22
+ case 'modified':
23
+ return 'yellow';
24
+ case 'added':
25
+ return 'green';
26
+ case 'deleted':
27
+ return 'red';
28
+ case 'untracked':
29
+ return 'gray';
30
+ case 'renamed':
31
+ return 'blue';
32
+ case 'copied':
33
+ return 'cyan';
34
+ default:
35
+ return 'white';
36
+ }
37
+ }
38
+ export function formatStats(insertions, deletions) {
39
+ if (insertions === undefined && deletions === undefined)
40
+ return '';
41
+ const parts = [];
42
+ if (insertions !== undefined && insertions > 0) {
43
+ parts.push(`{green-fg}+${insertions}{/green-fg}`);
44
+ }
45
+ if (deletions !== undefined && deletions > 0) {
46
+ parts.push(`{red-fg}-${deletions}{/red-fg}`);
47
+ }
48
+ return parts.length > 0 ? ' ' + parts.join(' ') : '';
49
+ }
50
+ export function formatSelectionIndicator(isSelected, isFocused) {
51
+ if (isSelected && isFocused) {
52
+ return '{cyan-fg}{bold}\u25b8 {/bold}{/cyan-fg}';
53
+ }
54
+ else if (isSelected) {
55
+ return '{gray-fg}\u25b8 {/gray-fg}';
56
+ }
57
+ return ' ';
58
+ }
59
+ export function formatFilePath(path, isSelected, isFocused, maxLength) {
60
+ const displayPath = shortenPath(path, maxLength);
61
+ if (isSelected && isFocused) {
62
+ return `{cyan-fg}{inverse}${displayPath}{/inverse}{/cyan-fg}`;
63
+ }
64
+ else if (isSelected) {
65
+ return `{cyan-fg}${displayPath}{/cyan-fg}`;
66
+ }
67
+ return displayPath;
68
+ }
69
+ export function formatOriginalPath(originalPath) {
70
+ if (!originalPath)
71
+ return '';
72
+ return ` {gray-fg}\u2190 ${shortenPath(originalPath, 30)}{/gray-fg}`;
73
+ }
@@ -85,7 +85,6 @@ export function truncateAnsi(str, maxVisualLength, suffix = '…') {
85
85
  else {
86
86
  // Partial fit - truncate this segment
87
87
  result += segment.content.slice(0, remainingSpace);
88
- currentVisualLength += remainingSpace;
89
88
  truncated = true;
90
89
  break;
91
90
  }
@@ -4,7 +4,7 @@ import { formatDateAbsolute } from './formatDate.js';
4
4
  import { isDisplayableDiffLine } from './diffFilters.js';
5
5
  import { breakLine, getLineRowCount } from './lineBreaking.js';
6
6
  import { computeWordDiff, areSimilarEnough } from './wordDiff.js';
7
- import { getLanguageFromPath, highlightBlockPreserveBg, } from './languageDetection.js';
7
+ import { getLanguageFromPath, highlightBlockPreserveBg } from './languageDetection.js';
8
8
  /**
9
9
  * Get the text content from a diff line (strip leading +/-/space and control chars)
10
10
  */
@@ -73,8 +73,7 @@ export function buildDiffDisplayRows(diff) {
73
73
  const rows = [];
74
74
  const fileSections = [];
75
75
  let currentSection = null;
76
- // Phase 1: Build display rows WITHOUT highlighting
77
- // Also collect content for block highlighting
76
+ // Phase 1: Build display rows and collect content streams per file section
78
77
  let i = 0;
79
78
  while (i < filteredLines.length) {
80
79
  const line = filteredLines[i];
@@ -82,13 +81,10 @@ export function buildDiffDisplayRows(diff) {
82
81
  if (line.type === 'header') {
83
82
  const filePath = extractFilePathFromHeader(line.content);
84
83
  if (filePath) {
85
- // Save previous section if any
86
84
  if (currentSection) {
87
85
  fileSections.push(currentSection);
88
- // Add spacer between files for visual separation
89
86
  rows.push({ type: 'spacer' });
90
87
  }
91
- // Start new section
92
88
  currentSection = {
93
89
  language: getLanguageFromPath(filePath),
94
90
  startRowIndex: rows.length,
@@ -102,7 +98,6 @@ export function buildDiffDisplayRows(diff) {
102
98
  i++;
103
99
  continue;
104
100
  }
105
- // Hunks - just convert (don't highlight)
106
101
  if (line.type === 'hunk') {
107
102
  rows.push(convertDiffLineToDisplayRow(line));
108
103
  i++;
@@ -117,7 +112,6 @@ export function buildDiffDisplayRows(diff) {
117
112
  lineNum: line.oldLineNum ?? line.newLineNum,
118
113
  content,
119
114
  });
120
- // Context appears in both old and new streams
121
115
  if (currentSection && currentSection.language) {
122
116
  currentSection.oldContent.push(content);
123
117
  currentSection.oldRowIndices.push(rowIndex);
@@ -139,22 +133,19 @@ export function buildDiffDisplayRows(diff) {
139
133
  additions.push(filteredLines[i]);
140
134
  i++;
141
135
  }
142
- // Build arrays to store word diff segments for paired lines
136
+ // Pair deletions with additions for word-level diff
143
137
  const delSegmentsMap = new Map();
144
138
  const addSegmentsMap = new Map();
145
- // Pair deletions with additions for word-level diff (only if similar enough)
146
139
  const pairCount = Math.min(deletions.length, additions.length);
147
140
  for (let j = 0; j < pairCount; j++) {
148
141
  const delContent = getLineContent(deletions[j]);
149
142
  const addContent = getLineContent(additions[j]);
150
- // Only compute word diff if lines are similar enough
151
143
  if (areSimilarEnough(delContent, addContent)) {
152
144
  const { oldSegments, newSegments } = computeWordDiff(delContent, addContent);
153
145
  delSegmentsMap.set(j, oldSegments);
154
146
  addSegmentsMap.set(j, newSegments);
155
147
  }
156
148
  }
157
- // Output deletions first (preserving original diff order)
158
149
  for (let j = 0; j < deletions.length; j++) {
159
150
  const delLine = deletions[j];
160
151
  const delContent = getLineContent(delLine);
@@ -166,13 +157,11 @@ export function buildDiffDisplayRows(diff) {
166
157
  content: delContent,
167
158
  ...(segments && { wordDiffSegments: segments }),
168
159
  });
169
- // Add to old stream (only if no word-diff, as word-diff takes priority)
170
160
  if (currentSection && currentSection.language && !segments) {
171
161
  currentSection.oldContent.push(delContent);
172
162
  currentSection.oldRowIndices.push(rowIndex);
173
163
  }
174
164
  }
175
- // Then output additions
176
165
  for (let j = 0; j < additions.length; j++) {
177
166
  const addLine = additions[j];
178
167
  const addContent = getLineContent(addLine);
@@ -184,14 +173,12 @@ export function buildDiffDisplayRows(diff) {
184
173
  content: addContent,
185
174
  ...(segments && { wordDiffSegments: segments }),
186
175
  });
187
- // Add to new stream (only if no word-diff, as word-diff takes priority)
188
176
  if (currentSection && currentSection.language && !segments) {
189
177
  currentSection.newContent.push(addContent);
190
178
  currentSection.newRowIndices.push(rowIndex);
191
179
  }
192
180
  }
193
181
  }
194
- // Save final section
195
182
  if (currentSection) {
196
183
  fileSections.push(currentSection);
197
184
  }
@@ -199,14 +186,12 @@ export function buildDiffDisplayRows(diff) {
199
186
  for (const section of fileSections) {
200
187
  if (!section.language)
201
188
  continue;
202
- // Highlight old stream (context + deletions)
203
189
  if (section.oldContent.length > 0) {
204
190
  const oldHighlighted = highlightBlockPreserveBg(section.oldContent, section.language);
205
191
  for (let j = 0; j < section.oldRowIndices.length; j++) {
206
192
  const rowIndex = section.oldRowIndices[j];
207
193
  const row = rows[rowIndex];
208
194
  const highlighted = oldHighlighted[j];
209
- // Only set highlighted if it's different from content
210
195
  if (highlighted &&
211
196
  highlighted !== row.content &&
212
197
  (row.type === 'diff-del' || row.type === 'diff-context')) {
@@ -214,15 +199,12 @@ export function buildDiffDisplayRows(diff) {
214
199
  }
215
200
  }
216
201
  }
217
- // Highlight new stream (context + additions)
218
202
  if (section.newContent.length > 0) {
219
203
  const newHighlighted = highlightBlockPreserveBg(section.newContent, section.language);
220
204
  for (let j = 0; j < section.newRowIndices.length; j++) {
221
205
  const rowIndex = section.newRowIndices[j];
222
206
  const row = rows[rowIndex];
223
207
  const highlighted = newHighlighted[j];
224
- // Only set highlighted if it's different from content
225
- // Note: context lines appear in both streams, but result should be same
226
208
  if (highlighted &&
227
209
  highlighted !== row.content &&
228
210
  (row.type === 'diff-add' || row.type === 'diff-context')) {
@@ -323,6 +305,33 @@ export function wrapDisplayRows(rows, contentWidth, wrapEnabled) {
323
305
  }
324
306
  return result;
325
307
  }
308
+ /**
309
+ * Find hunk boundaries in a DisplayRow or WrappedDisplayRow array.
310
+ * Each hunk spans from a 'diff-hunk' row to the next 'diff-hunk', 'diff-header', 'spacer', or end.
311
+ */
312
+ export function getHunkBoundaries(rows) {
313
+ const boundaries = [];
314
+ let currentStart = -1;
315
+ for (let i = 0; i < rows.length; i++) {
316
+ const type = rows[i].type;
317
+ if (type === 'diff-hunk') {
318
+ if (currentStart !== -1) {
319
+ boundaries.push({ startRow: currentStart, endRow: i });
320
+ }
321
+ currentStart = i;
322
+ }
323
+ else if (type === 'diff-header' || type === 'spacer') {
324
+ if (currentStart !== -1) {
325
+ boundaries.push({ startRow: currentStart, endRow: i });
326
+ currentStart = -1;
327
+ }
328
+ }
329
+ }
330
+ if (currentStart !== -1) {
331
+ boundaries.push({ startRow: currentStart, endRow: rows.length });
332
+ }
333
+ return boundaries;
334
+ }
326
335
  /**
327
336
  * Calculate the total row count after wrapping.
328
337
  * More efficient than wrapDisplayRows().length when you only need the count.
@@ -349,3 +358,74 @@ export function getWrappedRowCount(rows, contentWidth, wrapEnabled) {
349
358
  }
350
359
  return count;
351
360
  }
361
+ /**
362
+ * Parse the index-referenced line number from a hunk header.
363
+ * Staged diffs (HEAD→index): use +new (new-side = index lines).
364
+ * Unstaged diffs (index→working tree): use -old (old-side = index lines).
365
+ */
366
+ function parseHunkSortKey(hunkContent, source) {
367
+ const m = hunkContent.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/);
368
+ if (!m)
369
+ return 0;
370
+ return source === 'staged' ? parseInt(m[2], 10) : parseInt(m[1], 10);
371
+ }
372
+ /**
373
+ * Extract individual hunks from a DiffResult's lines.
374
+ * Returns file-level header lines separately.
375
+ */
376
+ function extractHunks(diff, source) {
377
+ if (!diff || diff.lines.length === 0)
378
+ return { fileHeaders: [], hunks: [] };
379
+ const fileHeaders = [];
380
+ const hunks = [];
381
+ let currentHunk = null;
382
+ let hunkIdx = 0;
383
+ for (const line of diff.lines) {
384
+ if (line.type === 'header') {
385
+ if (currentHunk) {
386
+ hunks.push(currentHunk);
387
+ currentHunk = null;
388
+ }
389
+ fileHeaders.push(line);
390
+ }
391
+ else if (line.type === 'hunk') {
392
+ if (currentHunk)
393
+ hunks.push(currentHunk);
394
+ currentHunk = {
395
+ headerLine: line,
396
+ bodyLines: [],
397
+ sortKey: parseHunkSortKey(line.content, source),
398
+ source,
399
+ hunkIndex: hunkIdx++,
400
+ };
401
+ }
402
+ else if (currentHunk) {
403
+ currentHunk.bodyLines.push(line);
404
+ }
405
+ }
406
+ if (currentHunk)
407
+ hunks.push(currentHunk);
408
+ return { fileHeaders, hunks };
409
+ }
410
+ /**
411
+ * Build combined display rows from unstaged and staged diffs for the same file.
412
+ * Hunks are interleaved by file position (index line number) into a single
413
+ * unified view. Returns display rows and a mapping from combined hunk index
414
+ * to source (unstaged/staged) and original hunk index.
415
+ */
416
+ export function buildCombinedDiffDisplayRows(unstaged, staged) {
417
+ const u = extractHunks(unstaged, 'unstaged');
418
+ const s = extractHunks(staged, 'staged');
419
+ const allHunks = [...u.hunks, ...s.hunks];
420
+ allHunks.sort((a, b) => a.sortKey - b.sortKey);
421
+ // Build a merged DiffLine array: file headers from whichever has them, then sorted hunks
422
+ const fileHeaders = u.fileHeaders.length > 0 ? u.fileHeaders : s.fileHeaders;
423
+ const mergedLines = [...fileHeaders];
424
+ const hunkMapping = [];
425
+ for (const hunk of allHunks) {
426
+ mergedLines.push(hunk.headerLine, ...hunk.bodyLines);
427
+ hunkMapping.push({ source: hunk.source, hunkIndex: hunk.hunkIndex });
428
+ }
429
+ const rows = buildDiffDisplayRows({ raw: '', lines: mergedLines });
430
+ return { rows, hunkMapping };
431
+ }