diffstalker 0.1.7 → 0.2.1

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 (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
@@ -0,0 +1,238 @@
1
+ import { formatDate } from '../../utils/formatDate.js';
2
+ import { formatCommitDisplay } from '../../utils/commitFormat.js';
3
+ import { buildFileTree, flattenTree, buildTreePrefix } from '../../utils/fileTree.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_YELLOW = '\x1b[33m';
10
+ const ANSI_GREEN = '\x1b[32m';
11
+ const ANSI_RED = '\x1b[31m';
12
+ const ANSI_BLUE = '\x1b[34m';
13
+ const ANSI_MAGENTA = '\x1b[35m';
14
+ const ANSI_INVERSE = '\x1b[7m';
15
+ /**
16
+ * Build the list of row items for the compare list view.
17
+ */
18
+ export function buildCompareListRows(commits, files, commitsExpanded = true, filesExpanded = true) {
19
+ const result = [];
20
+ // Commits section
21
+ if (commits.length > 0) {
22
+ result.push({ type: 'section-header', sectionType: 'commits' });
23
+ if (commitsExpanded) {
24
+ commits.forEach((commit, i) => {
25
+ result.push({ type: 'commit', commitIndex: i, commit });
26
+ });
27
+ }
28
+ }
29
+ // Files section with tree view
30
+ if (files.length > 0) {
31
+ if (commits.length > 0) {
32
+ result.push({ type: 'spacer' });
33
+ }
34
+ result.push({ type: 'section-header', sectionType: 'files' });
35
+ if (filesExpanded) {
36
+ // Build tree from files
37
+ const tree = buildFileTree(files);
38
+ const treeRows = flattenTree(tree);
39
+ for (const treeRow of treeRows) {
40
+ if (treeRow.type === 'directory') {
41
+ result.push({ type: 'directory', treeRow });
42
+ }
43
+ else {
44
+ const file = files[treeRow.fileIndex];
45
+ result.push({ type: 'file', fileIndex: treeRow.fileIndex, file, treeRow });
46
+ }
47
+ }
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+ /**
53
+ * Format a commit row.
54
+ */
55
+ function formatCommitRow(commit, isSelected, isFocused, width) {
56
+ const isHighlighted = isSelected && isFocused;
57
+ const dateStr = formatDate(commit.date);
58
+ // Fixed parts: indent(2) + hash(7) + spaces(4) + date + parens(2)
59
+ const baseWidth = 2 + 7 + 4 + dateStr.length + 2;
60
+ const remainingWidth = Math.max(10, width - baseWidth);
61
+ const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
62
+ let line = ` ${ANSI_YELLOW}${commit.shortHash}${ANSI_RESET} `;
63
+ if (isHighlighted) {
64
+ line += `${ANSI_CYAN}${ANSI_INVERSE}${displayMessage}${ANSI_RESET}`;
65
+ }
66
+ else {
67
+ line += displayMessage;
68
+ }
69
+ line += ` ${ANSI_GRAY}(${dateStr})${ANSI_RESET}`;
70
+ if (displayRefs) {
71
+ line += ` ${ANSI_GREEN}${displayRefs}${ANSI_RESET}`;
72
+ }
73
+ return `{escape}${line}{/escape}`;
74
+ }
75
+ /**
76
+ * Format a directory row in tree view.
77
+ */
78
+ function formatDirectoryRow(treeRow, width) {
79
+ const prefix = buildTreePrefix(treeRow);
80
+ const icon = '▸ '; // Collapsed folder icon (we don't support expanding individual folders yet)
81
+ // Truncate name if needed
82
+ const maxNameLen = width - prefix.length - icon.length - 2;
83
+ let name = treeRow.name;
84
+ if (name.length > maxNameLen) {
85
+ name = name.slice(0, maxNameLen - 1) + '…';
86
+ }
87
+ const line = `${ANSI_GRAY}${prefix}${ANSI_RESET}${ANSI_BLUE}${icon}${name}${ANSI_RESET}`;
88
+ return `{escape}${line}{/escape}`;
89
+ }
90
+ /**
91
+ * Format a file row in tree view.
92
+ */
93
+ function formatFileRow(file, treeRow, isSelected, isFocused, width) {
94
+ const isHighlighted = isSelected && isFocused;
95
+ const isUncommitted = file.isUncommitted ?? false;
96
+ const prefix = buildTreePrefix(treeRow);
97
+ const statusColors = {
98
+ added: ANSI_GREEN,
99
+ modified: ANSI_YELLOW,
100
+ deleted: ANSI_RED,
101
+ renamed: ANSI_BLUE,
102
+ };
103
+ // File icon based on status
104
+ const statusIcons = {
105
+ added: '+',
106
+ modified: '●',
107
+ deleted: '−',
108
+ renamed: '→',
109
+ };
110
+ const statusColor = isUncommitted ? ANSI_MAGENTA : statusColors[file.status];
111
+ const icon = statusIcons[file.status];
112
+ // Calculate available width for filename
113
+ const statsStr = `(+${file.additions} -${file.deletions})`;
114
+ const uncommittedStr = isUncommitted ? ' [uncommitted]' : '';
115
+ const fixedWidth = prefix.length + 2 + statsStr.length + uncommittedStr.length + 2;
116
+ const maxNameLen = Math.max(5, width - fixedWidth);
117
+ let name = treeRow.name;
118
+ if (name.length > maxNameLen) {
119
+ name = name.slice(0, maxNameLen - 1) + '…';
120
+ }
121
+ let line = `${ANSI_GRAY}${prefix}${ANSI_RESET}`;
122
+ line += `${statusColor}${icon}${ANSI_RESET} `;
123
+ if (isHighlighted) {
124
+ line += `${ANSI_CYAN}${ANSI_INVERSE}${name}${ANSI_RESET}`;
125
+ }
126
+ else if (isUncommitted) {
127
+ line += `${ANSI_MAGENTA}${name}${ANSI_RESET}`;
128
+ }
129
+ else {
130
+ line += name;
131
+ }
132
+ line += ` ${ANSI_GRAY}(${ANSI_GREEN}+${file.additions}${ANSI_RESET} ${ANSI_RED}-${file.deletions}${ANSI_GRAY})${ANSI_RESET}`;
133
+ if (isUncommitted) {
134
+ line += ` ${ANSI_MAGENTA}[uncommitted]${ANSI_RESET}`;
135
+ }
136
+ return `{escape}${line}{/escape}`;
137
+ }
138
+ /**
139
+ * Format the compare list view as blessed-compatible tagged string.
140
+ */
141
+ export function formatCompareListView(commits, files, selectedItem, isFocused, width, scrollOffset = 0, maxHeight) {
142
+ if (commits.length === 0 && files.length === 0) {
143
+ return '{gray-fg}No changes compared to base branch{/gray-fg}';
144
+ }
145
+ const rows = buildCompareListRows(commits, files);
146
+ // Apply scroll offset and max height
147
+ const visibleRows = maxHeight
148
+ ? rows.slice(scrollOffset, scrollOffset + maxHeight)
149
+ : rows.slice(scrollOffset);
150
+ const lines = [];
151
+ for (const row of visibleRows) {
152
+ if (row.type === 'section-header') {
153
+ const isCommits = row.sectionType === 'commits';
154
+ const count = isCommits ? commits.length : files.length;
155
+ const label = isCommits ? 'Commits' : 'Files';
156
+ lines.push(`{escape}${ANSI_CYAN}${ANSI_BOLD}▼ ${label}${ANSI_RESET} ${ANSI_GRAY}(${count})${ANSI_RESET}{/escape}`);
157
+ }
158
+ else if (row.type === 'spacer') {
159
+ lines.push('');
160
+ }
161
+ else if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
162
+ const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
163
+ lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
164
+ }
165
+ else if (row.type === 'directory' && row.treeRow) {
166
+ lines.push(formatDirectoryRow(row.treeRow, width));
167
+ }
168
+ else if (row.type === 'file' && row.file && row.fileIndex !== undefined && row.treeRow) {
169
+ const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
170
+ lines.push(formatFileRow(row.file, row.treeRow, isSelected, isFocused, width));
171
+ }
172
+ }
173
+ return lines.join('\n');
174
+ }
175
+ /**
176
+ * Get the total number of rows in the compare list view (for scroll calculation).
177
+ */
178
+ export function getCompareListTotalRows(commits, files, commitsExpanded = true, filesExpanded = true) {
179
+ return buildCompareListRows(commits, files, commitsExpanded, filesExpanded).length;
180
+ }
181
+ /**
182
+ * Map a row index to a selection.
183
+ * Returns null if the row is a header, spacer, or directory.
184
+ */
185
+ export function getCompareSelectionFromRow(rowIndex, commits, files, commitsExpanded = true, filesExpanded = true) {
186
+ const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
187
+ const row = rows[rowIndex];
188
+ if (!row)
189
+ return null;
190
+ if (row.type === 'commit' && row.commitIndex !== undefined) {
191
+ return { type: 'commit', index: row.commitIndex };
192
+ }
193
+ if (row.type === 'file' && row.fileIndex !== undefined) {
194
+ return { type: 'file', index: row.fileIndex };
195
+ }
196
+ return null;
197
+ }
198
+ /**
199
+ * Find the row index for a given selection.
200
+ */
201
+ export function getRowFromCompareSelection(selection, commits, files, commitsExpanded = true, filesExpanded = true) {
202
+ const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
203
+ for (let i = 0; i < rows.length; i++) {
204
+ const row = rows[i];
205
+ if (selection.type === 'commit' &&
206
+ row.type === 'commit' &&
207
+ row.commitIndex === selection.index) {
208
+ return i;
209
+ }
210
+ if (selection.type === 'file' && row.type === 'file' && row.fileIndex === selection.index) {
211
+ return i;
212
+ }
213
+ }
214
+ return 0;
215
+ }
216
+ /**
217
+ * Navigate to next selectable item.
218
+ */
219
+ export function getNextCompareSelection(current, commits, files, direction) {
220
+ const rows = buildCompareListRows(commits, files);
221
+ // Find current row index
222
+ let currentRowIndex = 0;
223
+ if (current) {
224
+ currentRowIndex = getRowFromCompareSelection(current, commits, files);
225
+ }
226
+ // Find next selectable row
227
+ const delta = direction === 'down' ? 1 : -1;
228
+ let nextRowIndex = currentRowIndex + delta;
229
+ while (nextRowIndex >= 0 && nextRowIndex < rows.length) {
230
+ const selection = getCompareSelectionFromRow(nextRowIndex, commits, files);
231
+ if (selection) {
232
+ return selection;
233
+ }
234
+ nextRowIndex += delta;
235
+ }
236
+ // Stay at current if no valid next selection
237
+ return current;
238
+ }
@@ -0,0 +1,281 @@
1
+ import { getTheme } from '../../themes.js';
2
+ import { buildDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, } from '../../utils/displayRows.js';
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_YELLOW = '\x1b[33m';
10
+ /**
11
+ * Truncate string to fit within maxWidth, adding ellipsis if needed.
12
+ */
13
+ function truncate(str, maxWidth) {
14
+ if (maxWidth <= 0 || str.length <= maxWidth)
15
+ return str;
16
+ if (maxWidth <= 1)
17
+ return '\u2026';
18
+ return str.slice(0, maxWidth - 1) + '\u2026';
19
+ }
20
+ /**
21
+ * Format line number with padding.
22
+ */
23
+ function formatLineNum(lineNum, width) {
24
+ if (lineNum === undefined)
25
+ return ' '.repeat(width);
26
+ return String(lineNum).padStart(width, ' ');
27
+ }
28
+ /**
29
+ * Escape blessed tags in content.
30
+ */
31
+ function escapeContent(content) {
32
+ return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
33
+ }
34
+ /**
35
+ * Build raw ANSI escape sequence for 24-bit RGB background.
36
+ */
37
+ function ansiBg(hex) {
38
+ const r = parseInt(hex.slice(1, 3), 16);
39
+ const g = parseInt(hex.slice(3, 5), 16);
40
+ const b = parseInt(hex.slice(5, 7), 16);
41
+ return `\x1b[48;2;${r};${g};${b}m`;
42
+ }
43
+ /**
44
+ * Build raw ANSI escape sequence for 24-bit RGB foreground.
45
+ */
46
+ function ansiFg(hex) {
47
+ const r = parseInt(hex.slice(1, 3), 16);
48
+ const g = parseInt(hex.slice(3, 5), 16);
49
+ const b = parseInt(hex.slice(5, 7), 16);
50
+ return `\x1b[38;2;${r};${g};${b}m`;
51
+ }
52
+ /**
53
+ * Format a single display row as blessed-compatible tagged string.
54
+ */
55
+ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode) {
56
+ const { colors } = theme;
57
+ 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
+ }
213
+ case 'diff-context': {
214
+ const isCont = row.isContinuation;
215
+ const symbol = isCont ? '\u00bb' : ' ';
216
+ const lineNum = formatLineNum(row.lineNum, lineNumWidth);
217
+ const prefix = `${lineNum} ${symbol} `;
218
+ 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)
223
+ if (row.highlighted && !isCont) {
224
+ const content = wrapMode ? row.highlighted : truncateAnsi(row.highlighted, contentWidth);
225
+ return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
226
+ }
227
+ const content = wrapMode ? rawContent : truncate(rawContent, contentWidth);
228
+ return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
229
+ }
230
+ case 'commit-header':
231
+ return `{escape}${ANSI_YELLOW}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
232
+ case 'commit-message':
233
+ return `{escape}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
234
+ case 'spacer':
235
+ return '';
236
+ }
237
+ }
238
+ /**
239
+ * Format diff output as blessed-compatible tagged string.
240
+ * Returns both the content and total row count for scroll calculations.
241
+ */
242
+ export function formatDiff(diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
243
+ const displayRows = buildDiffDisplayRows(diff);
244
+ if (displayRows.length === 0) {
245
+ return { content: '{gray-fg}No diff to display{/gray-fg}', totalRows: 0 };
246
+ }
247
+ const theme = getTheme(themeName);
248
+ const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
249
+ const contentWidth = width - lineNumWidth - 5; // line num + space + symbol + space + padding
250
+ const headerWidth = width - 2;
251
+ // Apply wrapping if enabled
252
+ const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
253
+ const totalRows = wrappedRows.length;
254
+ // Apply scroll offset and max height
255
+ 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 };
260
+ }
261
+ /**
262
+ * Format history diff (commit metadata + diff) as blessed-compatible tagged string.
263
+ * Returns both the content and total row count for scroll calculations.
264
+ */
265
+ export function formatHistoryDiff(commit, diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
266
+ const displayRows = buildHistoryDisplayRows(commit, diff);
267
+ if (displayRows.length === 0) {
268
+ return { content: '{gray-fg}No commit selected{/gray-fg}', totalRows: 0 };
269
+ }
270
+ const theme = getTheme(themeName);
271
+ const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
272
+ const contentWidth = width - lineNumWidth - 5;
273
+ const headerWidth = width - 2;
274
+ const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
275
+ const totalRows = wrappedRows.length;
276
+ const visibleRows = maxHeight
277
+ ? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
278
+ : wrappedRows.slice(scrollOffset);
279
+ const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
280
+ return { content: lines.join('\n'), totalRows };
281
+ }
@@ -0,0 +1,89 @@
1
+ import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, } from '../../utils/explorerDisplayRows.js';
2
+ import { truncateAnsi } from '../../utils/ansiTruncate.js';
3
+ const ANSI_RESET = '\x1b[0m';
4
+ const ANSI_GRAY = '\x1b[90m';
5
+ const ANSI_CYAN = '\x1b[36m';
6
+ const ANSI_YELLOW = '\x1b[33m';
7
+ /**
8
+ * Format explorer file content as blessed-compatible tagged string.
9
+ */
10
+ export function formatExplorerContent(filePath, content, width, scrollOffset = 0, maxHeight, truncated = false, wrapMode = false) {
11
+ if (!filePath) {
12
+ return '{gray-fg}Select a file to view its contents{/gray-fg}';
13
+ }
14
+ if (!content) {
15
+ return '{gray-fg}Loading...{/gray-fg}';
16
+ }
17
+ // Build base rows with syntax highlighting
18
+ const baseRows = buildExplorerContentRows(content, filePath, truncated);
19
+ if (baseRows.length === 0) {
20
+ return '{gray-fg}(empty file){/gray-fg}';
21
+ }
22
+ // Calculate line number width
23
+ const lineNumWidth = getExplorerContentLineNumWidth(baseRows);
24
+ // Calculate content width for wrapping
25
+ // Layout: lineNum + space(1) + content
26
+ const contentWidth = width - lineNumWidth - 2;
27
+ // Apply wrapping if enabled
28
+ const displayRows = wrapExplorerContentRows(baseRows, contentWidth, wrapMode);
29
+ // Apply scroll offset and max height
30
+ const visibleRows = maxHeight
31
+ ? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
32
+ : displayRows.slice(scrollOffset);
33
+ const lines = [];
34
+ for (const row of visibleRows) {
35
+ if (row.type === 'truncation') {
36
+ // Use {escape} with raw ANSI for consistency
37
+ lines.push(`{escape}${ANSI_YELLOW}${row.content}${ANSI_RESET}{/escape}`);
38
+ continue;
39
+ }
40
+ // Code row
41
+ const isContinuation = row.isContinuation ?? false;
42
+ // Line number display
43
+ let lineNumDisplay;
44
+ if (isContinuation) {
45
+ lineNumDisplay = '>>'.padStart(lineNumWidth, ' ');
46
+ }
47
+ else {
48
+ lineNumDisplay = String(row.lineNum).padStart(lineNumWidth, ' ');
49
+ }
50
+ // Determine what content to display
51
+ const rawContent = row.content;
52
+ const shouldTruncate = !wrapMode && rawContent.length > contentWidth;
53
+ // Use highlighted content if available and not a continuation
54
+ const canUseHighlighting = row.highlighted && !isContinuation;
55
+ let displayContent;
56
+ if (canUseHighlighting && row.highlighted) {
57
+ // Use ANSI-aware truncation to preserve syntax highlighting
58
+ displayContent = shouldTruncate
59
+ ? truncateAnsi(row.highlighted, contentWidth)
60
+ : row.highlighted;
61
+ }
62
+ else {
63
+ // Plain content path
64
+ let plainContent = rawContent;
65
+ // Simple truncation for plain content
66
+ if (shouldTruncate) {
67
+ plainContent = plainContent.slice(0, Math.max(0, contentWidth - 1)) + '...';
68
+ }
69
+ displayContent = plainContent;
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}`;
74
+ lines.push(line);
75
+ }
76
+ return lines.join('\n');
77
+ }
78
+ /**
79
+ * Get total rows for scroll calculations.
80
+ * Accounts for wrap mode when calculating.
81
+ */
82
+ export function getExplorerContentTotalRows(content, filePath, truncated, width, wrapMode) {
83
+ if (!content)
84
+ return 0;
85
+ const rows = buildExplorerContentRows(content, filePath, truncated);
86
+ const lineNumWidth = getExplorerContentLineNumWidth(rows);
87
+ const contentWidth = width - lineNumWidth - 2;
88
+ return getExplorerContentRowCount(rows, contentWidth, wrapMode);
89
+ }