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