diffstalker 0.1.7 → 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/CHANGELOG.md +36 -0
- package/bun.lock +89 -306
- package/dist/App.js +895 -520
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +632 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +221 -86
- package/dist/git/diff.js +4 -0
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +68 -53
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +195 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/FileFinder.js +232 -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 +238 -0
- package/dist/ui/widgets/DiffView.js +281 -0
- package/dist/ui/widgets/ExplorerContent.js +89 -0
- package/dist/ui/widgets/ExplorerView.js +204 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +50 -0
- package/dist/ui/widgets/Header.js +68 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/wordDiff.js +50 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +14 -12
- package/dist/components/BaseBranchPicker.js +0 -60
- package/dist/components/BottomPane.js +0 -101
- package/dist/components/CommitPanel.js +0 -58
- package/dist/components/CompareListView.js +0 -110
- package/dist/components/ExplorerContentView.js +0 -80
- package/dist/components/ExplorerView.js +0 -37
- package/dist/components/FileList.js +0 -131
- package/dist/components/Footer.js +0 -6
- package/dist/components/Header.js +0 -107
- package/dist/components/HistoryView.js +0 -21
- package/dist/components/HotkeysModal.js +0 -108
- package/dist/components/Modal.js +0 -19
- package/dist/components/ScrollableList.js +0 -125
- package/dist/components/ThemePicker.js +0 -42
- package/dist/components/TopPane.js +0 -14
- package/dist/components/UnifiedDiffView.js +0 -115
- package/dist/hooks/useCommitFlow.js +0 -66
- package/dist/hooks/useCompareState.js +0 -123
- package/dist/hooks/useExplorerState.js +0 -248
- package/dist/hooks/useGit.js +0 -156
- package/dist/hooks/useHistoryState.js +0 -62
- package/dist/hooks/useKeymap.js +0 -167
- package/dist/hooks/useLayout.js +0 -154
- package/dist/hooks/useMouse.js +0 -87
- package/dist/hooks/useTerminalSize.js +0 -20
- package/dist/hooks/useWatcher.js +0 -137
- package/dist/utils/mouseCoordinates.js +0 -165
- package/dist/utils/rowCalculations.js +0 -209
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|