diffstalker 0.1.6 → 0.2.0
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.
- package/.github/workflows/release.yml +5 -3
- package/CHANGELOG.md +36 -0
- package/bun.lock +378 -0
- package/dist/App.js +1162 -1
- package/dist/config.js +83 -2
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitOperationQueue.js +109 -1
- package/dist/core/GitStateManager.js +525 -1
- package/dist/git/diff.js +471 -10
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +237 -5
- package/dist/index.js +70 -16
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/services/commitService.js +22 -1
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +182 -0
- package/dist/themes.js +127 -1
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/HotkeysModal.js +209 -0
- package/dist/ui/modals/ThemePicker.js +107 -0
- package/dist/ui/widgets/CommitPanel.js +58 -0
- package/dist/ui/widgets/CompareListView.js +216 -0
- package/dist/ui/widgets/DiffView.js +279 -0
- package/dist/ui/widgets/ExplorerContent.js +102 -0
- package/dist/ui/widgets/ExplorerView.js +95 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +46 -0
- package/dist/ui/widgets/Header.js +111 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/ansiToBlessed.js +125 -0
- package/dist/utils/ansiTruncate.js +108 -0
- package/dist/utils/baseBranchCache.js +44 -2
- package/dist/utils/commitFormat.js +38 -1
- package/dist/utils/diffFilters.js +21 -1
- package/dist/utils/diffRowCalculations.js +113 -1
- package/dist/utils/displayRows.js +351 -2
- package/dist/utils/explorerDisplayRows.js +169 -0
- package/dist/utils/fileCategories.js +26 -1
- package/dist/utils/formatDate.js +39 -1
- package/dist/utils/formatPath.js +58 -1
- package/dist/utils/languageDetection.js +236 -0
- package/dist/utils/layoutCalculations.js +98 -1
- package/dist/utils/lineBreaking.js +88 -5
- package/dist/utils/mouseCoordinates.js +165 -1
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +246 -4
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +15 -19
- package/dist/components/BaseBranchPicker.js +0 -1
- package/dist/components/BottomPane.js +0 -1
- package/dist/components/CommitPanel.js +0 -1
- package/dist/components/CompareListView.js +0 -1
- package/dist/components/ExplorerContentView.js +0 -3
- package/dist/components/ExplorerView.js +0 -1
- package/dist/components/FileList.js +0 -1
- package/dist/components/Footer.js +0 -1
- package/dist/components/Header.js +0 -1
- package/dist/components/HistoryView.js +0 -1
- package/dist/components/HotkeysModal.js +0 -1
- package/dist/components/Modal.js +0 -1
- package/dist/components/ScrollableList.js +0 -1
- package/dist/components/ThemePicker.js +0 -1
- package/dist/components/TopPane.js +0 -1
- package/dist/components/UnifiedDiffView.js +0 -1
- package/dist/hooks/useCommitFlow.js +0 -1
- package/dist/hooks/useCompareState.js +0 -1
- package/dist/hooks/useExplorerState.js +0 -9
- package/dist/hooks/useGit.js +0 -1
- package/dist/hooks/useHistoryState.js +0 -1
- package/dist/hooks/useKeymap.js +0 -1
- package/dist/hooks/useLayout.js +0 -1
- package/dist/hooks/useMouse.js +0 -1
- package/dist/hooks/useTerminalSize.js +0 -1
- package/dist/hooks/useWatcher.js +0 -11
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { getTheme } from '../../themes.js';
|
|
2
|
+
import { buildDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, } from '../../utils/displayRows.js';
|
|
3
|
+
import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
|
|
4
|
+
import { truncateAnsi } from '../../utils/ansiTruncate.js';
|
|
5
|
+
/**
|
|
6
|
+
* Truncate string to fit within maxWidth, adding ellipsis if needed.
|
|
7
|
+
*/
|
|
8
|
+
function truncate(str, maxWidth) {
|
|
9
|
+
if (maxWidth <= 0 || str.length <= maxWidth)
|
|
10
|
+
return str;
|
|
11
|
+
if (maxWidth <= 1)
|
|
12
|
+
return '\u2026';
|
|
13
|
+
return str.slice(0, maxWidth - 1) + '\u2026';
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Format line number with padding.
|
|
17
|
+
*/
|
|
18
|
+
function formatLineNum(lineNum, width) {
|
|
19
|
+
if (lineNum === undefined)
|
|
20
|
+
return ' '.repeat(width);
|
|
21
|
+
return String(lineNum).padStart(width, ' ');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Escape blessed tags in content.
|
|
25
|
+
*/
|
|
26
|
+
function escapeContent(content) {
|
|
27
|
+
return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build raw ANSI escape sequence for 24-bit RGB background.
|
|
31
|
+
*/
|
|
32
|
+
function ansiBg(hex) {
|
|
33
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
34
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
35
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
36
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build raw ANSI escape sequence for 24-bit RGB foreground.
|
|
40
|
+
*/
|
|
41
|
+
function ansiFg(hex) {
|
|
42
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
43
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
44
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
45
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
46
|
+
}
|
|
47
|
+
const ANSI_RESET = '\x1b[0m';
|
|
48
|
+
/**
|
|
49
|
+
* Format a single display row as blessed-compatible tagged string.
|
|
50
|
+
*/
|
|
51
|
+
function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode) {
|
|
52
|
+
const { colors } = theme;
|
|
53
|
+
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
|
+
}
|
|
209
|
+
case 'diff-context': {
|
|
210
|
+
const isCont = row.isContinuation;
|
|
211
|
+
const symbol = isCont ? '\u00bb' : ' ';
|
|
212
|
+
const lineNum = formatLineNum(row.lineNum, lineNumWidth);
|
|
213
|
+
const prefix = `${lineNum} ${symbol} `;
|
|
214
|
+
const rawContent = row.content || '';
|
|
215
|
+
// Use syntax highlighting if available (not for continuations)
|
|
216
|
+
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}`;
|
|
222
|
+
}
|
|
223
|
+
const content = wrapMode
|
|
224
|
+
? escapeContent(rawContent)
|
|
225
|
+
: escapeContent(truncate(rawContent, contentWidth));
|
|
226
|
+
return `{gray-fg}${prefix}{/gray-fg}${content}`;
|
|
227
|
+
}
|
|
228
|
+
case 'commit-header':
|
|
229
|
+
return `{yellow-fg}${escapeContent(truncate(row.content, headerWidth))}{/yellow-fg}`;
|
|
230
|
+
case 'commit-message':
|
|
231
|
+
return escapeContent(truncate(row.content, headerWidth));
|
|
232
|
+
case 'spacer':
|
|
233
|
+
return '';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Format diff output as blessed-compatible tagged string.
|
|
238
|
+
* Returns both the content and total row count for scroll calculations.
|
|
239
|
+
*/
|
|
240
|
+
export function formatDiff(diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
|
|
241
|
+
const displayRows = buildDiffDisplayRows(diff);
|
|
242
|
+
if (displayRows.length === 0) {
|
|
243
|
+
return { content: '{gray-fg}No diff to display{/gray-fg}', totalRows: 0 };
|
|
244
|
+
}
|
|
245
|
+
const theme = getTheme(themeName);
|
|
246
|
+
const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
|
|
247
|
+
const contentWidth = width - lineNumWidth - 5; // line num + space + symbol + space + padding
|
|
248
|
+
const headerWidth = width - 2;
|
|
249
|
+
// Apply wrapping if enabled
|
|
250
|
+
const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
|
|
251
|
+
const totalRows = wrappedRows.length;
|
|
252
|
+
// Apply scroll offset and max height
|
|
253
|
+
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 };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Format history diff (commit metadata + diff) as blessed-compatible tagged string.
|
|
261
|
+
* Returns both the content and total row count for scroll calculations.
|
|
262
|
+
*/
|
|
263
|
+
export function formatHistoryDiff(commit, diff, width, scrollOffset = 0, maxHeight, themeName = 'dark', wrapMode = false) {
|
|
264
|
+
const displayRows = buildHistoryDisplayRows(commit, diff);
|
|
265
|
+
if (displayRows.length === 0) {
|
|
266
|
+
return { content: '{gray-fg}No commit selected{/gray-fg}', totalRows: 0 };
|
|
267
|
+
}
|
|
268
|
+
const theme = getTheme(themeName);
|
|
269
|
+
const lineNumWidth = getDisplayRowsLineNumWidth(displayRows);
|
|
270
|
+
const contentWidth = width - lineNumWidth - 5;
|
|
271
|
+
const headerWidth = width - 2;
|
|
272
|
+
const wrappedRows = wrapDisplayRows(displayRows, contentWidth, wrapMode);
|
|
273
|
+
const totalRows = wrappedRows.length;
|
|
274
|
+
const visibleRows = maxHeight
|
|
275
|
+
? wrappedRows.slice(scrollOffset, scrollOffset + maxHeight)
|
|
276
|
+
: wrappedRows.slice(scrollOffset);
|
|
277
|
+
const lines = visibleRows.map((row) => formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, wrapMode));
|
|
278
|
+
return { content: lines.join('\n'), totalRows };
|
|
279
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, applyMiddleDots, } from '../../utils/explorerDisplayRows.js';
|
|
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
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Format explorer file content as blessed-compatible tagged string.
|
|
12
|
+
*/
|
|
13
|
+
export function formatExplorerContent(filePath, content, width, scrollOffset = 0, maxHeight, truncated = false, wrapMode = false, showMiddleDots = false) {
|
|
14
|
+
if (!filePath) {
|
|
15
|
+
return '{gray-fg}Select a file to view its contents{/gray-fg}';
|
|
16
|
+
}
|
|
17
|
+
if (!content) {
|
|
18
|
+
return '{gray-fg}Loading...{/gray-fg}';
|
|
19
|
+
}
|
|
20
|
+
// Build base rows with syntax highlighting
|
|
21
|
+
const baseRows = buildExplorerContentRows(content, filePath, truncated);
|
|
22
|
+
if (baseRows.length === 0) {
|
|
23
|
+
return '{gray-fg}(empty file){/gray-fg}';
|
|
24
|
+
}
|
|
25
|
+
// Calculate line number width
|
|
26
|
+
const lineNumWidth = getExplorerContentLineNumWidth(baseRows);
|
|
27
|
+
// Calculate content width for wrapping
|
|
28
|
+
// Layout: lineNum + space(1) + content
|
|
29
|
+
const contentWidth = width - lineNumWidth - 2;
|
|
30
|
+
// Apply wrapping if enabled
|
|
31
|
+
const displayRows = wrapExplorerContentRows(baseRows, contentWidth, wrapMode);
|
|
32
|
+
// Apply scroll offset and max height
|
|
33
|
+
const visibleRows = maxHeight
|
|
34
|
+
? displayRows.slice(scrollOffset, scrollOffset + maxHeight)
|
|
35
|
+
: displayRows.slice(scrollOffset);
|
|
36
|
+
const lines = [];
|
|
37
|
+
for (const row of visibleRows) {
|
|
38
|
+
if (row.type === 'truncation') {
|
|
39
|
+
lines.push(`{yellow-fg}${escapeContent(row.content)}{/yellow-fg}`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Code row
|
|
43
|
+
const isContinuation = row.isContinuation ?? false;
|
|
44
|
+
// Line number display
|
|
45
|
+
let lineNumDisplay;
|
|
46
|
+
if (isContinuation) {
|
|
47
|
+
lineNumDisplay = '>>'.padStart(lineNumWidth, ' ');
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
lineNumDisplay = String(row.lineNum).padStart(lineNumWidth, ' ');
|
|
51
|
+
}
|
|
52
|
+
// Determine what content to display
|
|
53
|
+
const rawContent = row.content;
|
|
54
|
+
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;
|
|
57
|
+
let displayContent;
|
|
58
|
+
if (canUseHighlighting && row.highlighted) {
|
|
59
|
+
// Use ANSI-aware truncation to preserve syntax highlighting
|
|
60
|
+
const truncatedHighlight = shouldTruncate
|
|
61
|
+
? truncateAnsi(row.highlighted, contentWidth)
|
|
62
|
+
: row.highlighted;
|
|
63
|
+
// Convert ANSI to blessed tags
|
|
64
|
+
displayContent = ansiToBlessed(truncatedHighlight);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Plain content path
|
|
68
|
+
let plainContent = rawContent;
|
|
69
|
+
// Apply middle-dots to raw content
|
|
70
|
+
if (showMiddleDots && !isContinuation) {
|
|
71
|
+
plainContent = applyMiddleDots(plainContent, true);
|
|
72
|
+
}
|
|
73
|
+
// Simple truncation for plain content
|
|
74
|
+
if (shouldTruncate) {
|
|
75
|
+
plainContent = plainContent.slice(0, Math.max(0, contentWidth - 1)) + '...';
|
|
76
|
+
}
|
|
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 || ' '}`;
|
|
86
|
+
}
|
|
87
|
+
lines.push(line);
|
|
88
|
+
}
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Get total rows for scroll calculations.
|
|
93
|
+
* Accounts for wrap mode when calculating.
|
|
94
|
+
*/
|
|
95
|
+
export function getExplorerContentTotalRows(content, filePath, truncated, width, wrapMode) {
|
|
96
|
+
if (!content)
|
|
97
|
+
return 0;
|
|
98
|
+
const rows = buildExplorerContentRows(content, filePath, truncated);
|
|
99
|
+
const lineNumWidth = getExplorerContentLineNumWidth(rows);
|
|
100
|
+
const contentWidth = width - lineNumWidth - 2;
|
|
101
|
+
return getExplorerContentRowCount(rows, contentWidth, wrapMode);
|
|
102
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape blessed tags in content.
|
|
3
|
+
*/
|
|
4
|
+
function escapeContent(content) {
|
|
5
|
+
return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Format the explorer directory listing as blessed-compatible tagged string.
|
|
9
|
+
*/
|
|
10
|
+
export function formatExplorerView(items, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight, isLoading = false, error = null) {
|
|
11
|
+
if (error) {
|
|
12
|
+
return `{red-fg}Error: ${escapeContent(error)}{/red-fg}`;
|
|
13
|
+
}
|
|
14
|
+
if (isLoading) {
|
|
15
|
+
return '{gray-fg}Loading...{/gray-fg}';
|
|
16
|
+
}
|
|
17
|
+
if (items.length === 0) {
|
|
18
|
+
return '{gray-fg}(empty directory){/gray-fg}';
|
|
19
|
+
}
|
|
20
|
+
// Apply scroll offset and max height
|
|
21
|
+
const visibleItems = maxHeight
|
|
22
|
+
? items.slice(scrollOffset, scrollOffset + maxHeight)
|
|
23
|
+
: items.slice(scrollOffset);
|
|
24
|
+
// Calculate max name width for alignment
|
|
25
|
+
const maxNameWidth = Math.min(Math.max(...items.map((item) => item.name.length + (item.isDirectory ? 1 : 0))), width - 10);
|
|
26
|
+
const lines = [];
|
|
27
|
+
for (let i = 0; i < visibleItems.length; i++) {
|
|
28
|
+
const item = visibleItems[i];
|
|
29
|
+
const actualIndex = scrollOffset + i;
|
|
30
|
+
const isSelected = actualIndex === selectedIndex;
|
|
31
|
+
const isHighlighted = isSelected && isFocused;
|
|
32
|
+
const displayName = item.isDirectory ? `${item.name}/` : item.name;
|
|
33
|
+
const paddedName = displayName.padEnd(maxNameWidth + 1);
|
|
34
|
+
let line = '';
|
|
35
|
+
if (isHighlighted) {
|
|
36
|
+
// Selected and focused - highlight with cyan
|
|
37
|
+
if (item.isDirectory) {
|
|
38
|
+
line = `{cyan-fg}{bold}{inverse}${escapeContent(paddedName)}{/inverse}{/bold}{/cyan-fg}`;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
line = `{cyan-fg}{bold}{inverse}${escapeContent(paddedName)}{/inverse}{/bold}{/cyan-fg}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// Not selected or not focused
|
|
46
|
+
if (item.isDirectory) {
|
|
47
|
+
line = `{blue-fg}${escapeContent(paddedName)}{/blue-fg}`;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
line = escapeContent(paddedName);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
lines.push(line);
|
|
54
|
+
}
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build breadcrumb segments from a path.
|
|
59
|
+
* Returns segments like ["src", "components"] for "src/components"
|
|
60
|
+
*/
|
|
61
|
+
export function buildBreadcrumbs(currentPath) {
|
|
62
|
+
if (!currentPath)
|
|
63
|
+
return [];
|
|
64
|
+
return currentPath.split('/').filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Format breadcrumbs for display.
|
|
68
|
+
*/
|
|
69
|
+
export function formatBreadcrumbs(currentPath, repoName) {
|
|
70
|
+
const segments = buildBreadcrumbs(currentPath);
|
|
71
|
+
if (segments.length === 0) {
|
|
72
|
+
return `{bold}${escapeContent(repoName)}{/bold}`;
|
|
73
|
+
}
|
|
74
|
+
const parts = [repoName, ...segments];
|
|
75
|
+
return parts
|
|
76
|
+
.map((part, i) => {
|
|
77
|
+
if (i === parts.length - 1) {
|
|
78
|
+
return `{bold}${escapeContent(part)}{/bold}`;
|
|
79
|
+
}
|
|
80
|
+
return `{gray-fg}${escapeContent(part)}{/gray-fg}`;
|
|
81
|
+
})
|
|
82
|
+
.join('{gray-fg}/{/gray-fg}');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get total rows in explorer for scroll calculations.
|
|
86
|
+
*/
|
|
87
|
+
export function getExplorerTotalRows(items) {
|
|
88
|
+
return items.length;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get item at index.
|
|
92
|
+
*/
|
|
93
|
+
export function getExplorerItemAtIndex(items, index) {
|
|
94
|
+
return items[index] ?? null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the list of row items for the file list.
|
|
53
|
+
*/
|
|
54
|
+
export function buildFileListRows(files) {
|
|
55
|
+
const { modified, untracked, staged } = categorizeFiles(files);
|
|
56
|
+
const rows = [];
|
|
57
|
+
let currentFileIndex = 0;
|
|
58
|
+
if (modified.length > 0) {
|
|
59
|
+
rows.push({ type: 'header', content: 'Modified:', headerColor: 'yellow' });
|
|
60
|
+
modified.forEach((file) => {
|
|
61
|
+
rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (untracked.length > 0) {
|
|
65
|
+
if (modified.length > 0) {
|
|
66
|
+
rows.push({ type: 'spacer' });
|
|
67
|
+
}
|
|
68
|
+
rows.push({ type: 'header', content: 'Untracked:', headerColor: 'gray' });
|
|
69
|
+
untracked.forEach((file) => {
|
|
70
|
+
rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (staged.length > 0) {
|
|
74
|
+
if (modified.length > 0 || untracked.length > 0) {
|
|
75
|
+
rows.push({ type: 'spacer' });
|
|
76
|
+
}
|
|
77
|
+
rows.push({ type: 'header', content: 'Staged:', headerColor: 'green' });
|
|
78
|
+
staged.forEach((file) => {
|
|
79
|
+
rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Format the file list as blessed-compatible tagged string.
|
|
86
|
+
*/
|
|
87
|
+
export function formatFileList(files, selectedIndex, isFocused, width, scrollOffset = 0, maxHeight) {
|
|
88
|
+
if (files.length === 0) {
|
|
89
|
+
return '{gray-fg} No changes{/gray-fg}';
|
|
90
|
+
}
|
|
91
|
+
const rows = buildFileListRows(files);
|
|
92
|
+
const maxPathLength = width - 12; // Account for prefix chars
|
|
93
|
+
// Apply scroll offset and max height
|
|
94
|
+
const visibleRows = maxHeight
|
|
95
|
+
? rows.slice(scrollOffset, scrollOffset + maxHeight)
|
|
96
|
+
: rows.slice(scrollOffset);
|
|
97
|
+
const lines = [];
|
|
98
|
+
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}`;
|
|
141
|
+
}
|
|
142
|
+
// Stats
|
|
143
|
+
line += stats;
|
|
144
|
+
lines.push(line);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Get the total number of rows in the file list (for scroll calculation).
|
|
151
|
+
*/
|
|
152
|
+
export function getFileListTotalRows(files) {
|
|
153
|
+
return buildFileListRows(files).length;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get the file at a specific index (accounting for category ordering).
|
|
157
|
+
*/
|
|
158
|
+
export function getFileAtIndex(files, index) {
|
|
159
|
+
const { ordered } = categorizeFiles(files);
|
|
160
|
+
return ordered[index] ?? null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get the file index from a visual row (accounting for headers and spacers).
|
|
164
|
+
* Returns null if the row is a header or spacer.
|
|
165
|
+
*/
|
|
166
|
+
export function getFileIndexFromRow(row, files) {
|
|
167
|
+
const rows = buildFileListRows(files);
|
|
168
|
+
const rowItem = rows[row];
|
|
169
|
+
if (rowItem?.type === 'file' && rowItem.fileIndex !== undefined) {
|
|
170
|
+
return rowItem.fileIndex;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Get the visual row index for a file index.
|
|
176
|
+
*/
|
|
177
|
+
export function getRowFromFileIndex(fileIndex, files) {
|
|
178
|
+
const rows = buildFileListRows(files);
|
|
179
|
+
for (let i = 0; i < rows.length; i++) {
|
|
180
|
+
if (rows[i].type === 'file' && rows[i].fileIndex === fileIndex) {
|
|
181
|
+
return i;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|