diffstalker 0.2.0 → 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.
Files changed (46) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. package/dist/utils/rowCalculations.js +0 -246
@@ -1,7 +1,14 @@
1
1
  import { getTheme } from '../../themes.js';
2
- import { buildDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, } from '../../utils/displayRows.js';
3
- import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
2
+ import { buildDiffDisplayRows, buildCombinedDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, getHunkBoundaries, } from '../../utils/displayRows.js';
4
3
  import { truncateAnsi } from '../../utils/ansiTruncate.js';
4
+ // ANSI escape codes for raw terminal output (avoids blessed tag escaping issues)
5
+ const ANSI_RESET = '\x1b[0m';
6
+ const ANSI_BOLD = '\x1b[1m';
7
+ const ANSI_GRAY = '\x1b[90m';
8
+ const ANSI_CYAN = '\x1b[36m';
9
+ const ANSI_GREEN = '\x1b[32m';
10
+ const ANSI_YELLOW = '\x1b[33m';
11
+ const ANSI_INVERSE = '\x1b[7m';
5
12
  /**
6
13
  * Truncate string to fit within maxWidth, adding ellipsis if needed.
7
14
  */
@@ -44,191 +51,133 @@ function ansiFg(hex) {
44
51
  const b = parseInt(hex.slice(5, 7), 16);
45
52
  return `\x1b[38;2;${r};${g};${b}m`;
46
53
  }
47
- const ANSI_RESET = '\x1b[0m';
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
+ }
48
150
  /**
49
151
  * Format a single display row as blessed-compatible tagged string.
50
152
  */
51
153
  function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode) {
52
- const { colors } = theme;
53
154
  switch (row.type) {
54
- case 'diff-header': {
55
- const content = row.content;
56
- if (content.startsWith('diff --git')) {
57
- const match = content.match(/diff --git a\/.+ b\/(.+)$/);
58
- if (match) {
59
- const maxPathLen = headerWidth - 6;
60
- const path = truncate(match[1], maxPathLen);
61
- return `{bold}{cyan-fg}\u2500\u2500 ${path} \u2500\u2500{/cyan-fg}{/bold}`;
62
- }
63
- }
64
- return `{gray-fg}${escapeContent(truncate(content, headerWidth))}{/gray-fg}`;
65
- }
66
- case 'diff-hunk': {
67
- const match = row.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
68
- if (match) {
69
- const oldStart = parseInt(match[1], 10);
70
- const oldCount = match[2] ? parseInt(match[2], 10) : 1;
71
- const newStart = parseInt(match[3], 10);
72
- const newCount = match[4] ? parseInt(match[4], 10) : 1;
73
- const context = match[5].trim();
74
- const oldEnd = oldStart + oldCount - 1;
75
- const newEnd = newStart + newCount - 1;
76
- const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart}-${oldEnd}`;
77
- const newRange = newCount === 1 ? `${newStart}` : `${newStart}-${newEnd}`;
78
- const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
79
- const contextMaxLen = headerWidth - rangeText.length - 1;
80
- const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
81
- return `{cyan-fg}${rangeText}{/cyan-fg}{gray-fg}${truncatedContext}{/gray-fg}`;
82
- }
83
- return `{cyan-fg}${escapeContent(truncate(row.content, headerWidth))}{/cyan-fg}`;
84
- }
85
- case 'diff-add': {
86
- const isCont = row.isContinuation;
87
- const symbol = isCont ? '\u00bb' : '+';
88
- const lineNum = formatLineNum(row.lineNum, lineNumWidth);
89
- const prefix = `${lineNum} ${symbol} `;
90
- if (theme.name.includes('ansi')) {
91
- // Basic ANSI theme - use blessed tags with 16-color palette
92
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
93
- const visibleContent = `${prefix}${rawContent}`;
94
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
95
- return `{green-bg}{white-fg}${escapeContent(paddedContent)}{/white-fg}{/green-bg}`;
96
- }
97
- // Use 24-bit RGB via {escape} tag
98
- const bg = ansiBg(colors.addBg);
99
- const highlightBg = ansiBg(colors.addHighlight);
100
- const fg = ansiFg('#ffffff');
101
- // Check for word-level diff segments (only in non-wrap mode or first row)
102
- if (row.wordDiffSegments && !isCont) {
103
- const rawContent = row.content || '';
104
- // Check visible content length (not including escape codes)
105
- if (!wrapMode && rawContent.length > contentWidth) {
106
- // Content too long - fall back to simple truncation without word highlighting
107
- const truncated = truncate(rawContent, contentWidth);
108
- const visibleContent = `${prefix}${truncated}`;
109
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
110
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
111
- }
112
- // Build content with word-level highlighting
113
- let contentStr = '';
114
- for (const seg of row.wordDiffSegments) {
115
- if (seg.type === 'changed') {
116
- contentStr += `${highlightBg}${seg.text}${bg}`;
117
- }
118
- else {
119
- contentStr += seg.text;
120
- }
121
- }
122
- // Calculate padding based on visible width (prefix + rawContent)
123
- const visibleWidth = prefix.length + rawContent.length;
124
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
125
- return `{escape}${bg}${fg}${prefix}${contentStr}${padding}${ANSI_RESET}{/escape}`;
126
- }
127
- // Check for syntax highlighting (when no word-diff)
128
- if (row.highlighted && !isCont) {
129
- const rawContent = row.content || '';
130
- if (!wrapMode && rawContent.length > contentWidth) {
131
- // Too long - fall back to plain truncation
132
- const truncated = truncate(rawContent, contentWidth);
133
- const visibleContent = `${prefix}${truncated}`;
134
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
135
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
136
- }
137
- // Use highlighted content (already has foreground colors, bg preserved)
138
- const visibleWidth = prefix.length + rawContent.length;
139
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
140
- return `{escape}${bg}${fg}${prefix}${row.highlighted}${padding}${ANSI_RESET}{/escape}`;
141
- }
142
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
143
- const visibleContent = `${prefix}${rawContent}`;
144
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
145
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
146
- }
147
- case 'diff-del': {
148
- const isCont = row.isContinuation;
149
- const symbol = isCont ? '\u00bb' : '-';
150
- const lineNum = formatLineNum(row.lineNum, lineNumWidth);
151
- const prefix = `${lineNum} ${symbol} `;
152
- if (theme.name.includes('ansi')) {
153
- // Basic ANSI theme - use blessed tags with 16-color palette
154
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
155
- const visibleContent = `${prefix}${rawContent}`;
156
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
157
- return `{red-bg}{white-fg}${escapeContent(paddedContent)}{/white-fg}{/red-bg}`;
158
- }
159
- // Use 24-bit RGB via {escape} tag
160
- const bg = ansiBg(colors.delBg);
161
- const highlightBg = ansiBg(colors.delHighlight);
162
- const fg = ansiFg('#ffffff');
163
- // Check for word-level diff segments (only in non-wrap mode or first row)
164
- if (row.wordDiffSegments && !isCont) {
165
- const rawContent = row.content || '';
166
- // Check visible content length (not including escape codes)
167
- if (!wrapMode && rawContent.length > contentWidth) {
168
- // Content too long - fall back to simple truncation without word highlighting
169
- const truncated = truncate(rawContent, contentWidth);
170
- const visibleContent = `${prefix}${truncated}`;
171
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
172
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
173
- }
174
- // Build content with word-level highlighting
175
- let contentStr = '';
176
- for (const seg of row.wordDiffSegments) {
177
- if (seg.type === 'changed') {
178
- contentStr += `${highlightBg}${seg.text}${bg}`;
179
- }
180
- else {
181
- contentStr += seg.text;
182
- }
183
- }
184
- // Calculate padding based on visible width (prefix + rawContent)
185
- const visibleWidth = prefix.length + rawContent.length;
186
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
187
- return `{escape}${bg}${fg}${prefix}${contentStr}${padding}${ANSI_RESET}{/escape}`;
188
- }
189
- // Check for syntax highlighting (when no word-diff)
190
- if (row.highlighted && !isCont) {
191
- const rawContent = row.content || '';
192
- if (!wrapMode && rawContent.length > contentWidth) {
193
- // Too long - fall back to plain truncation
194
- const truncated = truncate(rawContent, contentWidth);
195
- const visibleContent = `${prefix}${truncated}`;
196
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
197
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
198
- }
199
- // Use highlighted content (already has foreground colors, bg preserved)
200
- const visibleWidth = prefix.length + rawContent.length;
201
- const padding = ' '.repeat(Math.max(0, headerWidth - visibleWidth));
202
- return `{escape}${bg}${fg}${prefix}${row.highlighted}${padding}${ANSI_RESET}{/escape}`;
203
- }
204
- const rawContent = wrapMode ? row.content || '' : truncate(row.content || '', contentWidth);
205
- const visibleContent = `${prefix}${rawContent}`;
206
- const paddedContent = visibleContent.padEnd(headerWidth, ' ');
207
- return `{escape}${bg}${fg}${paddedContent}${ANSI_RESET}{/escape}`;
208
- }
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');
209
163
  case 'diff-context': {
210
164
  const isCont = row.isContinuation;
211
165
  const symbol = isCont ? '\u00bb' : ' ';
212
166
  const lineNum = formatLineNum(row.lineNum, lineNumWidth);
213
167
  const prefix = `${lineNum} ${symbol} `;
214
168
  const rawContent = row.content || '';
215
- // Use syntax highlighting if available (not for continuations)
169
+ const prefixAnsi = `\x1b[90m${prefix}\x1b[0m`;
216
170
  if (row.highlighted && !isCont) {
217
- const truncatedHighlight = wrapMode
218
- ? row.highlighted
219
- : truncateAnsi(row.highlighted, contentWidth);
220
- const highlightedContent = ansiToBlessed(truncatedHighlight);
221
- return `{gray-fg}${prefix}{/gray-fg}${highlightedContent}`;
171
+ const content = wrapMode ? row.highlighted : truncateAnsi(row.highlighted, contentWidth);
172
+ return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
222
173
  }
223
- const content = wrapMode
224
- ? escapeContent(rawContent)
225
- : escapeContent(truncate(rawContent, contentWidth));
226
- return `{gray-fg}${prefix}{/gray-fg}${content}`;
174
+ const content = wrapMode ? rawContent : truncate(rawContent, contentWidth);
175
+ return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
227
176
  }
228
177
  case 'commit-header':
229
- return `{yellow-fg}${escapeContent(truncate(row.content, headerWidth))}{/yellow-fg}`;
178
+ return `{escape}${ANSI_YELLOW}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
230
179
  case 'commit-message':
231
- return escapeContent(truncate(row.content, headerWidth));
180
+ return `{escape}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
232
181
  case 'spacer':
233
182
  return '';
234
183
  }
@@ -237,24 +186,55 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
237
186
  * Format diff output as blessed-compatible tagged string.
238
187
  * Returns both the content and total row count for scroll calculations.
239
188
  */
240
- 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;
241
191
  const displayRows = buildDiffDisplayRows(diff);
242
192
  if (displayRows.length === 0) {
243
- 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
+ };
244
199
  }
245
200
  const theme = getTheme(themeName);
246
201
  const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
247
- 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;
248
205
  const headerWidth = width - 2;
249
206
  // Apply wrapping if enabled
250
207
  const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
251
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;
252
215
  // Apply scroll offset and max height
216
+ const startIdx = scrollOffset;
253
217
  const visibleRows = maxHeight
254
- ? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
255
- : wrappedRows.slice(scrollOffset);
256
- const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
257
- 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 };
258
238
  }
259
239
  /**
260
240
  * Format history diff (commit metadata + diff) as blessed-compatible tagged string.
@@ -263,7 +243,12 @@ export function formatDiff(diff, width, scrollOffset = 0, maxHeight, themeName =
263
243
  export function formatHistoryDiff(commit, diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
264
244
  const displayRows = buildHistoryDisplayRows(commit, diff);
265
245
  if (displayRows.length === 0) {
266
- 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
+ };
267
252
  }
268
253
  const theme = getTheme(themeName);
269
254
  const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
@@ -275,5 +260,68 @@ export function formatHistoryDiff(commit, diff, width, scrollOffset = 0, maxHeig
275
260
  ? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
276
261
  : wrappedRows.slice(scrollOffset);
277
262
  const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
278
- 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 };
279
327
  }
@@ -1,16 +1,13 @@
1
- import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, applyMiddleDots, } from '../../utils/explorerDisplayRows.js';
1
+ import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, } from '../../utils/explorerDisplayRows.js';
2
2
  import { truncateAnsi } from '../../utils/ansiTruncate.js';
3
- import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
4
- /**
5
- * Escape blessed tags in content.
6
- */
7
- function escapeContent(content) {
8
- return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
9
- }
3
+ const ANSI_RESET = '\x1b[0m';
4
+ const ANSI_GRAY = '\x1b[90m';
5
+ const ANSI_CYAN = '\x1b[36m';
6
+ const ANSI_YELLOW = '\x1b[33m';
10
7
  /**
11
8
  * Format explorer file content as blessed-compatible tagged string.
12
9
  */
13
- export function formatExplorerContent(filePath, content, width, scrollOffset = 0, maxHeight, truncated = false, wrapMode = false, showMiddleDots = false) {
10
+ export function formatExplorerContent(filePath, content, width, scrollOffset = 0, maxHeight, truncated = false, wrapMode = false) {
14
11
  if (!filePath) {
15
12
  return '{gray-fg}Select a file to view its contents{/gray-fg}';
16
13
  }
@@ -36,7 +33,8 @@ export function formatExplorerContent(filePath, content, width, scrollOffset = 0
36
33
  const lines = [];
37
34
  for (const row of visibleRows) {
38
35
  if (row.type === 'truncation') {
39
- lines.push(`{yellow-fg}${escapeContent(row.content)}{/yellow-fg}`);
36
+ // Use {escape} with raw ANSI for consistency
37
+ lines.push(`{escape}${ANSI_YELLOW}${row.content}${ANSI_RESET}{/escape}`);
40
38
  continue;
41
39
  }
42
40
  // Code row
@@ -52,38 +50,27 @@ export function formatExplorerContent(filePath, content, width, scrollOffset = 0
52
50
  // Determine what content to display
53
51
  const rawContent = row.content;
54
52
  const shouldTruncate = !wrapMode && rawContent.length > contentWidth;
55
- // Use highlighted content if available and not a continuation or middle-dots mode
56
- const canUseHighlighting = row.highlighted && !isContinuation && !showMiddleDots;
53
+ // Use highlighted content if available and not a continuation
54
+ const canUseHighlighting = row.highlighted && !isContinuation;
57
55
  let displayContent;
58
56
  if (canUseHighlighting && row.highlighted) {
59
57
  // Use ANSI-aware truncation to preserve syntax highlighting
60
- const truncatedHighlight = shouldTruncate
58
+ displayContent = shouldTruncate
61
59
  ? truncateAnsi(row.highlighted, contentWidth)
62
60
  : row.highlighted;
63
- // Convert ANSI to blessed tags
64
- displayContent = ansiToBlessed(truncatedHighlight);
65
61
  }
66
62
  else {
67
63
  // Plain content path
68
64
  let plainContent = rawContent;
69
- // Apply middle-dots to raw content
70
- if (showMiddleDots && !isContinuation) {
71
- plainContent = applyMiddleDots(plainContent, true);
72
- }
73
65
  // Simple truncation for plain content
74
66
  if (shouldTruncate) {
75
67
  plainContent = plainContent.slice(0, Math.max(0, contentWidth - 1)) + '...';
76
68
  }
77
- displayContent = escapeContent(plainContent);
78
- }
79
- // Format line with line number
80
- let line = '';
81
- if (isContinuation) {
82
- line = `{cyan-fg}${lineNumDisplay}{/cyan-fg} ${displayContent || ' '}`;
83
- }
84
- else {
85
- line = `{gray-fg}${lineNumDisplay}{/gray-fg} ${displayContent || ' '}`;
69
+ displayContent = plainContent;
86
70
  }
71
+ // Format line with line number using raw ANSI (avoids blessed escaping issues)
72
+ const lineNumColor = isContinuation ? ANSI_CYAN : ANSI_GRAY;
73
+ const line = `{escape}${lineNumColor}${lineNumDisplay}${ANSI_RESET} ${displayContent || ' '}{/escape}`;
87
74
  lines.push(line);
88
75
  }
89
76
  return lines.join('\n');