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,12 +1,14 @@
1
1
  import { getTheme } from '../../themes.js';
2
- import { buildDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, } from '../../utils/displayRows.js';
2
+ import { buildDiffDisplayRows, buildCombinedDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, getHunkBoundaries, } from '../../utils/displayRows.js';
3
3
  import { truncateAnsi } from '../../utils/ansiTruncate.js';
4
4
  // ANSI escape codes for raw terminal output (avoids blessed tag escaping issues)
5
5
  const ANSI_RESET = '\x1b[0m';
6
6
  const ANSI_BOLD = '\x1b[1m';
7
7
  const ANSI_GRAY = '\x1b[90m';
8
8
  const ANSI_CYAN = '\x1b[36m';
9
+ const ANSI_GREEN = '\x1b[32m';
9
10
  const ANSI_YELLOW = '\x1b[33m';
11
+ const ANSI_INVERSE = '\x1b[7m';
10
12
  /**
11
13
  * Truncate string to fit within maxWidth, adding ellipsis if needed.
12
14
  */
@@ -49,177 +51,122 @@ function ansiFg(hex) {
49
51
  const b = parseInt(hex.slice(5, 7), 16);
50
52
  return `\x1b[38;2;${r};${g};${b}m`;
51
53
  }
54
+ /**
55
+ * Format a diff file header row (e.g. "── path/to/file ──").
56
+ */
57
+ function formatDiffHeader(content, headerWidth) {
58
+ if (content.startsWith('diff --git')) {
59
+ const match = content.match(/diff --git a\/.+ b\/(.+)$/);
60
+ if (match) {
61
+ const maxPathLen = headerWidth - 6;
62
+ const path = truncate(match[1], maxPathLen);
63
+ return `{escape}${ANSI_BOLD}${ANSI_CYAN}\u2500\u2500 ${path} \u2500\u2500${ANSI_RESET}{/escape}`;
64
+ }
65
+ }
66
+ return `{escape}${ANSI_GRAY}${truncate(content, headerWidth)}${ANSI_RESET}{/escape}`;
67
+ }
68
+ /**
69
+ * Format a diff hunk header row (e.g. "Lines 10-20 → 15-25 functionName").
70
+ */
71
+ function formatDiffHunk(content, headerWidth) {
72
+ const match = content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
73
+ if (match) {
74
+ const oldStart = parseInt(match[1], 10);
75
+ const oldCount = match[2] ? parseInt(match[2], 10) : 1;
76
+ const newStart = parseInt(match[3], 10);
77
+ const newCount = match[4] ? parseInt(match[4], 10) : 1;
78
+ const context = match[5].trim();
79
+ const oldEnd = oldStart + oldCount - 1;
80
+ const newEnd = newStart + newCount - 1;
81
+ const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart}-${oldEnd}`;
82
+ const newRange = newCount === 1 ? `${newStart}` : `${newStart}-${newEnd}`;
83
+ const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
84
+ const contextMaxLen = headerWidth - rangeText.length - 1;
85
+ const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
86
+ return `{escape}${ANSI_CYAN}${rangeText}${ANSI_GRAY}${truncatedContext}${ANSI_RESET}{/escape}`;
87
+ }
88
+ return `{escape}${ANSI_CYAN}${truncate(content, headerWidth)}${ANSI_RESET}{/escape}`;
89
+ }
90
+ /**
91
+ * Truncate or keep content based on wrap mode. Returns the display text and its visible length.
92
+ */
93
+ function prepareContent(text, contentWidth, wrapMode) {
94
+ const display = wrapMode ? text : truncate(text, contentWidth);
95
+ return { display, visibleLength: display.length };
96
+ }
97
+ /**
98
+ * Pad a line to the target width with spaces, using the given ANSI background.
99
+ */
100
+ function padToWidth(prefix, content, visibleLength, targetWidth, bg, fg) {
101
+ const padding = ' '.repeat(Math.max(0, targetWidth - prefix.length - visibleLength));
102
+ return `{escape}${bg}${fg}${prefix}${content}${padding}${ANSI_RESET}{/escape}`;
103
+ }
104
+ /**
105
+ * Format a content line for the ANSI theme (blessed tags, no 24-bit color).
106
+ */
107
+ function formatAnsiContentLine(prefix, rawContent, contentWidth, headerWidth, wrapMode, lineType) {
108
+ const { display } = prepareContent(rawContent, contentWidth, wrapMode);
109
+ const paddedContent = `${prefix}${display}`.padEnd(headerWidth, ' ');
110
+ const bgTag = lineType === 'add' ? 'green' : 'red';
111
+ return `{${bgTag}-bg}{white-fg}${escapeContent(paddedContent)}{/white-fg}{/${bgTag}-bg}`;
112
+ }
113
+ /**
114
+ * Build word-diff content string with highlight segments.
115
+ */
116
+ function buildWordDiffContent(segments, highlightBg, bg) {
117
+ let result = '';
118
+ for (const seg of segments) {
119
+ result += seg.type === 'changed' ? `${highlightBg}${seg.text}${bg}` : seg.text;
120
+ }
121
+ return result;
122
+ }
123
+ /**
124
+ * Format an add or delete content line, parameterized by line type.
125
+ */
126
+ function formatDiffContentLine(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode, lineType) {
127
+ const typeSymbol = lineType === 'add' ? '+' : '-';
128
+ const symbol = row.isContinuation ? '\u00bb' : typeSymbol;
129
+ const lineNum = formatLineNum(row.lineNum, lineNumWidth);
130
+ const prefix = `${lineNum} ${symbol} `;
131
+ const rawContent = row.content || '';
132
+ if (theme.name.includes('ansi')) {
133
+ return formatAnsiContentLine(prefix, rawContent, contentWidth, headerWidth, wrapMode, lineType);
134
+ }
135
+ const { colors } = theme;
136
+ const bg = ansiBg(lineType === 'add' ? colors.addBg : colors.delBg);
137
+ const fg = ansiFg('#ffffff');
138
+ const { display, visibleLength } = prepareContent(rawContent, contentWidth, wrapMode);
139
+ const canUseRichContent = !row.isContinuation && (wrapMode || rawContent.length <= contentWidth);
140
+ if (row.wordDiffSegments && canUseRichContent) {
141
+ const highlightBg = ansiBg(lineType === 'add' ? colors.addHighlight : colors.delHighlight);
142
+ const contentStr = buildWordDiffContent(row.wordDiffSegments, highlightBg, bg);
143
+ return padToWidth(prefix, contentStr, rawContent.length, headerWidth, bg, fg);
144
+ }
145
+ if (row.highlighted && canUseRichContent) {
146
+ return padToWidth(prefix, row.highlighted, rawContent.length, headerWidth, bg, fg);
147
+ }
148
+ return padToWidth(prefix, display, visibleLength, headerWidth, bg, fg);
149
+ }
52
150
  /**
53
151
  * Format a single display row as blessed-compatible tagged string.
54
152
  */
55
153
  function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode) {
56
- const { colors } = theme;
57
154
  switch (row.type) {
58
- case 'diff-header': {
59
- const content = row.content;
60
- if (content.startsWith('diff --git')) {
61
- const match = content.match(/diff --git a\/.+ b\/(.+)$/);
62
- if (match) {
63
- const maxPathLen = headerWidth - 6;
64
- const path = truncate(match[1], maxPathLen);
65
- return `{escape}${ANSI_BOLD}${ANSI_CYAN}\u2500\u2500 ${path} \u2500\u2500${ANSI_RESET}{/escape}`;
66
- }
67
- }
68
- return `{escape}${ANSI_GRAY}${truncate(content, headerWidth)}${ANSI_RESET}{/escape}`;
69
- }
70
- case 'diff-hunk': {
71
- const match = row.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
72
- if (match) {
73
- const oldStart = parseInt(match[1], 10);
74
- const oldCount = match[2] ? parseInt(match[2], 10) : 1;
75
- const newStart = parseInt(match[3], 10);
76
- const newCount = match[4] ? parseInt(match[4], 10) : 1;
77
- const context = match[5].trim();
78
- const oldEnd = oldStart + oldCount - 1;
79
- const newEnd = newStart + newCount - 1;
80
- const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart}-${oldEnd}`;
81
- const newRange = newCount === 1 ? `${newStart}` : `${newStart}-${newEnd}`;
82
- const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
83
- const contextMaxLen = headerWidth - rangeText.length - 1;
84
- const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
85
- return `{escape}${ANSI_CYAN}${rangeText}${ANSI_GRAY}${truncatedContext}${ANSI_RESET}{/escape}`;
86
- }
87
- return `{escape}${ANSI_CYAN}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
88
- }
89
- case 'diff-add': {
90
- const isCont = row.isContinuation;
91
- const symbol = isCont ? '\u00bb' : '+';
92
- const lineNum = formatLineNum(row.lineNum, lineNumWidth);
93
- const prefix = `${lineNum} ${symbol} `;
94
- if (theme.name.includes('ansi')) {
95
- // Basic ANSI theme - use blessed tags with 16-color palette
96
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
97
- const visibleContent = `${prefix}${rawContent}`;
98
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
99
- return `{green-bg}{white-fg}${escapeContent(paddedContent)}{/white-fg}{/green-bg}`;
100
- }
101
- // Use 24-bit RGB via {escape} tag
102
- const bg = ansiBg(colors.addBg);
103
- const highlightBg = ansiBg(colors.addHighlight);
104
- const fg = ansiFg('#ffffff');
105
- // Check for word-level diff segments (only in non-wrap mode or first row)
106
- if (row.wordDiffSegments && !isCont) {
107
- const rawContent = row.content || '';
108
- // Check visible content length (not including escape codes)
109
- if (!wrapMode && rawContent.length > contentWidth) {
110
- // Content too long - fall back to simple truncation without word highlighting
111
- const truncated = truncate(rawContent, contentWidth);
112
- const visibleContent = `${prefix}${truncated}`;
113
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
114
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
115
- }
116
- // Build content with word-level highlighting
117
- let contentStr = '';
118
- for (const seg of row.wordDiffSegments) {
119
- if (seg.type === 'changed') {
120
- contentStr += `${highlightBg}${seg.text}${bg}`;
121
- }
122
- else {
123
- contentStr += seg.text;
124
- }
125
- }
126
- // Calculate padding based on visible width (prefix + rawContent)
127
- const visibleWidth = prefix.length + rawContent.length;
128
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
129
- return `{escape}${bg}${fg}${prefix}${contentStr}${padding}${ANSI_RESET}{/escape}`;
130
- }
131
- // Check for syntax highlighting (when no word-diff)
132
- if (row.highlighted && !isCont) {
133
- const rawContent = row.content || '';
134
- if (!wrapMode && rawContent.length > contentWidth) {
135
- // Too long - fall back to plain truncation
136
- const truncated = truncate(rawContent, contentWidth);
137
- const visibleContent = `${prefix}${truncated}`;
138
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
139
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
140
- }
141
- // Use highlighted content (already has foreground colors, bg preserved)
142
- const visibleWidth = prefix.length + rawContent.length;
143
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
144
- return `{escape}${bg}${fg}${prefix}${row.highlighted}${padding}${ANSI_RESET}{/escape}`;
145
- }
146
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
147
- const visibleContent = `${prefix}${rawContent}`;
148
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
149
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
150
- }
151
- case 'diff-del': {
152
- const isCont = row.isContinuation;
153
- const symbol = isCont ? '\u00bb' : '-';
154
- const lineNum = formatLineNum(row.lineNum, lineNumWidth);
155
- const prefix = `${lineNum} ${symbol} `;
156
- if (theme.name.includes('ansi')) {
157
- // Basic ANSI theme - use blessed tags with 16-color palette
158
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
159
- const visibleContent = `${prefix}${rawContent}`;
160
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
161
- return `{red-bg}{white-fg}${escapeContent(paddedContent)}{/white-fg}{/red-bg}`;
162
- }
163
- // Use 24-bit RGB via {escape} tag
164
- const bg = ansiBg(colors.delBg);
165
- const highlightBg = ansiBg(colors.delHighlight);
166
- const fg = ansiFg('#ffffff');
167
- // Check for word-level diff segments (only in non-wrap mode or first row)
168
- if (row.wordDiffSegments && !isCont) {
169
- const rawContent = row.content || '';
170
- // Check visible content length (not including escape codes)
171
- if (!wrapMode && rawContent.length > contentWidth) {
172
- // Content too long - fall back to simple truncation without word highlighting
173
- const truncated = truncate(rawContent, contentWidth);
174
- const visibleContent = `${prefix}${truncated}`;
175
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
176
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
177
- }
178
- // Build content with word-level highlighting
179
- let contentStr = '';
180
- for (const seg of row.wordDiffSegments) {
181
- if (seg.type === 'changed') {
182
- contentStr += `${highlightBg}${seg.text}${bg}`;
183
- }
184
- else {
185
- contentStr += seg.text;
186
- }
187
- }
188
- // Calculate padding based on visible width (prefix + rawContent)
189
- const visibleWidth = prefix.length + rawContent.length;
190
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
191
- return `{escape}${bg}${fg}${prefix}${contentStr}${padding}${ANSI_RESET}{/escape}`;
192
- }
193
- // Check for syntax highlighting (when no word-diff)
194
- if (row.highlighted && !isCont) {
195
- const rawContent = row.content || '';
196
- if (!wrapMode && rawContent.length > contentWidth) {
197
- // Too long - fall back to plain truncation
198
- const truncated = truncate(rawContent, contentWidth);
199
- const visibleContent = `${prefix}${truncated}`;
200
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
201
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
202
- }
203
- // Use highlighted content (already has foreground colors, bg preserved)
204
- const visibleWidth = prefix.length + rawContent.length;
205
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
206
- return `{escape}${bg}${fg}${prefix}${row.highlighted}${padding}${ANSI_RESET}{/escape}`;
207
- }
208
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
209
- const visibleContent = `${prefix}${rawContent}`;
210
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
211
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
212
- }
155
+ case 'diff-header':
156
+ return formatDiffHeader(row.content, headerWidth);
157
+ case 'diff-hunk':
158
+ return formatDiffHunk(row.content, headerWidth);
159
+ case 'diff-add':
160
+ return formatDiffContentLine(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode, 'add');
161
+ case 'diff-del':
162
+ return formatDiffContentLine(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode, 'del');
213
163
  case 'diff-context': {
214
164
  const isCont = row.isContinuation;
215
165
  const symbol = isCont ? '\u00bb' : ' ';
216
166
  const lineNum = formatLineNum(row.lineNum, lineNumWidth);
217
167
  const prefix = `${lineNum} ${symbol} `;
218
168
  const rawContent = row.content || '';
219
- // Use {escape} for raw ANSI output (consistent with add/del lines)
220
- // This avoids blessed's tag escaping issues with braces
221
- const prefixAnsi = `\x1b[90m${prefix}\x1b[0m`; // gray prefix
222
- // Use syntax highlighting if available (not for continuations)
169
+ const prefixAnsi = `\x1b[90m${prefix}\x1b[0m`;
223
170
  if (row.highlighted && !isCont) {
224
171
  const content = wrapMode ? row.highlighted : truncateAnsi(row.highlighted, contentWidth);
225
172
  return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
@@ -239,24 +186,55 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
239
186
  * Format diff output as blessed-compatible tagged string.
240
187
  * Returns both the content and total row count for scroll calculations.
241
188
  */
242
- export function formatDiff(diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
189
+ export function formatDiff(diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false, selectedHunkIndex, isFileStaged) {
190
+ const hunkActive = selectedHunkIndex !== undefined && selectedHunkIndex >= 0;
243
191
  const displayRows = buildDiffDisplayRows(diff);
244
192
  if (displayRows.length === 0) {
245
- return { content: '{gray-fg}No diff to display{/gray-fg}', totalRows: 0 };
193
+ return {
194
+ content: '{gray-fg}No diff to display{/gray-fg}',
195
+ totalRows: 0,
196
+ hunkCount: 0,
197
+ hunkBoundaries: [],
198
+ };
246
199
  }
247
200
  const theme = getTheme(themeName);
248
201
  const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
249
- const contentWidth = width - lineNumWidth - 5; // line num + space + symbol + space + padding
202
+ // Reserve 1 column for hunk gutter when active
203
+ const gutterWidth = hunkActive ? 1 : 0;
204
+ const contentWidth = width - lineNumWidth - 5 - gutterWidth;
250
205
  const headerWidth = width - 2;
251
206
  // Apply wrapping if enabled
252
207
  const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
253
208
  const totalRows = wrappedRows.length;
209
+ // Compute hunk boundaries on wrapped rows
210
+ const hunkBoundaries = getHunkBoundaries(wrappedRows);
211
+ const hunkCount = hunkBoundaries.length;
212
+ // Determine which rows belong to the selected hunk
213
+ const clampedHunkIndex = Math.min(selectedHunkIndex ?? 0, hunkCount - 1);
214
+ const selectedBoundary = hunkActive && hunkCount > 0 ? hunkBoundaries[clampedHunkIndex] : null;
254
215
  // Apply scroll offset and max height
216
+ const startIdx = scrollOffset;
255
217
  const visibleRows = maxHeight
256
- ? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
257
- : wrappedRows.slice(scrollOffset);
258
- const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
259
- return { content: lines.join('\n'), totalRows };
218
+ ? wrappedRows.slice(startIdx, startIdx + maxHeight)
219
+ : wrappedRows.slice(startIdx);
220
+ const lines = visibleRows.map((row, i) => {
221
+ const absoluteIdx = startIdx + i;
222
+ const formatted = formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode);
223
+ if (!hunkActive)
224
+ return formatted;
225
+ // Determine gutter character — green for staged, cyan for unstaged
226
+ const inSelectedHunk = selectedBoundary &&
227
+ absoluteIdx >= selectedBoundary.startRow &&
228
+ absoluteIdx < selectedBoundary.endRow;
229
+ const gutterColor = isFileStaged ? ANSI_GREEN : ANSI_CYAN;
230
+ const gutter = inSelectedHunk ? `{escape}${gutterColor}\u258c${ANSI_RESET}{/escape}` : ' ';
231
+ // Highlight the @@ header of the selected hunk with inverse video
232
+ if (inSelectedHunk && row.type === 'diff-hunk') {
233
+ return gutter + formatted.replace('{escape}', `{escape}${ANSI_INVERSE}`);
234
+ }
235
+ return gutter + formatted;
236
+ });
237
+ return { content: lines.join('\n'), totalRows, hunkCount, hunkBoundaries };
260
238
  }
261
239
  /**
262
240
  * Format history diff (commit metadata + diff) as blessed-compatible tagged string.
@@ -265,7 +243,12 @@ export function formatDiff(diff, width, scrollOffset = 0, maxHeight, themeName =
265
243
  export function formatHistoryDiff(commit, diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
266
244
  const displayRows = buildHistoryDisplayRows(commit, diff);
267
245
  if (displayRows.length === 0) {
268
- return { content: '{gray-fg}No commit selected{/gray-fg}', totalRows: 0 };
246
+ return {
247
+ content: '{gray-fg}No commit selected{/gray-fg}',
248
+ totalRows: 0,
249
+ hunkCount: 0,
250
+ hunkBoundaries: [],
251
+ };
269
252
  }
270
253
  const theme = getTheme(themeName);
271
254
  const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
@@ -277,5 +260,68 @@ export function formatHistoryDiff(commit, diff, width, scrollOffset = 0, maxHeig
277
260
  ? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
278
261
  : wrappedRows.slice(scrollOffset);
279
262
  const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
280
- return { content: lines.join('\n'), totalRows };
263
+ return { content: lines.join('\n'), totalRows, hunkCount: 0, hunkBoundaries: [] };
264
+ }
265
+ /**
266
+ * Format combined (unstaged + staged) diff for a single file.
267
+ * Hunks are interleaved by file position. Each hunk shows a gutter
268
+ * indicator for its staging state (cyan=unstaged, green=staged).
269
+ */
270
+ export function formatCombinedDiff(unstaged, staged, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false, selectedHunkIndex) {
271
+ const { rows: displayRows, hunkMapping } = buildCombinedDiffDisplayRows(unstaged, staged);
272
+ if (displayRows.length === 0) {
273
+ return {
274
+ content: '{gray-fg}No diff to display{/gray-fg}',
275
+ totalRows: 0,
276
+ hunkCount: 0,
277
+ hunkBoundaries: [],
278
+ hunkMapping: [],
279
+ };
280
+ }
281
+ const hunkActive = selectedHunkIndex !== undefined && selectedHunkIndex >= 0;
282
+ const theme = getTheme(themeName);
283
+ const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
284
+ // Always reserve gutter space in combined diff when hunks are focused
285
+ const gutterWidth = hunkActive ? 1 : 0;
286
+ const contentWidth = width - lineNumWidth - 5 - gutterWidth;
287
+ const headerWidth = width - 2;
288
+ const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
289
+ const totalRows = wrappedRows.length;
290
+ const hunkBoundaries = getHunkBoundaries(wrappedRows);
291
+ const hunkCount = hunkBoundaries.length;
292
+ const clampedHunkIndex = Math.min(selectedHunkIndex ?? 0, hunkCount - 1);
293
+ // Build a row→hunkIndex lookup for per-hunk gutter coloring
294
+ const rowToHunkIndex = new Map();
295
+ if (hunkActive) {
296
+ for (let hi = 0; hi < hunkBoundaries.length; hi++) {
297
+ const b = hunkBoundaries[hi];
298
+ for (let r = b.startRow; r < b.endRow; r++) {
299
+ rowToHunkIndex.set(r, hi);
300
+ }
301
+ }
302
+ }
303
+ const startIdx = scrollOffset;
304
+ const visibleRows = maxHeight
305
+ ? wrappedRows.slice(startIdx, startIdx + maxHeight)
306
+ : wrappedRows.slice(startIdx);
307
+ const lines = visibleRows.map((row, i) => {
308
+ const absoluteIdx = startIdx + i;
309
+ const formatted = formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode);
310
+ if (!hunkActive)
311
+ return formatted;
312
+ const hi = rowToHunkIndex.get(absoluteIdx);
313
+ if (hi === undefined)
314
+ return ' ' + formatted;
315
+ const isSelected = hi === clampedHunkIndex;
316
+ const isStaged = hunkMapping[hi]?.source === 'staged';
317
+ const color = isStaged ? ANSI_GREEN : ANSI_CYAN;
318
+ const bold = isSelected ? ANSI_BOLD : '';
319
+ const gutter = `{escape}${bold}${color}\u258c${ANSI_RESET}{/escape}`;
320
+ // Highlight the @@ header of the selected hunk with inverse video
321
+ if (isSelected && row.type === 'diff-hunk') {
322
+ return gutter + formatted.replace('{escape}', `{escape}${ANSI_INVERSE}`);
323
+ }
324
+ return gutter + formatted;
325
+ });
326
+ return { content: lines.join('\n'), totalRows, hunkCount, hunkBoundaries, hunkMapping };
281
327
  }
@@ -80,6 +80,55 @@ function getStatusColor(status) {
80
80
  return ANSI_RESET;
81
81
  }
82
82
  }
83
+ /**
84
+ * Format a single explorer row as a raw ANSI string.
85
+ */
86
+ function formatExplorerRow(row, isSelected, isFocused, width) {
87
+ const isHighlighted = isSelected && isFocused;
88
+ const node = row.node;
89
+ const prefix = buildTreePrefix(row);
90
+ let icon = '';
91
+ if (node.isDirectory) {
92
+ icon = node.expanded ? '▾ ' : '▸ ';
93
+ }
94
+ const statusMarker = getStatusMarker(node.gitStatus);
95
+ const statusColor = getStatusColor(node.gitStatus);
96
+ const statusDisplay = statusMarker ? `${statusColor}${statusMarker}${ANSI_RESET} ` : '';
97
+ const dirStatusDisplay = node.isDirectory && node.hasChangedChildren ? `${ANSI_YELLOW}●${ANSI_RESET} ` : '';
98
+ const prefixLen = prefix.length +
99
+ icon.length +
100
+ (statusMarker ? 2 : 0) +
101
+ (node.hasChangedChildren && node.isDirectory ? 2 : 0);
102
+ const maxNameLen = Math.max(5, width - prefixLen - 2);
103
+ let displayName = node.isDirectory ? `${node.name}/` : node.name;
104
+ if (displayName.length > maxNameLen) {
105
+ displayName = displayName.slice(0, maxNameLen - 1) + '…';
106
+ }
107
+ let line = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
108
+ if (node.isDirectory) {
109
+ line += `${ANSI_BLUE}${icon}${ANSI_RESET}`;
110
+ line += dirStatusDisplay;
111
+ if (isHighlighted) {
112
+ line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
113
+ }
114
+ else {
115
+ line += `${ANSI_BLUE}${displayName}${ANSI_RESET}`;
116
+ }
117
+ }
118
+ else {
119
+ line += statusDisplay;
120
+ if (isHighlighted) {
121
+ line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
122
+ }
123
+ else if (node.gitStatus) {
124
+ line += `${statusColor}${displayName}${ANSI_RESET}`;
125
+ }
126
+ else {
127
+ line += displayName;
128
+ }
129
+ }
130
+ return line;
131
+ }
83
132
  /**
84
133
  * Format the explorer tree view as blessed-compatible tagged string.
85
134
  */
@@ -93,66 +142,13 @@ export function formatExplorerView(displayRows, selectedIndex, isFocused, width,
93
142
  if (displayRows.length === 0) {
94
143
  return '{gray-fg}(empty directory){/gray-fg}';
95
144
  }
96
- // Apply scroll offset and max height
97
145
  const visibleRows = maxHeight
98
146
  ? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
99
147
  : displayRows.slice(scrollOffset);
100
148
  const lines = [];
101
149
  for (let i = 0; i < visibleRows.length; i++) {
102
- const row = visibleRows[i];
103
150
  const actualIndex = scrollOffset + i;
104
- const isSelected = actualIndex === selectedIndex;
105
- const isHighlighted = isSelected && isFocused;
106
- const node = row.node;
107
- // Build tree prefix
108
- const prefix = buildTreePrefix(row);
109
- // Directory icon (▸ collapsed, ▾ expanded)
110
- let icon = '';
111
- if (node.isDirectory) {
112
- icon = node.expanded ? '▾ ' : '▸ ';
113
- }
114
- // Git status indicator
115
- const statusMarker = getStatusMarker(node.gitStatus);
116
- const statusColor = getStatusColor(node.gitStatus);
117
- const statusDisplay = statusMarker ? `${statusColor}${statusMarker}${ANSI_RESET} ` : '';
118
- // Directory status indicator (dot if has changed children)
119
- const dirStatusDisplay = node.isDirectory && node.hasChangedChildren ? `${ANSI_YELLOW}●${ANSI_RESET} ` : '';
120
- // Calculate available width for name
121
- const prefixLen = prefix.length +
122
- icon.length +
123
- (statusMarker ? 2 : 0) +
124
- (node.hasChangedChildren && node.isDirectory ? 2 : 0);
125
- const maxNameLen = Math.max(5, width - prefixLen - 2);
126
- // Display name (with trailing / for directories)
127
- let displayName = node.isDirectory ? `${node.name}/` : node.name;
128
- if (displayName.length > maxNameLen) {
129
- displayName = displayName.slice(0, maxNameLen - 1) + '…';
130
- }
131
- // Build the line
132
- let line = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
133
- if (node.isDirectory) {
134
- line += `${ANSI_BLUE}${icon}${ANSI_RESET}`;
135
- line += dirStatusDisplay;
136
- if (isHighlighted) {
137
- line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
138
- }
139
- else {
140
- line += `${ANSI_BLUE}${displayName}${ANSI_RESET}`;
141
- }
142
- }
143
- else {
144
- // File
145
- line += statusDisplay;
146
- if (isHighlighted) {
147
- line += `${ANSI_CYAN}${ANSI_BOLD}${ANSI_INVERSE}${displayName}${ANSI_RESET}`;
148
- }
149
- else if (node.gitStatus) {
150
- line += `${statusColor}${displayName}${ANSI_RESET}`;
151
- }
152
- else {
153
- line += displayName;
154
- }
155
- }
151
+ const line = formatExplorerRow(visibleRows[i], actualIndex === selectedIndex, isFocused, width);
156
152
  lines.push(`{escape}${line}{/escape}`);
157
153
  }
158
154
  return lines.join('\n');