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,209 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
const hotkeyGroups = [
|
|
3
|
+
{
|
|
4
|
+
title: 'Navigation',
|
|
5
|
+
entries: [
|
|
6
|
+
{ key: 'j/k', description: 'Move up/down' },
|
|
7
|
+
{ key: 'Tab', description: 'Toggle pane focus' },
|
|
8
|
+
],
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
title: 'Staging',
|
|
12
|
+
entries: [
|
|
13
|
+
{ key: 's', description: 'Stage file' },
|
|
14
|
+
{ key: 'U', description: 'Unstage file' },
|
|
15
|
+
{ key: 'A', description: 'Stage all' },
|
|
16
|
+
{ key: 'Z', description: 'Unstage all' },
|
|
17
|
+
{ key: 'Space', description: 'Toggle stage' },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
title: 'Actions',
|
|
22
|
+
entries: [
|
|
23
|
+
{ key: 'c', description: 'Commit panel' },
|
|
24
|
+
{ key: 'r', description: 'Refresh' },
|
|
25
|
+
{ key: 'q', description: 'Quit' },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
title: 'Resize',
|
|
30
|
+
entries: [
|
|
31
|
+
{ key: '-', description: 'Shrink top pane' },
|
|
32
|
+
{ key: '+', description: 'Grow top pane' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
title: 'Tabs',
|
|
37
|
+
entries: [
|
|
38
|
+
{ key: '1', description: 'Diff view' },
|
|
39
|
+
{ key: '2', description: 'Commit panel' },
|
|
40
|
+
{ key: '3', description: 'History view' },
|
|
41
|
+
{ key: '4', description: 'Compare view' },
|
|
42
|
+
{ key: '5', description: 'Explorer view' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
title: 'Toggles',
|
|
47
|
+
entries: [
|
|
48
|
+
{ key: 'm', description: 'Mouse mode' },
|
|
49
|
+
{ key: 'w', description: 'Wrap mode' },
|
|
50
|
+
{ key: 'f', description: 'Follow mode' },
|
|
51
|
+
{ key: 't', description: 'Theme picker' },
|
|
52
|
+
{ key: '?', description: 'This help' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
title: 'Explorer',
|
|
57
|
+
entries: [
|
|
58
|
+
{ key: 'Enter', description: 'Enter directory' },
|
|
59
|
+
{ key: 'Backspace', description: 'Go up' },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
title: 'Compare',
|
|
64
|
+
entries: [
|
|
65
|
+
{ key: 'b', description: 'Base branch picker' },
|
|
66
|
+
{ key: 'u', description: 'Toggle uncommitted' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
title: 'Diff',
|
|
71
|
+
entries: [{ key: 'd', description: 'Discard changes' }],
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* HotkeysModal shows available keyboard shortcuts.
|
|
76
|
+
*/
|
|
77
|
+
export class HotkeysModal {
|
|
78
|
+
box;
|
|
79
|
+
screen;
|
|
80
|
+
onClose;
|
|
81
|
+
constructor(screen, onClose) {
|
|
82
|
+
this.screen = screen;
|
|
83
|
+
this.onClose = onClose;
|
|
84
|
+
// Calculate modal dimensions
|
|
85
|
+
const screenWidth = screen.width;
|
|
86
|
+
const screenHeight = screen.height;
|
|
87
|
+
// Determine layout based on screen width
|
|
88
|
+
const useTwoColumns = screenWidth >= 90;
|
|
89
|
+
const width = useTwoColumns ? Math.min(80, screenWidth - 4) : Math.min(42, screenWidth - 4);
|
|
90
|
+
const height = Math.min(this.calculateHeight(useTwoColumns), screenHeight - 4);
|
|
91
|
+
this.box = blessed.box({
|
|
92
|
+
parent: screen,
|
|
93
|
+
top: 'center',
|
|
94
|
+
left: 'center',
|
|
95
|
+
width,
|
|
96
|
+
height,
|
|
97
|
+
border: {
|
|
98
|
+
type: 'line',
|
|
99
|
+
},
|
|
100
|
+
style: {
|
|
101
|
+
border: {
|
|
102
|
+
fg: 'cyan',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
tags: true,
|
|
106
|
+
keys: true,
|
|
107
|
+
scrollable: true,
|
|
108
|
+
alwaysScroll: true,
|
|
109
|
+
});
|
|
110
|
+
// Setup key handlers
|
|
111
|
+
this.setupKeyHandlers();
|
|
112
|
+
// Render content
|
|
113
|
+
this.render(useTwoColumns, width);
|
|
114
|
+
}
|
|
115
|
+
calculateHeight(useTwoColumns) {
|
|
116
|
+
if (useTwoColumns) {
|
|
117
|
+
const midpoint = Math.ceil(hotkeyGroups.length / 2);
|
|
118
|
+
const leftGroups = hotkeyGroups.slice(0, midpoint);
|
|
119
|
+
const rightGroups = hotkeyGroups.slice(midpoint);
|
|
120
|
+
const leftLines = leftGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
|
|
121
|
+
const rightLines = rightGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
|
|
122
|
+
return Math.max(leftLines, rightLines) + 5;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
return hotkeyGroups.reduce((sum, g) => sum + g.entries.length + 2, 0) + 5;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setupKeyHandlers() {
|
|
129
|
+
this.box.key(['escape', 'enter', '?', 'q'], () => {
|
|
130
|
+
this.close();
|
|
131
|
+
this.onClose();
|
|
132
|
+
});
|
|
133
|
+
// Close on click anywhere
|
|
134
|
+
this.box.on('click', () => {
|
|
135
|
+
this.close();
|
|
136
|
+
this.onClose();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Calculate the visible width of a string (excluding blessed tags).
|
|
141
|
+
*/
|
|
142
|
+
visibleWidth(str) {
|
|
143
|
+
return str.replace(/\{[^}]+\}/g, '').length;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Pad a string with blessed tags to a visible width.
|
|
147
|
+
*/
|
|
148
|
+
padToVisible(str, targetWidth) {
|
|
149
|
+
const visible = this.visibleWidth(str);
|
|
150
|
+
const padding = Math.max(0, targetWidth - visible);
|
|
151
|
+
return str + ' '.repeat(padding);
|
|
152
|
+
}
|
|
153
|
+
render(useTwoColumns, width) {
|
|
154
|
+
const lines = [];
|
|
155
|
+
// Header
|
|
156
|
+
lines.push('{bold}{cyan-fg} Keyboard Shortcuts{/cyan-fg}{/bold}');
|
|
157
|
+
lines.push('');
|
|
158
|
+
if (useTwoColumns) {
|
|
159
|
+
const midpoint = Math.ceil(hotkeyGroups.length / 2);
|
|
160
|
+
const leftGroups = hotkeyGroups.slice(0, midpoint);
|
|
161
|
+
const rightGroups = hotkeyGroups.slice(midpoint);
|
|
162
|
+
const colWidth = Math.floor((width - 6) / 2);
|
|
163
|
+
// Render side by side
|
|
164
|
+
const leftLines = this.renderGroups(leftGroups, colWidth);
|
|
165
|
+
const rightLines = this.renderGroups(rightGroups, colWidth);
|
|
166
|
+
const maxLines = Math.max(leftLines.length, rightLines.length);
|
|
167
|
+
for (let i = 0; i < maxLines; i++) {
|
|
168
|
+
const left = this.padToVisible(leftLines[i] || '', colWidth);
|
|
169
|
+
const right = rightLines[i] || '';
|
|
170
|
+
lines.push(left + ' ' + right);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// Single column
|
|
175
|
+
for (const group of hotkeyGroups) {
|
|
176
|
+
lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
|
|
177
|
+
for (const entry of group.entries) {
|
|
178
|
+
lines.push(` {cyan-fg}${entry.key.padEnd(10)}{/cyan-fg} ${entry.description}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push('');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Footer
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push('{gray-fg}Press Esc, Enter, or ? to close{/gray-fg}');
|
|
186
|
+
this.box.setContent(lines.join('\n'));
|
|
187
|
+
this.screen.render();
|
|
188
|
+
}
|
|
189
|
+
renderGroups(groups, colWidth) {
|
|
190
|
+
const lines = [];
|
|
191
|
+
for (const group of groups) {
|
|
192
|
+
lines.push(`{bold}{gray-fg}${group.title}{/gray-fg}{/bold}`);
|
|
193
|
+
for (const entry of group.entries) {
|
|
194
|
+
lines.push(` {cyan-fg}${entry.key.padEnd(10)}{/cyan-fg} ${entry.description}`);
|
|
195
|
+
}
|
|
196
|
+
lines.push('');
|
|
197
|
+
}
|
|
198
|
+
return lines;
|
|
199
|
+
}
|
|
200
|
+
close() {
|
|
201
|
+
this.box.destroy();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Focus the modal.
|
|
205
|
+
*/
|
|
206
|
+
focus() {
|
|
207
|
+
this.box.focus();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
import { themes, themeOrder, getTheme } from '../../themes.js';
|
|
3
|
+
/**
|
|
4
|
+
* ThemePicker modal for selecting diff themes.
|
|
5
|
+
*/
|
|
6
|
+
export class ThemePicker {
|
|
7
|
+
box;
|
|
8
|
+
screen;
|
|
9
|
+
selectedIndex;
|
|
10
|
+
currentTheme;
|
|
11
|
+
onSelect;
|
|
12
|
+
onCancel;
|
|
13
|
+
constructor(screen, currentTheme, onSelect, onCancel) {
|
|
14
|
+
this.screen = screen;
|
|
15
|
+
this.currentTheme = currentTheme;
|
|
16
|
+
this.onSelect = onSelect;
|
|
17
|
+
this.onCancel = onCancel;
|
|
18
|
+
// Find current theme index
|
|
19
|
+
this.selectedIndex = themeOrder.indexOf(currentTheme);
|
|
20
|
+
if (this.selectedIndex < 0)
|
|
21
|
+
this.selectedIndex = 0;
|
|
22
|
+
// Create modal box
|
|
23
|
+
const width = 50;
|
|
24
|
+
const height = themeOrder.length + 12; // themes + header + preview + footer + borders + padding
|
|
25
|
+
this.box = blessed.box({
|
|
26
|
+
parent: screen,
|
|
27
|
+
top: 'center',
|
|
28
|
+
left: 'center',
|
|
29
|
+
width,
|
|
30
|
+
height,
|
|
31
|
+
border: {
|
|
32
|
+
type: 'line',
|
|
33
|
+
},
|
|
34
|
+
style: {
|
|
35
|
+
border: {
|
|
36
|
+
fg: 'cyan',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
tags: true,
|
|
40
|
+
keys: true,
|
|
41
|
+
});
|
|
42
|
+
// Setup key handlers
|
|
43
|
+
this.setupKeyHandlers();
|
|
44
|
+
// Initial render
|
|
45
|
+
this.render();
|
|
46
|
+
}
|
|
47
|
+
setupKeyHandlers() {
|
|
48
|
+
this.box.key(['escape', 'q'], () => {
|
|
49
|
+
this.close();
|
|
50
|
+
this.onCancel();
|
|
51
|
+
});
|
|
52
|
+
this.box.key(['enter', 'space'], () => {
|
|
53
|
+
const selected = themeOrder[this.selectedIndex];
|
|
54
|
+
this.close();
|
|
55
|
+
this.onSelect(selected);
|
|
56
|
+
});
|
|
57
|
+
this.box.key(['up', 'k'], () => {
|
|
58
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
59
|
+
this.render();
|
|
60
|
+
});
|
|
61
|
+
this.box.key(['down', 'j'], () => {
|
|
62
|
+
this.selectedIndex = Math.min(themeOrder.length - 1, this.selectedIndex + 1);
|
|
63
|
+
this.render();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
render() {
|
|
67
|
+
const lines = [];
|
|
68
|
+
// Header
|
|
69
|
+
lines.push('{bold}{cyan-fg} Select Theme{/cyan-fg}{/bold}');
|
|
70
|
+
lines.push('');
|
|
71
|
+
// Theme list
|
|
72
|
+
for (let i = 0; i < themeOrder.length; i++) {
|
|
73
|
+
const themeName = themeOrder[i];
|
|
74
|
+
const theme = themes[themeName];
|
|
75
|
+
const isSelected = i === this.selectedIndex;
|
|
76
|
+
const isCurrent = themeName === this.currentTheme;
|
|
77
|
+
let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
|
|
78
|
+
line += theme.displayName;
|
|
79
|
+
if (isSelected)
|
|
80
|
+
line += '{/bold}{/cyan-fg}';
|
|
81
|
+
if (isCurrent)
|
|
82
|
+
line += ' {gray-fg}(current){/gray-fg}';
|
|
83
|
+
lines.push(line);
|
|
84
|
+
}
|
|
85
|
+
// Preview section
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push('{gray-fg}Preview:{/gray-fg}');
|
|
88
|
+
const previewTheme = getTheme(themeOrder[this.selectedIndex]);
|
|
89
|
+
// Simple preview - just show add/del colors
|
|
90
|
+
lines.push(` {green-fg}+ added line{/green-fg}`);
|
|
91
|
+
lines.push(` {red-fg}- deleted line{/red-fg}`);
|
|
92
|
+
// Footer
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
|
|
95
|
+
this.box.setContent(lines.join('\n'));
|
|
96
|
+
this.screen.render();
|
|
97
|
+
}
|
|
98
|
+
close() {
|
|
99
|
+
this.box.destroy();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Focus the modal.
|
|
103
|
+
*/
|
|
104
|
+
focus() {
|
|
105
|
+
this.box.focus();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format the commit panel as blessed-compatible tagged string.
|
|
3
|
+
*/
|
|
4
|
+
export function formatCommitPanel(state, stagedCount, width) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
// Title
|
|
7
|
+
let title = '{bold}Commit Message{/bold}';
|
|
8
|
+
if (state.amend) {
|
|
9
|
+
title += ' {yellow-fg}(amending){/yellow-fg}';
|
|
10
|
+
}
|
|
11
|
+
lines.push(title);
|
|
12
|
+
lines.push('');
|
|
13
|
+
// Message input area
|
|
14
|
+
const borderChar = state.inputFocused ? '\u2502' : '\u2502';
|
|
15
|
+
const borderColor = state.inputFocused ? 'cyan' : 'gray';
|
|
16
|
+
// Top border
|
|
17
|
+
const innerWidth = Math.max(20, width - 6);
|
|
18
|
+
lines.push(`{${borderColor}-fg}\u250c${'─'.repeat(innerWidth + 2)}\u2510{/${borderColor}-fg}`);
|
|
19
|
+
// Message content (or placeholder)
|
|
20
|
+
const displayMessage = state.message || (state.inputFocused ? '' : 'Press i or Enter to edit...');
|
|
21
|
+
const messageColor = state.message ? '' : '{gray-fg}';
|
|
22
|
+
const messageEnd = state.message ? '' : '{/gray-fg}';
|
|
23
|
+
// Truncate message if needed
|
|
24
|
+
const truncatedMessage = displayMessage.length > innerWidth
|
|
25
|
+
? displayMessage.slice(0, innerWidth - 1) + '\u2026'
|
|
26
|
+
: displayMessage.padEnd(innerWidth);
|
|
27
|
+
lines.push(`{${borderColor}-fg}${borderChar}{/${borderColor}-fg} ${messageColor}${truncatedMessage}${messageEnd} {${borderColor}-fg}${borderChar}{/${borderColor}-fg}`);
|
|
28
|
+
// Bottom border
|
|
29
|
+
lines.push(`{${borderColor}-fg}\u2514${'─'.repeat(innerWidth + 2)}\u2518{/${borderColor}-fg}`);
|
|
30
|
+
lines.push('');
|
|
31
|
+
// Amend checkbox
|
|
32
|
+
const checkbox = state.amend ? '[x]' : '[ ]';
|
|
33
|
+
const checkboxColor = state.amend ? 'green' : 'gray';
|
|
34
|
+
lines.push(`{${checkboxColor}-fg}${checkbox}{/${checkboxColor}-fg} Amend {gray-fg}(a){/gray-fg}`);
|
|
35
|
+
// Error message
|
|
36
|
+
if (state.error) {
|
|
37
|
+
lines.push('');
|
|
38
|
+
lines.push(`{red-fg}${state.error}{/red-fg}`);
|
|
39
|
+
}
|
|
40
|
+
// Committing status
|
|
41
|
+
if (state.isCommitting) {
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push('{yellow-fg}Committing...{/yellow-fg}');
|
|
44
|
+
}
|
|
45
|
+
lines.push('');
|
|
46
|
+
// Help text
|
|
47
|
+
const helpText = state.inputFocused
|
|
48
|
+
? 'Enter: commit | Esc: unfocus'
|
|
49
|
+
: 'i/Enter: edit | Esc: cancel | a: toggle amend';
|
|
50
|
+
lines.push(`{gray-fg}Staged: ${stagedCount} file(s) | ${helpText}{/gray-fg}`);
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Format inactive commit panel.
|
|
55
|
+
*/
|
|
56
|
+
export function formatCommitPanelInactive() {
|
|
57
|
+
return "{gray-fg}Press '2' or 'c' to open commit panel{/gray-fg}";
|
|
58
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { formatDate } from '../../utils/formatDate.js';
|
|
2
|
+
import { formatCommitDisplay } from '../../utils/commitFormat.js';
|
|
3
|
+
import { shortenPath } from '../../utils/formatPath.js';
|
|
4
|
+
/**
|
|
5
|
+
* Build the list of row items for the compare list view.
|
|
6
|
+
*/
|
|
7
|
+
export function buildCompareListRows(commits, files, commitsExpanded = true, filesExpanded = true) {
|
|
8
|
+
const result = [];
|
|
9
|
+
// Commits section
|
|
10
|
+
if (commits.length > 0) {
|
|
11
|
+
result.push({ type: 'section-header', sectionType: 'commits' });
|
|
12
|
+
if (commitsExpanded) {
|
|
13
|
+
commits.forEach((commit, i) => {
|
|
14
|
+
result.push({ type: 'commit', commitIndex: i, commit });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Files section
|
|
19
|
+
if (files.length > 0) {
|
|
20
|
+
if (commits.length > 0) {
|
|
21
|
+
result.push({ type: 'spacer' });
|
|
22
|
+
}
|
|
23
|
+
result.push({ type: 'section-header', sectionType: 'files' });
|
|
24
|
+
if (filesExpanded) {
|
|
25
|
+
files.forEach((file, i) => {
|
|
26
|
+
result.push({ type: 'file', fileIndex: i, file });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Escape blessed tags in content.
|
|
34
|
+
*/
|
|
35
|
+
function escapeContent(content) {
|
|
36
|
+
return content.replace(/\{/g, '{{').replace(/\}/g, '}}');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Format a commit row.
|
|
40
|
+
*/
|
|
41
|
+
function formatCommitRow(commit, isSelected, isFocused, width) {
|
|
42
|
+
const isHighlighted = isSelected && isFocused;
|
|
43
|
+
const dateStr = formatDate(commit.date);
|
|
44
|
+
// Fixed parts: indent(2) + hash(7) + spaces(4) + date + parens(2)
|
|
45
|
+
const baseWidth = 2 + 7 + 4 + dateStr.length + 2;
|
|
46
|
+
const remainingWidth = Math.max(10, width - baseWidth);
|
|
47
|
+
const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
|
|
48
|
+
let line = ' ';
|
|
49
|
+
line += `{yellow-fg}${commit.shortHash}{/yellow-fg} `;
|
|
50
|
+
if (isHighlighted) {
|
|
51
|
+
line += `{cyan-fg}{inverse}${escapeContent(displayMessage)}{/inverse}{/cyan-fg}`;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
line += escapeContent(displayMessage);
|
|
55
|
+
}
|
|
56
|
+
line += ` {gray-fg}(${dateStr}){/gray-fg}`;
|
|
57
|
+
if (displayRefs) {
|
|
58
|
+
line += ` {green-fg}${escapeContent(displayRefs)}{/green-fg}`;
|
|
59
|
+
}
|
|
60
|
+
return line;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Format a file row.
|
|
64
|
+
*/
|
|
65
|
+
function formatFileRow(file, isSelected, isFocused, maxPathLength) {
|
|
66
|
+
const isHighlighted = isSelected && isFocused;
|
|
67
|
+
const isUncommitted = file.isUncommitted ?? false;
|
|
68
|
+
const statusColors = {
|
|
69
|
+
added: 'green',
|
|
70
|
+
modified: 'yellow',
|
|
71
|
+
deleted: 'red',
|
|
72
|
+
renamed: 'blue',
|
|
73
|
+
};
|
|
74
|
+
const statusChars = {
|
|
75
|
+
added: 'A',
|
|
76
|
+
modified: 'M',
|
|
77
|
+
deleted: 'D',
|
|
78
|
+
renamed: 'R',
|
|
79
|
+
};
|
|
80
|
+
// Account for stats: " (+123 -456)" and possible "*" for uncommitted
|
|
81
|
+
const statsLength = 5 + String(file.additions).length + String(file.deletions).length;
|
|
82
|
+
const uncommittedLength = isUncommitted ? 14 : 0;
|
|
83
|
+
const availableForPath = Math.max(10, maxPathLength - statsLength - uncommittedLength);
|
|
84
|
+
let line = ' ';
|
|
85
|
+
if (isUncommitted) {
|
|
86
|
+
line += '{magenta-fg}{bold}*{/bold}{/magenta-fg}';
|
|
87
|
+
}
|
|
88
|
+
const statusColor = isUncommitted ? 'magenta' : statusColors[file.status];
|
|
89
|
+
line += `{${statusColor}-fg}{bold}${statusChars[file.status]}{/bold}{/${statusColor}-fg} `;
|
|
90
|
+
const displayPath = shortenPath(file.path, availableForPath);
|
|
91
|
+
if (isHighlighted) {
|
|
92
|
+
line += `{cyan-fg}{inverse}${escapeContent(displayPath)}{/inverse}{/cyan-fg}`;
|
|
93
|
+
}
|
|
94
|
+
else if (isUncommitted) {
|
|
95
|
+
line += `{magenta-fg}${escapeContent(displayPath)}{/magenta-fg}`;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
line += escapeContent(displayPath);
|
|
99
|
+
}
|
|
100
|
+
line += ` {gray-fg}({/gray-fg}{green-fg}+${file.additions}{/green-fg} {red-fg}-${file.deletions}{/red-fg}{gray-fg}){/gray-fg}`;
|
|
101
|
+
if (isUncommitted) {
|
|
102
|
+
line += ' {magenta-fg}[uncommitted]{/magenta-fg}';
|
|
103
|
+
}
|
|
104
|
+
return line;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Format the compare list view as blessed-compatible tagged string.
|
|
108
|
+
*/
|
|
109
|
+
export function formatCompareListView(commits, files, selectedItem, isFocused, width, scrollOffset = 0, maxHeight) {
|
|
110
|
+
if (commits.length === 0 && files.length === 0) {
|
|
111
|
+
return '{gray-fg}No changes compared to base branch{/gray-fg}';
|
|
112
|
+
}
|
|
113
|
+
const rows = buildCompareListRows(commits, files);
|
|
114
|
+
// Apply scroll offset and max height
|
|
115
|
+
const visibleRows = maxHeight
|
|
116
|
+
? rows.slice(scrollOffset, scrollOffset + maxHeight)
|
|
117
|
+
: rows.slice(scrollOffset);
|
|
118
|
+
const lines = [];
|
|
119
|
+
for (const row of visibleRows) {
|
|
120
|
+
if (row.type === 'section-header') {
|
|
121
|
+
const isCommits = row.sectionType === 'commits';
|
|
122
|
+
const count = isCommits ? commits.length : files.length;
|
|
123
|
+
const label = isCommits ? 'Commits' : 'Files';
|
|
124
|
+
lines.push(`{cyan-fg}{bold}▼ ${label}{/bold}{/cyan-fg} {gray-fg}(${count}){/gray-fg}`);
|
|
125
|
+
}
|
|
126
|
+
else if (row.type === 'spacer') {
|
|
127
|
+
lines.push('');
|
|
128
|
+
}
|
|
129
|
+
else if (row.type === 'commit' && row.commit && row.commitIndex !== undefined) {
|
|
130
|
+
const isSelected = selectedItem?.type === 'commit' && selectedItem.index === row.commitIndex;
|
|
131
|
+
lines.push(formatCommitRow(row.commit, isSelected, isFocused, width));
|
|
132
|
+
}
|
|
133
|
+
else if (row.type === 'file' && row.file && row.fileIndex !== undefined) {
|
|
134
|
+
const isSelected = selectedItem?.type === 'file' && selectedItem.index === row.fileIndex;
|
|
135
|
+
lines.push(formatFileRow(row.file, isSelected, isFocused, width - 5));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Get the total number of rows in the compare list view (for scroll calculation).
|
|
142
|
+
*/
|
|
143
|
+
export function getCompareListTotalRows(commits, files, commitsExpanded = true, filesExpanded = true) {
|
|
144
|
+
let count = 0;
|
|
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;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Map a row index to a selection.
|
|
161
|
+
* Returns null if the row is a header or spacer.
|
|
162
|
+
*/
|
|
163
|
+
export function getCompareSelectionFromRow(rowIndex, commits, files, commitsExpanded = true, filesExpanded = true) {
|
|
164
|
+
const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
|
|
165
|
+
const row = rows[rowIndex];
|
|
166
|
+
if (!row)
|
|
167
|
+
return null;
|
|
168
|
+
if (row.type === 'commit' && row.commitIndex !== undefined) {
|
|
169
|
+
return { type: 'commit', index: row.commitIndex };
|
|
170
|
+
}
|
|
171
|
+
if (row.type === 'file' && row.fileIndex !== undefined) {
|
|
172
|
+
return { type: 'file', index: row.fileIndex };
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Find the row index for a given selection.
|
|
178
|
+
*/
|
|
179
|
+
export function getRowFromCompareSelection(selection, commits, files, commitsExpanded = true, filesExpanded = true) {
|
|
180
|
+
const rows = buildCompareListRows(commits, files, commitsExpanded, filesExpanded);
|
|
181
|
+
for (let i = 0; i < rows.length; i++) {
|
|
182
|
+
const row = rows[i];
|
|
183
|
+
if (selection.type === 'commit' &&
|
|
184
|
+
row.type === 'commit' &&
|
|
185
|
+
row.commitIndex === selection.index) {
|
|
186
|
+
return i;
|
|
187
|
+
}
|
|
188
|
+
if (selection.type === 'file' && row.type === 'file' && row.fileIndex === selection.index) {
|
|
189
|
+
return i;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Navigate to next selectable item.
|
|
196
|
+
*/
|
|
197
|
+
export function getNextCompareSelection(current, commits, files, direction) {
|
|
198
|
+
const rows = buildCompareListRows(commits, files);
|
|
199
|
+
// Find current row index
|
|
200
|
+
let currentRowIndex = 0;
|
|
201
|
+
if (current) {
|
|
202
|
+
currentRowIndex = getRowFromCompareSelection(current, commits, files);
|
|
203
|
+
}
|
|
204
|
+
// Find next selectable row
|
|
205
|
+
const delta = direction === 'down' ? 1 : -1;
|
|
206
|
+
let nextRowIndex = currentRowIndex + delta;
|
|
207
|
+
while (nextRowIndex >= 0 && nextRowIndex < rows.length) {
|
|
208
|
+
const selection = getCompareSelectionFromRow(nextRowIndex, commits, files);
|
|
209
|
+
if (selection) {
|
|
210
|
+
return selection;
|
|
211
|
+
}
|
|
212
|
+
nextRowIndex += delta;
|
|
213
|
+
}
|
|
214
|
+
// Stay at current if no valid next selection
|
|
215
|
+
return current;
|
|
216
|
+
}
|