diffstalker 0.2.0 → 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.
- package/.github/workflows/release.yml +8 -0
- package/bun.lock +23 -0
- package/dist/App.js +225 -471
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +444 -78
- package/dist/core/GitStateManager.js +169 -93
- package/dist/git/diff.js +4 -0
- package/dist/index.js +54 -53
- package/dist/state/UIState.js +17 -4
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/FileFinder.js +232 -0
- package/dist/ui/widgets/CompareListView.js +86 -64
- package/dist/ui/widgets/DiffView.js +19 -17
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +140 -31
- package/dist/ui/widgets/Footer.js +6 -2
- package/dist/ui/widgets/Header.js +3 -46
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +4 -1
- package/dist/utils/ansiToBlessed.js +0 -125
- package/dist/utils/mouseCoordinates.js +0 -165
- package/dist/utils/rowCalculations.js +0 -246
package/dist/state/UIState.js
CHANGED
|
@@ -16,7 +16,6 @@ const DEFAULT_STATE = {
|
|
|
16
16
|
wrapMode: false,
|
|
17
17
|
autoTabEnabled: false,
|
|
18
18
|
mouseEnabled: true,
|
|
19
|
-
showMiddleDots: false,
|
|
20
19
|
hideHiddenFiles: true,
|
|
21
20
|
hideGitignored: true,
|
|
22
21
|
splitRatio: 0.4,
|
|
@@ -121,9 +120,6 @@ export class UIState extends EventEmitter {
|
|
|
121
120
|
toggleMouse() {
|
|
122
121
|
this.update({ mouseEnabled: !this._state.mouseEnabled });
|
|
123
122
|
}
|
|
124
|
-
toggleMiddleDots() {
|
|
125
|
-
this.update({ showMiddleDots: !this._state.showMiddleDots });
|
|
126
|
-
}
|
|
127
123
|
toggleHideHiddenFiles() {
|
|
128
124
|
this.update({ hideHiddenFiles: !this._state.hideHiddenFiles });
|
|
129
125
|
}
|
|
@@ -179,4 +175,21 @@ export class UIState extends EventEmitter {
|
|
|
179
175
|
this.setPane(currentPane === 'explorer' ? 'diff' : 'explorer');
|
|
180
176
|
}
|
|
181
177
|
}
|
|
178
|
+
// Reset repo-specific state when switching repositories
|
|
179
|
+
resetForNewRepo() {
|
|
180
|
+
this._state = {
|
|
181
|
+
...this._state,
|
|
182
|
+
selectedIndex: 0,
|
|
183
|
+
fileListScrollOffset: 0,
|
|
184
|
+
diffScrollOffset: 0,
|
|
185
|
+
historySelectedIndex: 0,
|
|
186
|
+
historyScrollOffset: 0,
|
|
187
|
+
compareSelectedIndex: 0,
|
|
188
|
+
compareScrollOffset: 0,
|
|
189
|
+
explorerSelectedIndex: 0,
|
|
190
|
+
explorerScrollOffset: 0,
|
|
191
|
+
explorerFileScrollOffset: 0,
|
|
192
|
+
};
|
|
193
|
+
this.emit('change', this._state);
|
|
194
|
+
}
|
|
182
195
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { formatFileList } from './widgets/FileList.js';
|
|
2
|
+
import { formatHistoryView } from './widgets/HistoryView.js';
|
|
3
|
+
import { formatCompareListView } from './widgets/CompareListView.js';
|
|
4
|
+
import { formatExplorerView } from './widgets/ExplorerView.js';
|
|
5
|
+
import { formatDiff, formatHistoryDiff } from './widgets/DiffView.js';
|
|
6
|
+
import { formatCommitPanel } from './widgets/CommitPanel.js';
|
|
7
|
+
import { formatExplorerContent } from './widgets/ExplorerContent.js';
|
|
8
|
+
/**
|
|
9
|
+
* Render the top pane content for the current tab.
|
|
10
|
+
*/
|
|
11
|
+
export function renderTopPane(state, files, historyCommits, compareDiff, compareSelection, explorerState, width, topPaneHeight) {
|
|
12
|
+
if (state.bottomTab === 'history') {
|
|
13
|
+
return formatHistoryView(historyCommits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, topPaneHeight);
|
|
14
|
+
}
|
|
15
|
+
if (state.bottomTab === 'compare') {
|
|
16
|
+
const commits = compareDiff?.commits ?? [];
|
|
17
|
+
const compareFiles = compareDiff?.files ?? [];
|
|
18
|
+
return formatCompareListView(commits, compareFiles, compareSelection, state.currentPane === 'compare', width, state.compareScrollOffset, topPaneHeight);
|
|
19
|
+
}
|
|
20
|
+
if (state.bottomTab === 'explorer') {
|
|
21
|
+
const displayRows = explorerState?.displayRows ?? [];
|
|
22
|
+
return formatExplorerView(displayRows, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
|
|
23
|
+
}
|
|
24
|
+
// Default: diff tab file list
|
|
25
|
+
return formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, topPaneHeight);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Render the bottom pane content for the current tab.
|
|
29
|
+
*/
|
|
30
|
+
export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight) {
|
|
31
|
+
if (state.bottomTab === 'commit') {
|
|
32
|
+
const content = formatCommitPanel(commitFlowState, stagedCount, width);
|
|
33
|
+
return { content, totalRows: 0 };
|
|
34
|
+
}
|
|
35
|
+
if (state.bottomTab === 'history') {
|
|
36
|
+
const selectedCommit = historyState?.selectedCommit ?? null;
|
|
37
|
+
const commitDiff = historyState?.commitDiff ?? null;
|
|
38
|
+
const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
|
|
39
|
+
return { content, totalRows };
|
|
40
|
+
}
|
|
41
|
+
if (state.bottomTab === 'compare') {
|
|
42
|
+
const compareDiff = compareSelectionState?.diff ?? null;
|
|
43
|
+
if (compareDiff) {
|
|
44
|
+
const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
|
|
45
|
+
return { content, totalRows };
|
|
46
|
+
}
|
|
47
|
+
return { content: '{gray-fg}Select a commit or file to view diff{/gray-fg}', totalRows: 0 };
|
|
48
|
+
}
|
|
49
|
+
if (state.bottomTab === 'explorer') {
|
|
50
|
+
const content = formatExplorerContent(explorerSelectedFile?.path ?? null, explorerSelectedFile?.content ?? null, width, state.explorerFileScrollOffset, bottomPaneHeight, explorerSelectedFile?.truncated ?? false, state.wrapMode);
|
|
51
|
+
return { content, totalRows: 0 };
|
|
52
|
+
}
|
|
53
|
+
// Default: diff tab
|
|
54
|
+
const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
|
|
55
|
+
return { content, totalRows };
|
|
56
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
const MAX_RESULTS = 15;
|
|
3
|
+
/**
|
|
4
|
+
* Simple fuzzy match scoring.
|
|
5
|
+
* Returns -1 if no match, otherwise a score (higher is better).
|
|
6
|
+
*/
|
|
7
|
+
function fuzzyScore(query, target) {
|
|
8
|
+
const lowerQuery = query.toLowerCase();
|
|
9
|
+
const lowerTarget = target.toLowerCase();
|
|
10
|
+
// Must contain all query characters in order
|
|
11
|
+
let queryIndex = 0;
|
|
12
|
+
let score = 0;
|
|
13
|
+
let lastMatchIndex = -1;
|
|
14
|
+
for (let i = 0; i < lowerTarget.length && queryIndex < lowerQuery.length; i++) {
|
|
15
|
+
if (lowerTarget[i] === lowerQuery[queryIndex]) {
|
|
16
|
+
// Bonus for consecutive matches
|
|
17
|
+
if (lastMatchIndex === i - 1) {
|
|
18
|
+
score += 10;
|
|
19
|
+
}
|
|
20
|
+
// Bonus for matching at start of word
|
|
21
|
+
if (i === 0 || lowerTarget[i - 1] === '/' || lowerTarget[i - 1] === '.') {
|
|
22
|
+
score += 5;
|
|
23
|
+
}
|
|
24
|
+
score += 1;
|
|
25
|
+
lastMatchIndex = i;
|
|
26
|
+
queryIndex++;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// All query characters must match
|
|
30
|
+
if (queryIndex < lowerQuery.length) {
|
|
31
|
+
return -1;
|
|
32
|
+
}
|
|
33
|
+
// Bonus for shorter paths (more specific)
|
|
34
|
+
score += Math.max(0, 50 - target.length);
|
|
35
|
+
return score;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Highlight matched characters in path.
|
|
39
|
+
*/
|
|
40
|
+
function highlightMatch(query, path) {
|
|
41
|
+
if (!query)
|
|
42
|
+
return path;
|
|
43
|
+
const lowerQuery = query.toLowerCase();
|
|
44
|
+
const lowerPath = path.toLowerCase();
|
|
45
|
+
let result = '';
|
|
46
|
+
let queryIndex = 0;
|
|
47
|
+
for (let i = 0; i < path.length; i++) {
|
|
48
|
+
if (queryIndex < lowerQuery.length && lowerPath[i] === lowerQuery[queryIndex]) {
|
|
49
|
+
result += `{yellow-fg}${path[i]}{/yellow-fg}`;
|
|
50
|
+
queryIndex++;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
result += path[i];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* FileFinder modal for fuzzy file search.
|
|
60
|
+
*/
|
|
61
|
+
export class FileFinder {
|
|
62
|
+
box;
|
|
63
|
+
textbox;
|
|
64
|
+
screen;
|
|
65
|
+
allPaths;
|
|
66
|
+
results = [];
|
|
67
|
+
selectedIndex = 0;
|
|
68
|
+
query = '';
|
|
69
|
+
onSelect;
|
|
70
|
+
onCancel;
|
|
71
|
+
constructor(screen, allPaths, onSelect, onCancel) {
|
|
72
|
+
this.screen = screen;
|
|
73
|
+
this.allPaths = allPaths;
|
|
74
|
+
this.onSelect = onSelect;
|
|
75
|
+
this.onCancel = onCancel;
|
|
76
|
+
// Create modal box
|
|
77
|
+
const width = Math.min(80, screen.width - 10);
|
|
78
|
+
const height = MAX_RESULTS + 6; // results + input + header + borders + padding
|
|
79
|
+
this.box = blessed.box({
|
|
80
|
+
parent: screen,
|
|
81
|
+
top: 'center',
|
|
82
|
+
left: 'center',
|
|
83
|
+
width,
|
|
84
|
+
height,
|
|
85
|
+
border: {
|
|
86
|
+
type: 'line',
|
|
87
|
+
},
|
|
88
|
+
style: {
|
|
89
|
+
border: {
|
|
90
|
+
fg: 'cyan',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
tags: true,
|
|
94
|
+
keys: false, // We'll handle keys ourselves
|
|
95
|
+
});
|
|
96
|
+
// Create text input
|
|
97
|
+
this.textbox = blessed.textarea({
|
|
98
|
+
parent: this.box,
|
|
99
|
+
top: 1,
|
|
100
|
+
left: 1,
|
|
101
|
+
width: width - 4,
|
|
102
|
+
height: 1,
|
|
103
|
+
inputOnFocus: true,
|
|
104
|
+
style: {
|
|
105
|
+
fg: 'white',
|
|
106
|
+
bg: 'default',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
// Setup key handlers
|
|
110
|
+
this.setupKeyHandlers();
|
|
111
|
+
// Initial render with all files
|
|
112
|
+
this.updateResults();
|
|
113
|
+
this.render();
|
|
114
|
+
}
|
|
115
|
+
setupKeyHandlers() {
|
|
116
|
+
// Handle escape to cancel
|
|
117
|
+
this.textbox.key(['escape'], () => {
|
|
118
|
+
this.close();
|
|
119
|
+
this.onCancel();
|
|
120
|
+
});
|
|
121
|
+
// Handle enter to select
|
|
122
|
+
this.textbox.key(['enter'], () => {
|
|
123
|
+
if (this.results.length > 0) {
|
|
124
|
+
const selected = this.results[this.selectedIndex];
|
|
125
|
+
this.close();
|
|
126
|
+
this.onSelect(selected.path);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
|
|
130
|
+
this.textbox.key(['C-j', 'down'], () => {
|
|
131
|
+
this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
|
|
132
|
+
this.render();
|
|
133
|
+
});
|
|
134
|
+
this.textbox.key(['C-k', 'up'], () => {
|
|
135
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
136
|
+
this.render();
|
|
137
|
+
});
|
|
138
|
+
// Handle tab for next result
|
|
139
|
+
this.textbox.key(['tab'], () => {
|
|
140
|
+
this.selectedIndex = (this.selectedIndex + 1) % Math.max(1, this.results.length);
|
|
141
|
+
this.render();
|
|
142
|
+
});
|
|
143
|
+
// Handle shift-tab for previous result
|
|
144
|
+
this.textbox.key(['S-tab'], () => {
|
|
145
|
+
this.selectedIndex =
|
|
146
|
+
(this.selectedIndex - 1 + this.results.length) % Math.max(1, this.results.length);
|
|
147
|
+
this.render();
|
|
148
|
+
});
|
|
149
|
+
// Update results on keypress
|
|
150
|
+
this.textbox.on('keypress', () => {
|
|
151
|
+
// Defer to next tick to get updated value
|
|
152
|
+
setImmediate(() => {
|
|
153
|
+
const newQuery = this.textbox.getValue() || '';
|
|
154
|
+
if (newQuery !== this.query) {
|
|
155
|
+
this.query = newQuery;
|
|
156
|
+
this.selectedIndex = 0;
|
|
157
|
+
this.updateResults();
|
|
158
|
+
this.render();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
updateResults() {
|
|
164
|
+
if (!this.query) {
|
|
165
|
+
// Show first N files when no query
|
|
166
|
+
this.results = this.allPaths.slice(0, MAX_RESULTS).map((path) => ({ path, score: 0 }));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Fuzzy match all paths
|
|
170
|
+
const scored = [];
|
|
171
|
+
for (const path of this.allPaths) {
|
|
172
|
+
const score = fuzzyScore(this.query, path);
|
|
173
|
+
if (score >= 0) {
|
|
174
|
+
scored.push({ path, score });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Sort by score (descending)
|
|
178
|
+
scored.sort((a, b) => b.score - a.score);
|
|
179
|
+
// Take top results
|
|
180
|
+
this.results = scored.slice(0, MAX_RESULTS);
|
|
181
|
+
}
|
|
182
|
+
render() {
|
|
183
|
+
const lines = [];
|
|
184
|
+
const width = this.box.width - 4;
|
|
185
|
+
// Header
|
|
186
|
+
lines.push('{bold}{cyan-fg}Find File{/cyan-fg}{/bold}');
|
|
187
|
+
lines.push(''); // Space for input
|
|
188
|
+
lines.push('');
|
|
189
|
+
// Results
|
|
190
|
+
if (this.results.length === 0 && this.query) {
|
|
191
|
+
lines.push('{gray-fg}No matches{/gray-fg}');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
for (let i = 0; i < this.results.length; i++) {
|
|
195
|
+
const result = this.results[i];
|
|
196
|
+
const isSelected = i === this.selectedIndex;
|
|
197
|
+
// Truncate path if needed
|
|
198
|
+
let displayPath = result.path;
|
|
199
|
+
const maxLen = width - 4;
|
|
200
|
+
if (displayPath.length > maxLen) {
|
|
201
|
+
displayPath = '…' + displayPath.slice(-(maxLen - 1));
|
|
202
|
+
}
|
|
203
|
+
// Highlight matched characters
|
|
204
|
+
const highlighted = highlightMatch(this.query, displayPath);
|
|
205
|
+
if (isSelected) {
|
|
206
|
+
lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
lines.push(` ${highlighted}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Pad to fill space
|
|
214
|
+
while (lines.length < MAX_RESULTS + 3) {
|
|
215
|
+
lines.push('');
|
|
216
|
+
}
|
|
217
|
+
// Footer
|
|
218
|
+
lines.push('{gray-fg}Enter: select | Esc: cancel | Ctrl+j/k or ↑↓: navigate{/gray-fg}');
|
|
219
|
+
this.box.setContent(lines.join('\n'));
|
|
220
|
+
this.screen.render();
|
|
221
|
+
}
|
|
222
|
+
close() {
|
|
223
|
+
this.textbox.destroy();
|
|
224
|
+
this.box.destroy();
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Focus the modal input.
|
|
228
|
+
*/
|
|
229
|
+
focus() {
|
|
230
|
+
this.textbox.focus();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { formatDate } from '../../utils/formatDate.js';
|
|
2
2
|
import { formatCommitDisplay } from '../../utils/commitFormat.js';
|
|
3
|
-
import {
|
|
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';
|
|
4
15
|
/**
|
|
5
16
|
* Build the list of row items for the compare list view.
|
|
6
17
|
*/
|
|
@@ -15,26 +26,29 @@ export function buildCompareListRows(commits, files, commitsExpanded = true, fil
|
|
|
15
26
|
});
|
|
16
27
|
}
|
|
17
28
|
}
|
|
18
|
-
// Files section
|
|
29
|
+
// Files section with tree view
|
|
19
30
|
if (files.length > 0) {
|
|
20
31
|
if (commits.length > 0) {
|
|
21
32
|
result.push({ type: 'spacer' });
|
|
22
33
|
}
|
|
23
34
|
result.push({ type: 'section-header', sectionType: 'files' });
|
|
24
35
|
if (filesExpanded) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|
|
28
48
|
}
|
|
29
49
|
}
|
|
30
50
|
return result;
|
|
31
51
|
}
|
|
32
|
-
/**
|
|
33
|
-
* Escape blessed tags in content.
|
|
34
|
-
*/
|
|
35
|
-
function escapeContent(content) {
|
|
36
|
-
return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
37
|
-
}
|
|
38
52
|
/**
|
|
39
53
|
* Format a commit row.
|
|
40
54
|
*/
|
|
@@ -45,63 +59,81 @@ function formatCommitRow(commit, isSelected, isFocused, width) {
|
|
|
45
59
|
const baseWidth = 2 + 7 + 4 + dateStr.length + 2;
|
|
46
60
|
const remainingWidth = Math.max(10, width - baseWidth);
|
|
47
61
|
const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
|
|
48
|
-
let line =
|
|
49
|
-
line += `{yellow-fg}${commit.shortHash}{/yellow-fg} `;
|
|
62
|
+
let line = ` ${ANSI_YELLOW}${commit.shortHash}${ANSI_RESET} `;
|
|
50
63
|
if (isHighlighted) {
|
|
51
|
-
line +=
|
|
64
|
+
line += `${ANSI_CYAN}${ANSI_INVERSE}${displayMessage}${ANSI_RESET}`;
|
|
52
65
|
}
|
|
53
66
|
else {
|
|
54
|
-
line +=
|
|
67
|
+
line += displayMessage;
|
|
55
68
|
}
|
|
56
|
-
line += ` {
|
|
69
|
+
line += ` ${ANSI_GRAY}(${dateStr})${ANSI_RESET}`;
|
|
57
70
|
if (displayRefs) {
|
|
58
|
-
line += ` {
|
|
71
|
+
line += ` ${ANSI_GREEN}${displayRefs}${ANSI_RESET}`;
|
|
59
72
|
}
|
|
60
|
-
return line
|
|
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}`;
|
|
61
89
|
}
|
|
62
90
|
/**
|
|
63
|
-
* Format a file row.
|
|
91
|
+
* Format a file row in tree view.
|
|
64
92
|
*/
|
|
65
|
-
function formatFileRow(file, isSelected, isFocused,
|
|
93
|
+
function formatFileRow(file, treeRow, isSelected, isFocused, width) {
|
|
66
94
|
const isHighlighted = isSelected && isFocused;
|
|
67
95
|
const isUncommitted = file.isUncommitted ?? false;
|
|
96
|
+
const prefix = buildTreePrefix(treeRow);
|
|
68
97
|
const statusColors = {
|
|
69
|
-
added:
|
|
70
|
-
modified:
|
|
71
|
-
deleted:
|
|
72
|
-
renamed:
|
|
98
|
+
added: ANSI_GREEN,
|
|
99
|
+
modified: ANSI_YELLOW,
|
|
100
|
+
deleted: ANSI_RED,
|
|
101
|
+
renamed: ANSI_BLUE,
|
|
73
102
|
};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
// File icon based on status
|
|
104
|
+
const statusIcons = {
|
|
105
|
+
added: '+',
|
|
106
|
+
modified: '●',
|
|
107
|
+
deleted: '−',
|
|
108
|
+
renamed: '→',
|
|
79
109
|
};
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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} `;
|
|
91
123
|
if (isHighlighted) {
|
|
92
|
-
line +=
|
|
124
|
+
line += `${ANSI_CYAN}${ANSI_INVERSE}${name}${ANSI_RESET}`;
|
|
93
125
|
}
|
|
94
126
|
else if (isUncommitted) {
|
|
95
|
-
line +=
|
|
127
|
+
line += `${ANSI_MAGENTA}${name}${ANSI_RESET}`;
|
|
96
128
|
}
|
|
97
129
|
else {
|
|
98
|
-
line +=
|
|
130
|
+
line += name;
|
|
99
131
|
}
|
|
100
|
-
line += ` {
|
|
132
|
+
line += ` ${ANSI_GRAY}(${ANSI_GREEN}+${file.additions}${ANSI_RESET} ${ANSI_RED}-${file.deletions}${ANSI_GRAY})${ANSI_RESET}`;
|
|
101
133
|
if (isUncommitted) {
|
|
102
|
-
line +=
|
|
134
|
+
line += ` ${ANSI_MAGENTA}[uncommitted]${ANSI_RESET}`;
|
|
103
135
|
}
|
|
104
|
-
return line
|
|
136
|
+
return `{escape}${line}{/escape}`;
|
|
105
137
|
}
|
|
106
138
|
/**
|
|
107
139
|
* Format the compare list view as blessed-compatible tagged string.
|
|
@@ -121,7 +153,7 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
|
|
|
121
153
|
const isCommits = row.sectionType === 'commits';
|
|
122
154
|
const count = isCommits ? commits.length : files.length;
|
|
123
155
|
const label = isCommits ? 'Commits' : 'Files';
|
|
124
|
-
lines.push(`{
|
|
156
|
+
lines.push(`{escape}${ANSI_CYAN}${ANSI_BOLD}▼ ${label}${ANSI_RESET} ${ANSI_GRAY}(${count})${ANSI_RESET}{/escape}`);
|
|
125
157
|
}
|
|
126
158
|
else if (row.type === 'spacer') {
|
|
127
159
|
lines.push('');
|
|
@@ -130,9 +162,12 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
|
|
|
130
162
|
const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
|
|
131
163
|
lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
|
|
132
164
|
}
|
|
133
|
-
else if (row.type === '
|
|
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) {
|
|
134
169
|
const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
|
|
135
|
-
lines.push(formatFileRow(row.file, isSelected, isFocused, width
|
|
170
|
+
lines.push(formatFileRow(row.file, row.treeRow, isSelected, isFocused, width));
|
|
136
171
|
}
|
|
137
172
|
}
|
|
138
173
|
return lines.join('\n');
|
|
@@ -141,24 +176,11 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
|
|
|
141
176
|
* Get the total number of rows in the compare list view (for scroll calculation).
|
|
142
177
|
*/
|
|
143
178
|
export function getCompareListTotalRows(commits, files, commitsExpanded = true, filesExpanded = true) {
|
|
144
|
-
|
|
145
|
-
if (commits.length > 0) {
|
|
146
|
-
count += 1; // header
|
|
147
|
-
if (commitsExpanded)
|
|
148
|
-
count += commits.length;
|
|
149
|
-
}
|
|
150
|
-
if (files.length > 0) {
|
|
151
|
-
if (commits.length > 0)
|
|
152
|
-
count += 1; // spacer
|
|
153
|
-
count += 1; // header
|
|
154
|
-
if (filesExpanded)
|
|
155
|
-
count += files.length;
|
|
156
|
-
}
|
|
157
|
-
return count;
|
|
179
|
+
return buildCompareListRows(commits, files, commitsExpanded, filesExpanded).length;
|
|
158
180
|
}
|
|
159
181
|
/**
|
|
160
182
|
* Map a row index to a selection.
|
|
161
|
-
* Returns null if the row is a header or
|
|
183
|
+
* Returns null if the row is a header, spacer, or directory.
|
|
162
184
|
*/
|
|
163
185
|
export function getCompareSelectionFromRow(rowIndex, commits, files, commitsExpanded = true, filesExpanded = true) {
|
|
164
186
|
const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { getTheme } from '../../themes.js';
|
|
2
2
|
import { buildDiffDisplayRows, buildHistoryDisplayRows, getDisplayRowsLineNumWidth, wrapDisplayRows, } from '../../utils/displayRows.js';
|
|
3
|
-
import { ansiToBlessed } from '../../utils/ansiToBlessed.js';
|
|
4
3
|
import { truncateAnsi } from '../../utils/ansiTruncate.js';
|
|
4
|
+
// ANSI escape codes for raw terminal output (avoids blessed tag escaping issues)
|
|
5
|
+
const ANSI_RESET = '\x1b[0m';
|
|
6
|
+
const ANSI_BOLD = '\x1b[1m';
|
|
7
|
+
const ANSI_GRAY = '\x1b[90m';
|
|
8
|
+
const ANSI_CYAN = '\x1b[36m';
|
|
9
|
+
const ANSI_YELLOW = '\x1b[33m';
|
|
5
10
|
/**
|
|
6
11
|
* Truncate string to fit within maxWidth, adding ellipsis if needed.
|
|
7
12
|
*/
|
|
@@ -44,7 +49,6 @@ function ansiFg(hex) {
|
|
|
44
49
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
45
50
|
return `\x1b[38;2;${r};${g};${b}m`;
|
|
46
51
|
}
|
|
47
|
-
const ANSI_RESET = '\x1b[0m';
|
|
48
52
|
/**
|
|
49
53
|
* Format a single display row as blessed-compatible tagged string.
|
|
50
54
|
*/
|
|
@@ -58,10 +62,10 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
|
|
|
58
62
|
if (match) {
|
|
59
63
|
const maxPathLen = headerWidth - 6;
|
|
60
64
|
const path = truncate(match[1], maxPathLen);
|
|
61
|
-
return `{
|
|
65
|
+
return `{escape}${ANSI_BOLD}${ANSI_CYAN}\u2500\u2500 ${path} \u2500\u2500${ANSI_RESET}{/escape}`;
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
|
-
return `{
|
|
68
|
+
return `{escape}${ANSI_GRAY}${truncate(content, headerWidth)}${ANSI_RESET}{/escape}`;
|
|
65
69
|
}
|
|
66
70
|
case 'diff-hunk': {
|
|
67
71
|
const match = row.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
|
|
@@ -78,9 +82,9 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
|
|
|
78
82
|
const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
|
|
79
83
|
const contextMaxLen = headerWidth - rangeText.length - 1;
|
|
80
84
|
const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
|
|
81
|
-
return `{
|
|
85
|
+
return `{escape}${ANSI_CYAN}${rangeText}${ANSI_GRAY}${truncatedContext}${ANSI_RESET}{/escape}`;
|
|
82
86
|
}
|
|
83
|
-
return `{
|
|
87
|
+
return `{escape}${ANSI_CYAN}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
|
|
84
88
|
}
|
|
85
89
|
case 'diff-add': {
|
|
86
90
|
const isCont = row.isContinuation;
|
|
@@ -212,23 +216,21 @@ function formatDisplayRow(row, lineNumWidth, contentWidth, headerWidth, theme, w
|
|
|
212
216
|
const lineNum = formatLineNum(row.lineNum, lineNumWidth);
|
|
213
217
|
const prefix = `${lineNum} ${symbol} `;
|
|
214
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
|
|
215
222
|
// Use syntax highlighting if available (not for continuations)
|
|
216
223
|
if (row.highlighted && !isCont) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
: truncateAnsi(row.highlighted, contentWidth);
|
|
220
|
-
const highlightedContent = ansiToBlessed(truncatedHighlight);
|
|
221
|
-
return `{gray-fg}${prefix}{/gray-fg}${highlightedContent}`;
|
|
224
|
+
const content = wrapMode ? row.highlighted : truncateAnsi(row.highlighted, contentWidth);
|
|
225
|
+
return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
|
|
222
226
|
}
|
|
223
|
-
const content = wrapMode
|
|
224
|
-
|
|
225
|
-
: escapeContent(truncate(rawContent, contentWidth));
|
|
226
|
-
return `{gray-fg}${prefix}{/gray-fg}${content}`;
|
|
227
|
+
const content = wrapMode ? rawContent : truncate(rawContent, contentWidth);
|
|
228
|
+
return `{escape}${prefixAnsi}${content}${ANSI_RESET}{/escape}`;
|
|
227
229
|
}
|
|
228
230
|
case 'commit-header':
|
|
229
|
-
return `{
|
|
231
|
+
return `{escape}${ANSI_YELLOW}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
|
|
230
232
|
case 'commit-message':
|
|
231
|
-
return
|
|
233
|
+
return `{escape}${truncate(row.content, headerWidth)}${ANSI_RESET}{/escape}`;
|
|
232
234
|
case 'spacer':
|
|
233
235
|
return '';
|
|
234
236
|
}
|