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
|
@@ -17,15 +17,14 @@ export class CommandClient {
|
|
|
17
17
|
return new Promise((resolve, reject) => {
|
|
18
18
|
const socket = net.createConnection(this.socketPath);
|
|
19
19
|
let buffer = '';
|
|
20
|
-
|
|
21
|
-
const cleanup = () => {
|
|
22
|
-
clearTimeout(timeoutId);
|
|
23
|
-
socket.destroy();
|
|
24
|
-
};
|
|
25
|
-
timeoutId = setTimeout(() => {
|
|
20
|
+
const timeoutId = setTimeout(() => {
|
|
26
21
|
cleanup();
|
|
27
22
|
reject(new Error(`Command timed out after ${this.timeout}ms`));
|
|
28
23
|
}, this.timeout);
|
|
24
|
+
function cleanup() {
|
|
25
|
+
clearTimeout(timeoutId);
|
|
26
|
+
socket.destroy();
|
|
27
|
+
}
|
|
29
28
|
socket.on('connect', () => {
|
|
30
29
|
socket.write(JSON.stringify(command) + '\n');
|
|
31
30
|
});
|
|
@@ -38,7 +37,7 @@ export class CommandClient {
|
|
|
38
37
|
try {
|
|
39
38
|
resolve(JSON.parse(json));
|
|
40
39
|
}
|
|
41
|
-
catch
|
|
40
|
+
catch {
|
|
42
41
|
reject(new Error(`Invalid JSON response: ${json}`));
|
|
43
42
|
}
|
|
44
43
|
}
|
package/dist/state/UIState.js
CHANGED
|
@@ -13,11 +13,13 @@ const DEFAULT_STATE = {
|
|
|
13
13
|
compareSelectedIndex: 0,
|
|
14
14
|
includeUncommitted: false,
|
|
15
15
|
explorerSelectedIndex: 0,
|
|
16
|
+
selectedHunkIndex: 0,
|
|
16
17
|
wrapMode: false,
|
|
17
18
|
autoTabEnabled: false,
|
|
18
19
|
mouseEnabled: true,
|
|
19
20
|
hideHiddenFiles: true,
|
|
20
21
|
hideGitignored: true,
|
|
22
|
+
flatViewMode: false,
|
|
21
23
|
splitRatio: 0.4,
|
|
22
24
|
activeModal: null,
|
|
23
25
|
pendingDiscard: null,
|
|
@@ -110,6 +112,22 @@ export class UIState extends EventEmitter {
|
|
|
110
112
|
setExplorerSelectedIndex(index) {
|
|
111
113
|
this.update({ explorerSelectedIndex: Math.max(0, index) });
|
|
112
114
|
}
|
|
115
|
+
// Hunk selection
|
|
116
|
+
setSelectedHunkIndex(index) {
|
|
117
|
+
this.update({ selectedHunkIndex: Math.max(0, index) });
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Silently clamp selectedHunkIndex to valid range without emitting events.
|
|
121
|
+
* Called during render to sync state with actual hunk count.
|
|
122
|
+
*/
|
|
123
|
+
clampSelectedHunkIndex(hunkCount) {
|
|
124
|
+
if (hunkCount <= 0) {
|
|
125
|
+
this._state.selectedHunkIndex = 0;
|
|
126
|
+
}
|
|
127
|
+
else if (this._state.selectedHunkIndex >= hunkCount) {
|
|
128
|
+
this._state.selectedHunkIndex = hunkCount - 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
113
131
|
// Display toggles
|
|
114
132
|
toggleWrapMode() {
|
|
115
133
|
this.update({ wrapMode: !this._state.wrapMode, diffScrollOffset: 0 });
|
|
@@ -126,6 +144,9 @@ export class UIState extends EventEmitter {
|
|
|
126
144
|
toggleHideGitignored() {
|
|
127
145
|
this.update({ hideGitignored: !this._state.hideGitignored });
|
|
128
146
|
}
|
|
147
|
+
toggleFlatViewMode() {
|
|
148
|
+
this.update({ flatViewMode: !this._state.flatViewMode, fileListScrollOffset: 0 });
|
|
149
|
+
}
|
|
129
150
|
// Split ratio
|
|
130
151
|
adjustSplitRatio(delta) {
|
|
131
152
|
const newRatio = Math.min(0.85, Math.max(0.15, this._state.splitRatio + delta));
|
|
@@ -189,6 +210,7 @@ export class UIState extends EventEmitter {
|
|
|
189
210
|
explorerSelectedIndex: 0,
|
|
190
211
|
explorerScrollOffset: 0,
|
|
191
212
|
explorerFileScrollOffset: 0,
|
|
213
|
+
selectedHunkIndex: 0,
|
|
192
214
|
};
|
|
193
215
|
this.emit('change', this._state);
|
|
194
216
|
}
|
package/dist/ui/PaneRenderers.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { formatFileList } from './widgets/FileList.js';
|
|
2
|
+
import { formatFlatFileList } from './widgets/FlatFileList.js';
|
|
2
3
|
import { formatHistoryView } from './widgets/HistoryView.js';
|
|
3
4
|
import { formatCompareListView } from './widgets/CompareListView.js';
|
|
4
5
|
import { formatExplorerView } from './widgets/ExplorerView.js';
|
|
5
|
-
import { formatDiff, formatHistoryDiff } from './widgets/DiffView.js';
|
|
6
|
+
import { formatDiff, formatCombinedDiff, formatHistoryDiff } from './widgets/DiffView.js';
|
|
6
7
|
import { formatCommitPanel } from './widgets/CommitPanel.js';
|
|
7
8
|
import { formatExplorerContent } from './widgets/ExplorerContent.js';
|
|
8
9
|
/**
|
|
9
10
|
* Render the top pane content for the current tab.
|
|
10
11
|
*/
|
|
11
|
-
export function renderTopPane(state, files, historyCommits, compareDiff, compareSelection, explorerState, width, topPaneHeight) {
|
|
12
|
+
export function renderTopPane(state, files, historyCommits, compareDiff, compareSelection, explorerState, width, topPaneHeight, hunkCounts, flatFiles) {
|
|
12
13
|
if (state.bottomTab === 'history') {
|
|
13
14
|
return formatHistoryView(historyCommits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, topPaneHeight);
|
|
14
15
|
}
|
|
@@ -21,36 +22,55 @@ export function renderTopPane(state, files, historyCommits, compareDiff, compare
|
|
|
21
22
|
const displayRows = explorerState?.displayRows ?? [];
|
|
22
23
|
return formatExplorerView(displayRows, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
|
|
23
24
|
}
|
|
24
|
-
// Default: diff tab file list
|
|
25
|
-
|
|
25
|
+
// Default: diff/commit tab file list
|
|
26
|
+
if (state.flatViewMode && flatFiles) {
|
|
27
|
+
return formatFlatFileList(flatFiles, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, topPaneHeight);
|
|
28
|
+
}
|
|
29
|
+
return formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, topPaneHeight, hunkCounts);
|
|
26
30
|
}
|
|
27
31
|
/**
|
|
28
32
|
* Render the bottom pane content for the current tab.
|
|
29
33
|
*/
|
|
30
|
-
export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight) {
|
|
34
|
+
export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight, selectedHunkIndex, isFileStaged, combinedFileDiffs) {
|
|
31
35
|
if (state.bottomTab === 'commit') {
|
|
32
36
|
const content = formatCommitPanel(commitFlowState, stagedCount, width);
|
|
33
|
-
return { content, totalRows: 0 };
|
|
37
|
+
return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
|
|
34
38
|
}
|
|
35
39
|
if (state.bottomTab === 'history') {
|
|
36
40
|
const selectedCommit = historyState?.selectedCommit ?? null;
|
|
37
41
|
const commitDiff = historyState?.commitDiff ?? null;
|
|
38
42
|
const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
|
|
39
|
-
return { content, totalRows };
|
|
43
|
+
return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
|
|
40
44
|
}
|
|
41
45
|
if (state.bottomTab === 'compare') {
|
|
42
46
|
const compareDiff = compareSelectionState?.diff ?? null;
|
|
43
47
|
if (compareDiff) {
|
|
44
48
|
const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
|
|
45
|
-
return { content, totalRows };
|
|
49
|
+
return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
|
|
46
50
|
}
|
|
47
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
content: '{gray-fg}Select a commit or file to view diff{/gray-fg}',
|
|
53
|
+
totalRows: 0,
|
|
54
|
+
hunkCount: 0,
|
|
55
|
+
hunkBoundaries: [],
|
|
56
|
+
};
|
|
48
57
|
}
|
|
49
58
|
if (state.bottomTab === 'explorer') {
|
|
50
59
|
const content = formatExplorerContent(explorerSelectedFile?.path ?? null, explorerSelectedFile?.content ?? null, width, state.explorerFileScrollOffset, bottomPaneHeight, explorerSelectedFile?.truncated ?? false, state.wrapMode);
|
|
51
|
-
return { content, totalRows: 0 };
|
|
60
|
+
return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
|
|
61
|
+
}
|
|
62
|
+
// Flat mode: show combined unstaged+staged diff with section headers
|
|
63
|
+
if (state.flatViewMode && combinedFileDiffs) {
|
|
64
|
+
const result = formatCombinedDiff(combinedFileDiffs.unstaged, combinedFileDiffs.staged, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode, selectedHunkIndex);
|
|
65
|
+
return {
|
|
66
|
+
content: result.content,
|
|
67
|
+
totalRows: result.totalRows,
|
|
68
|
+
hunkCount: result.hunkCount,
|
|
69
|
+
hunkBoundaries: result.hunkBoundaries,
|
|
70
|
+
hunkMapping: result.hunkMapping,
|
|
71
|
+
};
|
|
52
72
|
}
|
|
53
|
-
// Default: diff tab
|
|
54
|
-
const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode);
|
|
55
|
-
return { content, totalRows };
|
|
73
|
+
// Default: diff tab — pass selectedHunkIndex for hunk gutter
|
|
74
|
+
const { content, totalRows, hunkCount, hunkBoundaries } = formatDiff(diff, width, state.diffScrollOffset, bottomPaneHeight, currentTheme, state.wrapMode, selectedHunkIndex, isFileStaged);
|
|
75
|
+
return { content, totalRows, hunkCount, hunkBoundaries };
|
|
56
76
|
}
|
|
@@ -1,56 +1,19 @@
|
|
|
1
1
|
import blessed from 'neo-blessed';
|
|
2
|
+
import { Fzf } from 'fzf';
|
|
2
3
|
const MAX_RESULTS = 15;
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* Highlight matched characters in a display path using fzf position data.
|
|
6
|
+
* The positions set refers to indices in the original full path,
|
|
7
|
+
* so we need an offset when the display path is truncated.
|
|
6
8
|
*/
|
|
7
|
-
function
|
|
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();
|
|
9
|
+
function highlightMatch(displayPath, positions, offset) {
|
|
45
10
|
let result = '';
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
result += `{yellow-fg}${path[i]}{/yellow-fg}`;
|
|
50
|
-
queryIndex++;
|
|
11
|
+
for (let i = 0; i < displayPath.length; i++) {
|
|
12
|
+
if (positions.has(i + offset)) {
|
|
13
|
+
result += `{yellow-fg}${displayPath[i]}{/yellow-fg}`;
|
|
51
14
|
}
|
|
52
15
|
else {
|
|
53
|
-
result +=
|
|
16
|
+
result += displayPath[i];
|
|
54
17
|
}
|
|
55
18
|
}
|
|
56
19
|
return result;
|
|
@@ -62,6 +25,7 @@ export class FileFinder {
|
|
|
62
25
|
box;
|
|
63
26
|
textbox;
|
|
64
27
|
screen;
|
|
28
|
+
fzf;
|
|
65
29
|
allPaths;
|
|
66
30
|
results = [];
|
|
67
31
|
selectedIndex = 0;
|
|
@@ -71,6 +35,7 @@ export class FileFinder {
|
|
|
71
35
|
constructor(screen, allPaths, onSelect, onCancel) {
|
|
72
36
|
this.screen = screen;
|
|
73
37
|
this.allPaths = allPaths;
|
|
38
|
+
this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, forward: false });
|
|
74
39
|
this.onSelect = onSelect;
|
|
75
40
|
this.onCancel = onCancel;
|
|
76
41
|
// Create modal box
|
|
@@ -123,7 +88,7 @@ export class FileFinder {
|
|
|
123
88
|
if (this.results.length > 0) {
|
|
124
89
|
const selected = this.results[this.selectedIndex];
|
|
125
90
|
this.close();
|
|
126
|
-
this.onSelect(selected.
|
|
91
|
+
this.onSelect(selected.item);
|
|
127
92
|
}
|
|
128
93
|
});
|
|
129
94
|
// Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
|
|
@@ -162,22 +127,13 @@ export class FileFinder {
|
|
|
162
127
|
}
|
|
163
128
|
updateResults() {
|
|
164
129
|
if (!this.query) {
|
|
165
|
-
//
|
|
166
|
-
this.results = this.allPaths
|
|
130
|
+
// fzf returns nothing for empty query, show first N files
|
|
131
|
+
this.results = this.allPaths
|
|
132
|
+
.slice(0, MAX_RESULTS)
|
|
133
|
+
.map((item) => ({ item, positions: new Set(), start: 0, end: 0, score: 0 }));
|
|
167
134
|
return;
|
|
168
135
|
}
|
|
169
|
-
|
|
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);
|
|
136
|
+
this.results = this.fzf.find(this.query);
|
|
181
137
|
}
|
|
182
138
|
render() {
|
|
183
139
|
const lines = [];
|
|
@@ -195,13 +151,18 @@ export class FileFinder {
|
|
|
195
151
|
const result = this.results[i];
|
|
196
152
|
const isSelected = i === this.selectedIndex;
|
|
197
153
|
// Truncate path if needed
|
|
198
|
-
|
|
154
|
+
const fullPath = result.item;
|
|
199
155
|
const maxLen = width - 4;
|
|
156
|
+
let displayPath = fullPath;
|
|
157
|
+
let offset = 0;
|
|
200
158
|
if (displayPath.length > maxLen) {
|
|
201
|
-
|
|
159
|
+
offset = displayPath.length - (maxLen - 1);
|
|
160
|
+
displayPath = '…' + displayPath.slice(offset);
|
|
161
|
+
// Account for the '…' prefix: display index 0 is '…', actual content starts at 1
|
|
162
|
+
offset = offset - 1;
|
|
202
163
|
}
|
|
203
|
-
// Highlight matched characters
|
|
204
|
-
const highlighted = highlightMatch(
|
|
164
|
+
// Highlight matched characters using fzf positions
|
|
165
|
+
const highlighted = highlightMatch(displayPath, result.positions, offset);
|
|
205
166
|
if (isSelected) {
|
|
206
167
|
lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
|
|
207
168
|
}
|
|
@@ -45,6 +45,7 @@ const hotkeyGroups = [
|
|
|
45
45
|
{
|
|
46
46
|
title: 'Toggles',
|
|
47
47
|
entries: [
|
|
48
|
+
{ key: 'h', description: 'Flat file view' },
|
|
48
49
|
{ key: 'm', description: 'Mouse mode' },
|
|
49
50
|
{ key: 'w', description: 'Wrap mode' },
|
|
50
51
|
{ key: 'f', description: 'Follow mode' },
|
|
@@ -57,6 +58,9 @@ const hotkeyGroups = [
|
|
|
57
58
|
entries: [
|
|
58
59
|
{ key: 'Enter', description: 'Enter directory' },
|
|
59
60
|
{ key: 'Backspace', description: 'Go up' },
|
|
61
|
+
{ key: '/', description: 'Find file' },
|
|
62
|
+
{ key: 'Ctrl+P', description: 'Find file (any tab)' },
|
|
63
|
+
{ key: 'g', description: 'Show changes only' },
|
|
60
64
|
],
|
|
61
65
|
},
|
|
62
66
|
{
|
|
@@ -67,8 +71,13 @@ const hotkeyGroups = [
|
|
|
67
71
|
],
|
|
68
72
|
},
|
|
69
73
|
{
|
|
70
|
-
title: 'Diff',
|
|
71
|
-
entries: [
|
|
74
|
+
title: 'Diff (pane focus)',
|
|
75
|
+
entries: [
|
|
76
|
+
{ key: 'n', description: 'Next hunk' },
|
|
77
|
+
{ key: 'N', description: 'Previous hunk' },
|
|
78
|
+
{ key: 's', description: 'Toggle hunk staged/unstaged' },
|
|
79
|
+
{ key: 'd', description: 'Discard changes' },
|
|
80
|
+
],
|
|
72
81
|
},
|
|
73
82
|
];
|
|
74
83
|
/**
|
|
@@ -186,7 +195,7 @@ export class HotkeysModal {
|
|
|
186
195
|
this.box.setContent(lines.join('\n'));
|
|
187
196
|
this.screen.render();
|
|
188
197
|
}
|
|
189
|
-
renderGroups(groups,
|
|
198
|
+
renderGroups(groups, _colWidth) {
|
|
190
199
|
const lines = [];
|
|
191
200
|
for (const group of groups) {
|
|
192
201
|
lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import blessed from 'neo-blessed';
|
|
2
|
-
import { themes, themeOrder
|
|
2
|
+
import { themes, themeOrder } from '../../themes.js';
|
|
3
3
|
/**
|
|
4
4
|
* ThemePicker modal for selecting diff themes.
|
|
5
5
|
*/
|
|
@@ -85,7 +85,6 @@ export class ThemePicker {
|
|
|
85
85
|
// Preview section
|
|
86
86
|
lines.push('');
|
|
87
87
|
lines.push('{gray-fg}Preview:{/gray-fg}');
|
|
88
|
-
const previewTheme = getTheme(themeOrder[this.selectedIndex]);
|
|
89
88
|
// Simple preview - just show add/del colors
|
|
90
89
|
lines.push(` {green-fg}+ added line{/green-fg}`);
|
|
91
90
|
lines.push(` {red-fg}- deleted line{/red-fg}`);
|
|
@@ -11,7 +11,7 @@ export function formatCommitPanel(state, stagedCount, width) {
|
|
|
11
11
|
lines.push(title);
|
|
12
12
|
lines.push('');
|
|
13
13
|
// Message input area
|
|
14
|
-
const borderChar =
|
|
14
|
+
const borderChar = '\u2502';
|
|
15
15
|
const borderColor = state.inputFocused ? 'cyan' : 'gray';
|
|
16
16
|
// Top border
|
|
17
17
|
const innerWidth = Math.max(20, width - 6);
|
|
@@ -135,6 +135,47 @@ function formatFileRow(file, treeRow, isSelected, isFocused, width) {
|
|
|
135
135
|
}
|
|
136
136
|
return `{escape}${line}{/escape}`;
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Check if a row is currently selected.
|
|
140
|
+
*/
|
|
141
|
+
function isRowSelected(row, selectedItem) {
|
|
142
|
+
if (!selectedItem)
|
|
143
|
+
return false;
|
|
144
|
+
if (row.type === 'commit' && row.commitIndex !== undefined) {
|
|
145
|
+
return selectedItem.type === 'commit' && selectedItem.index === row.commitIndex;
|
|
146
|
+
}
|
|
147
|
+
if (row.type === 'file' && row.fileIndex !== undefined) {
|
|
148
|
+
return selectedItem.type === 'file' && selectedItem.index === row.fileIndex;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Format a section header line (e.g. "▼ Commits (5)").
|
|
154
|
+
*/
|
|
155
|
+
function formatSectionHeader(label, count) {
|
|
156
|
+
return `{escape}${ANSI_CYAN}${ANSI_BOLD}▼ ${label}${ANSI_RESET} ${ANSI_GRAY}(${count})${ANSI_RESET}{/escape}`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Format a single compare list row, returning null for unrenderable rows.
|
|
160
|
+
*/
|
|
161
|
+
function formatCompareRow(row, selectedItem, isFocused, commits, files, width) {
|
|
162
|
+
if (row.type === 'section-header') {
|
|
163
|
+
const isCommits = row.sectionType === 'commits';
|
|
164
|
+
return formatSectionHeader(isCommits ? 'Commits' : 'Files', isCommits ? commits.length : files.length);
|
|
165
|
+
}
|
|
166
|
+
if (row.type === 'spacer')
|
|
167
|
+
return '';
|
|
168
|
+
if (row.type === 'directory' && row.treeRow)
|
|
169
|
+
return formatDirectoryRow(row.treeRow, width);
|
|
170
|
+
const selected = isRowSelected(row, selectedItem);
|
|
171
|
+
if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
|
|
172
|
+
return formatCommitRow(row.commit, selected, isFocused, width);
|
|
173
|
+
}
|
|
174
|
+
if (row.type === 'file' && row.file && row.fileIndex !== undefined && row.treeRow) {
|
|
175
|
+
return formatFileRow(row.file, row.treeRow, selected, isFocused, width);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
138
179
|
/**
|
|
139
180
|
* Format the compare list view as blessed-compatible tagged string.
|
|
140
181
|
*/
|
|
@@ -147,29 +188,9 @@ export function formatCompareListView(commits, files, selectedItem, isFocused, w
|
|
|
147
188
|
const visibleRows = maxHeight
|
|
148
189
|
? rows.slice(scrollOffset, scrollOffset + maxHeight)
|
|
149
190
|
: rows.slice(scrollOffset);
|
|
150
|
-
const lines =
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const isCommits = row.sectionType === 'commits';
|
|
154
|
-
const count = isCommits ? commits.length : files.length;
|
|
155
|
-
const label = isCommits ? 'Commits' : 'Files';
|
|
156
|
-
lines.push(`{escape}${ANSI_CYAN}${ANSI_BOLD}▼ ${label}${ANSI_RESET} ${ANSI_GRAY}(${count})${ANSI_RESET}{/escape}`);
|
|
157
|
-
}
|
|
158
|
-
else if (row.type === 'spacer') {
|
|
159
|
-
lines.push('');
|
|
160
|
-
}
|
|
161
|
-
else if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
|
|
162
|
-
const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
|
|
163
|
-
lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
|
|
164
|
-
}
|
|
165
|
-
else if (row.type === 'directory' && row.treeRow) {
|
|
166
|
-
lines.push(formatDirectoryRow(row.treeRow, width));
|
|
167
|
-
}
|
|
168
|
-
else if (row.type === 'file' && row.file && row.fileIndex !== undefined && row.treeRow) {
|
|
169
|
-
const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
|
|
170
|
-
lines.push(formatFileRow(row.file, row.treeRow, isSelected, isFocused, width));
|
|
171
|
-
}
|
|
172
|
-
}
|
|
191
|
+
const lines = visibleRows
|
|
192
|
+
.map((row) => formatCompareRow(row, selectedItem, isFocused, commits, files, width))
|
|
193
|
+
.filter((line) => line !== null);
|
|
173
194
|
return lines.join('\n');
|
|
174
195
|
}
|
|
175
196
|
/**
|