diffstalker 0.1.7 → 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/CHANGELOG.md +36 -0
- package/bun.lock +72 -312
- package/dist/App.js +1136 -515
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +75 -16
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +67 -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 +182 -0
- 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/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +37 -0
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +11 -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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
/**
|
|
3
|
+
* Layout constants matching the React/Ink implementation.
|
|
4
|
+
*/
|
|
5
|
+
export const LAYOUT_OVERHEAD = 5; // Header (1-2) + 3 separators + footer (1)
|
|
6
|
+
export const SPLIT_RATIO_STEP = 0.05;
|
|
7
|
+
/**
|
|
8
|
+
* Calculate layout dimensions based on terminal size and split ratio.
|
|
9
|
+
*/
|
|
10
|
+
export function calculateLayout(terminalHeight, terminalWidth, splitRatio, headerHeight = 1) {
|
|
11
|
+
// Total overhead: header + 3 separators + footer
|
|
12
|
+
const overhead = headerHeight + 4; // 3 separators + 1 footer
|
|
13
|
+
const availableHeight = terminalHeight - overhead;
|
|
14
|
+
const topPaneHeight = Math.floor(availableHeight * splitRatio);
|
|
15
|
+
const bottomPaneHeight = availableHeight - topPaneHeight;
|
|
16
|
+
return {
|
|
17
|
+
width: terminalWidth,
|
|
18
|
+
height: terminalHeight,
|
|
19
|
+
headerHeight,
|
|
20
|
+
topPaneHeight,
|
|
21
|
+
bottomPaneHeight,
|
|
22
|
+
footerRow: terminalHeight - 1,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Calculate pane boundaries for mouse click detection.
|
|
27
|
+
*/
|
|
28
|
+
export function calculatePaneBoundaries(terminalHeight, headerHeight, topPaneHeight, bottomPaneHeight) {
|
|
29
|
+
const stagingPaneStart = headerHeight + 1; // After header + separator
|
|
30
|
+
const fileListEnd = stagingPaneStart + topPaneHeight;
|
|
31
|
+
const diffPaneStart = fileListEnd + 1; // After separator
|
|
32
|
+
const diffPaneEnd = diffPaneStart + bottomPaneHeight;
|
|
33
|
+
const footerRow = terminalHeight - 1;
|
|
34
|
+
return {
|
|
35
|
+
stagingPaneStart,
|
|
36
|
+
fileListEnd,
|
|
37
|
+
diffPaneStart,
|
|
38
|
+
diffPaneEnd,
|
|
39
|
+
footerRow,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* LayoutManager creates and manages blessed boxes for the two-pane layout.
|
|
44
|
+
*/
|
|
45
|
+
export class LayoutManager {
|
|
46
|
+
screen;
|
|
47
|
+
headerBox;
|
|
48
|
+
topSeparator;
|
|
49
|
+
topPane;
|
|
50
|
+
middleSeparator;
|
|
51
|
+
bottomPane;
|
|
52
|
+
bottomSeparator;
|
|
53
|
+
footerBox;
|
|
54
|
+
_dimensions;
|
|
55
|
+
_splitRatio;
|
|
56
|
+
constructor(screen, splitRatio = 0.4) {
|
|
57
|
+
this.screen = screen;
|
|
58
|
+
this._splitRatio = splitRatio;
|
|
59
|
+
this._dimensions = this.calculateDimensions();
|
|
60
|
+
// Create all layout boxes
|
|
61
|
+
this.headerBox = this.createHeaderBox();
|
|
62
|
+
this.topSeparator = this.createSeparator(this._dimensions.headerHeight);
|
|
63
|
+
this.topPane = this.createTopPane();
|
|
64
|
+
this.middleSeparator = this.createSeparator(this._dimensions.headerHeight + 1 + this._dimensions.topPaneHeight);
|
|
65
|
+
this.bottomPane = this.createBottomPane();
|
|
66
|
+
this.bottomSeparator = this.createSeparator(this._dimensions.headerHeight +
|
|
67
|
+
2 +
|
|
68
|
+
this._dimensions.topPaneHeight +
|
|
69
|
+
this._dimensions.bottomPaneHeight);
|
|
70
|
+
this.footerBox = this.createFooterBox();
|
|
71
|
+
// Handle screen resize
|
|
72
|
+
screen.on('resize', () => this.onResize());
|
|
73
|
+
}
|
|
74
|
+
get dimensions() {
|
|
75
|
+
return this._dimensions;
|
|
76
|
+
}
|
|
77
|
+
get splitRatio() {
|
|
78
|
+
return this._splitRatio;
|
|
79
|
+
}
|
|
80
|
+
setSplitRatio(ratio) {
|
|
81
|
+
this._splitRatio = Math.min(0.85, Math.max(0.15, ratio));
|
|
82
|
+
this.updateLayout();
|
|
83
|
+
}
|
|
84
|
+
adjustSplitRatio(delta) {
|
|
85
|
+
this.setSplitRatio(this._splitRatio + delta);
|
|
86
|
+
}
|
|
87
|
+
calculateDimensions() {
|
|
88
|
+
const height = this.screen.height || 24;
|
|
89
|
+
const width = this.screen.width || 80;
|
|
90
|
+
return calculateLayout(height, width, this._splitRatio);
|
|
91
|
+
}
|
|
92
|
+
createHeaderBox() {
|
|
93
|
+
return blessed.box({
|
|
94
|
+
parent: this.screen,
|
|
95
|
+
top: 0,
|
|
96
|
+
left: 0,
|
|
97
|
+
width: '100%',
|
|
98
|
+
height: this._dimensions.headerHeight,
|
|
99
|
+
tags: true,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
createSeparator(top) {
|
|
103
|
+
const width = this.screen.width || 80;
|
|
104
|
+
return blessed.box({
|
|
105
|
+
parent: this.screen,
|
|
106
|
+
top,
|
|
107
|
+
left: 0,
|
|
108
|
+
width: '100%',
|
|
109
|
+
height: 1,
|
|
110
|
+
content: '\u2500'.repeat(width),
|
|
111
|
+
style: {
|
|
112
|
+
fg: 'gray',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
createTopPane() {
|
|
117
|
+
return blessed.box({
|
|
118
|
+
parent: this.screen,
|
|
119
|
+
top: this._dimensions.headerHeight + 1,
|
|
120
|
+
left: 0,
|
|
121
|
+
width: '100%',
|
|
122
|
+
height: this._dimensions.topPaneHeight,
|
|
123
|
+
tags: true,
|
|
124
|
+
scrollable: true,
|
|
125
|
+
alwaysScroll: true,
|
|
126
|
+
wrap: false, // Disable blessed's built-in wrapping - we handle wrapping ourselves
|
|
127
|
+
scrollbar: {
|
|
128
|
+
ch: ' ',
|
|
129
|
+
track: {
|
|
130
|
+
bg: 'gray',
|
|
131
|
+
},
|
|
132
|
+
style: {
|
|
133
|
+
inverse: true,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
createBottomPane() {
|
|
139
|
+
return blessed.box({
|
|
140
|
+
parent: this.screen,
|
|
141
|
+
top: this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight,
|
|
142
|
+
left: 0,
|
|
143
|
+
width: '100%',
|
|
144
|
+
height: this._dimensions.bottomPaneHeight,
|
|
145
|
+
tags: true,
|
|
146
|
+
scrollable: true,
|
|
147
|
+
alwaysScroll: true,
|
|
148
|
+
wrap: false, // Disable blessed's built-in wrapping - we handle wrapping ourselves
|
|
149
|
+
scrollbar: {
|
|
150
|
+
ch: ' ',
|
|
151
|
+
track: {
|
|
152
|
+
bg: 'gray',
|
|
153
|
+
},
|
|
154
|
+
style: {
|
|
155
|
+
inverse: true,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
createFooterBox() {
|
|
161
|
+
return blessed.box({
|
|
162
|
+
parent: this.screen,
|
|
163
|
+
top: this._dimensions.footerRow,
|
|
164
|
+
left: 0,
|
|
165
|
+
width: '100%',
|
|
166
|
+
height: 1,
|
|
167
|
+
tags: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
onResize() {
|
|
171
|
+
this._dimensions = this.calculateDimensions();
|
|
172
|
+
this.updateLayout();
|
|
173
|
+
// Don't call screen.render() here - App's resize handler will render
|
|
174
|
+
// with properly recalculated content
|
|
175
|
+
}
|
|
176
|
+
updateLayout() {
|
|
177
|
+
this._dimensions = this.calculateDimensions();
|
|
178
|
+
const width = this.screen.width || 80;
|
|
179
|
+
// Update header
|
|
180
|
+
this.headerBox.height = this._dimensions.headerHeight;
|
|
181
|
+
this.headerBox.width = width;
|
|
182
|
+
// Update top separator
|
|
183
|
+
this.topSeparator.top = this._dimensions.headerHeight;
|
|
184
|
+
this.topSeparator.width = width;
|
|
185
|
+
this.topSeparator.setContent('\u2500'.repeat(width));
|
|
186
|
+
// Update top pane
|
|
187
|
+
this.topPane.top = this._dimensions.headerHeight + 1;
|
|
188
|
+
this.topPane.height = this._dimensions.topPaneHeight;
|
|
189
|
+
this.topPane.width = width;
|
|
190
|
+
// Update middle separator
|
|
191
|
+
this.middleSeparator.top = this._dimensions.headerHeight + 1 + this._dimensions.topPaneHeight;
|
|
192
|
+
this.middleSeparator.width = width;
|
|
193
|
+
this.middleSeparator.setContent('\u2500'.repeat(width));
|
|
194
|
+
// Update bottom pane
|
|
195
|
+
this.bottomPane.top = this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight;
|
|
196
|
+
this.bottomPane.height = this._dimensions.bottomPaneHeight;
|
|
197
|
+
this.bottomPane.width = width;
|
|
198
|
+
// Update bottom separator
|
|
199
|
+
this.bottomSeparator.top =
|
|
200
|
+
this._dimensions.headerHeight +
|
|
201
|
+
2 +
|
|
202
|
+
this._dimensions.topPaneHeight +
|
|
203
|
+
this._dimensions.bottomPaneHeight;
|
|
204
|
+
this.bottomSeparator.width = width;
|
|
205
|
+
this.bottomSeparator.setContent('\u2500'.repeat(width));
|
|
206
|
+
// Update footer
|
|
207
|
+
this.footerBox.top = this._dimensions.footerRow;
|
|
208
|
+
this.footerBox.width = width;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Get pane boundaries for mouse click detection.
|
|
212
|
+
*/
|
|
213
|
+
getPaneBoundaries() {
|
|
214
|
+
return calculatePaneBoundaries(this._dimensions.height, this._dimensions.headerHeight, this._dimensions.topPaneHeight, this._dimensions.bottomPaneHeight);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Convert screen Y coordinate to content row within the top pane.
|
|
218
|
+
* Returns the 0-based row index of the content, or -1 if outside the pane.
|
|
219
|
+
*/
|
|
220
|
+
screenYToTopPaneRow(screenY) {
|
|
221
|
+
const paneTop = this._dimensions.headerHeight + 1; // header + separator
|
|
222
|
+
const paneBottom = paneTop + this._dimensions.topPaneHeight;
|
|
223
|
+
if (screenY < paneTop || screenY >= paneBottom) {
|
|
224
|
+
return -1;
|
|
225
|
+
}
|
|
226
|
+
return screenY - paneTop;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Convert screen Y coordinate to content row within the bottom pane.
|
|
230
|
+
* Returns the 0-based row index of the content, or -1 if outside the pane.
|
|
231
|
+
*/
|
|
232
|
+
screenYToBottomPaneRow(screenY) {
|
|
233
|
+
const paneTop = this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight; // header + 2 separators + top pane
|
|
234
|
+
const paneBottom = paneTop + this._dimensions.bottomPaneHeight;
|
|
235
|
+
if (screenY < paneTop || screenY >= paneBottom) {
|
|
236
|
+
return -1;
|
|
237
|
+
}
|
|
238
|
+
return screenY - paneTop;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Get the top position of the top pane (for reference).
|
|
242
|
+
*/
|
|
243
|
+
get topPaneTop() {
|
|
244
|
+
return this._dimensions.headerHeight + 1;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Get the top position of the bottom pane (for reference).
|
|
248
|
+
*/
|
|
249
|
+
get bottomPaneTop() {
|
|
250
|
+
return this._dimensions.headerHeight + 2 + this._dimensions.topPaneHeight;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
/**
|
|
3
|
+
* BaseBranchPicker modal for selecting the base branch for PR comparison.
|
|
4
|
+
*/
|
|
5
|
+
export class BaseBranchPicker {
|
|
6
|
+
box;
|
|
7
|
+
screen;
|
|
8
|
+
branches;
|
|
9
|
+
selectedIndex;
|
|
10
|
+
currentBranch;
|
|
11
|
+
onSelect;
|
|
12
|
+
onCancel;
|
|
13
|
+
constructor(screen, branches, currentBranch, onSelect, onCancel) {
|
|
14
|
+
this.screen = screen;
|
|
15
|
+
this.branches = branches;
|
|
16
|
+
this.currentBranch = currentBranch;
|
|
17
|
+
this.onSelect = onSelect;
|
|
18
|
+
this.onCancel = onCancel;
|
|
19
|
+
// Find current branch index
|
|
20
|
+
this.selectedIndex = currentBranch ? branches.indexOf(currentBranch) : 0;
|
|
21
|
+
if (this.selectedIndex < 0)
|
|
22
|
+
this.selectedIndex = 0;
|
|
23
|
+
// Create modal box
|
|
24
|
+
const width = 50;
|
|
25
|
+
const maxVisibleBranches = Math.min(branches.length, 15);
|
|
26
|
+
const height = maxVisibleBranches + 6; // branches + header + footer + borders + padding
|
|
27
|
+
this.box = blessed.box({
|
|
28
|
+
parent: screen,
|
|
29
|
+
top: 'center',
|
|
30
|
+
left: 'center',
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
border: {
|
|
34
|
+
type: 'line',
|
|
35
|
+
},
|
|
36
|
+
style: {
|
|
37
|
+
border: {
|
|
38
|
+
fg: 'cyan',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
tags: true,
|
|
42
|
+
keys: true,
|
|
43
|
+
scrollable: true,
|
|
44
|
+
alwaysScroll: true,
|
|
45
|
+
});
|
|
46
|
+
// Setup key handlers
|
|
47
|
+
this.setupKeyHandlers();
|
|
48
|
+
// Initial render
|
|
49
|
+
this.render();
|
|
50
|
+
}
|
|
51
|
+
setupKeyHandlers() {
|
|
52
|
+
this.box.key(['escape', 'q'], () => {
|
|
53
|
+
this.close();
|
|
54
|
+
this.onCancel();
|
|
55
|
+
});
|
|
56
|
+
this.box.key(['enter', 'space'], () => {
|
|
57
|
+
const selected = this.branches[this.selectedIndex];
|
|
58
|
+
if (selected) {
|
|
59
|
+
this.close();
|
|
60
|
+
this.onSelect(selected);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
this.box.key(['up', 'k'], () => {
|
|
64
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
65
|
+
this.render();
|
|
66
|
+
});
|
|
67
|
+
this.box.key(['down', 'j'], () => {
|
|
68
|
+
this.selectedIndex = Math.min(this.branches.length - 1, this.selectedIndex + 1);
|
|
69
|
+
this.render();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
render() {
|
|
73
|
+
const lines = [];
|
|
74
|
+
// Header
|
|
75
|
+
lines.push('{bold}{cyan-fg} Select Base Branch{/cyan-fg}{/bold}');
|
|
76
|
+
lines.push('');
|
|
77
|
+
if (this.branches.length === 0) {
|
|
78
|
+
lines.push('{gray-fg}No branches found{/gray-fg}');
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// Branch list
|
|
82
|
+
for (let i = 0; i < this.branches.length; i++) {
|
|
83
|
+
const branch = this.branches[i];
|
|
84
|
+
const isSelected = i === this.selectedIndex;
|
|
85
|
+
const isCurrent = branch === this.currentBranch;
|
|
86
|
+
let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
|
|
87
|
+
line += branch;
|
|
88
|
+
if (isSelected)
|
|
89
|
+
line += '{/bold}{/cyan-fg}';
|
|
90
|
+
if (isCurrent)
|
|
91
|
+
line += ' {gray-fg}(current){/gray-fg}';
|
|
92
|
+
lines.push(line);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Footer
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
|
|
98
|
+
this.box.setContent(lines.join('\n'));
|
|
99
|
+
this.screen.render();
|
|
100
|
+
}
|
|
101
|
+
close() {
|
|
102
|
+
this.box.destroy();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Focus the modal.
|
|
106
|
+
*/
|
|
107
|
+
focus() {
|
|
108
|
+
this.box.focus();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
/**
|
|
3
|
+
* DiscardConfirm modal for confirming discard of file changes.
|
|
4
|
+
*/
|
|
5
|
+
export class DiscardConfirm {
|
|
6
|
+
box;
|
|
7
|
+
screen;
|
|
8
|
+
filePath;
|
|
9
|
+
onConfirm;
|
|
10
|
+
onCancel;
|
|
11
|
+
constructor(screen, filePath, onConfirm, onCancel) {
|
|
12
|
+
this.screen = screen;
|
|
13
|
+
this.filePath = filePath;
|
|
14
|
+
this.onConfirm = onConfirm;
|
|
15
|
+
this.onCancel = onCancel;
|
|
16
|
+
// Create modal box - small confirmation dialog
|
|
17
|
+
const width = Math.min(60, Math.max(40, filePath.length + 20));
|
|
18
|
+
const height = 7;
|
|
19
|
+
this.box = blessed.box({
|
|
20
|
+
parent: screen,
|
|
21
|
+
top: 'center',
|
|
22
|
+
left: 'center',
|
|
23
|
+
width,
|
|
24
|
+
height,
|
|
25
|
+
border: {
|
|
26
|
+
type: 'line',
|
|
27
|
+
},
|
|
28
|
+
style: {
|
|
29
|
+
border: {
|
|
30
|
+
fg: 'yellow',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
tags: true,
|
|
34
|
+
keys: true,
|
|
35
|
+
});
|
|
36
|
+
// Setup key handlers
|
|
37
|
+
this.setupKeyHandlers();
|
|
38
|
+
// Render content
|
|
39
|
+
this.render();
|
|
40
|
+
}
|
|
41
|
+
setupKeyHandlers() {
|
|
42
|
+
this.box.key(['y', 'Y'], () => {
|
|
43
|
+
this.close();
|
|
44
|
+
this.onConfirm();
|
|
45
|
+
});
|
|
46
|
+
this.box.key(['n', 'N', 'escape', 'q'], () => {
|
|
47
|
+
this.close();
|
|
48
|
+
this.onCancel();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
render() {
|
|
52
|
+
const lines = [];
|
|
53
|
+
// Header
|
|
54
|
+
lines.push('{bold}{yellow-fg} Discard Changes?{/yellow-fg}{/bold}');
|
|
55
|
+
lines.push('');
|
|
56
|
+
// File path (truncate if needed)
|
|
57
|
+
const maxPathLen = this.box.width - 6;
|
|
58
|
+
const displayPath = this.filePath.length > maxPathLen
|
|
59
|
+
? '...' + this.filePath.slice(-(maxPathLen - 3))
|
|
60
|
+
: this.filePath;
|
|
61
|
+
lines.push(`{white-fg}${displayPath}{/white-fg}`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
// Prompt
|
|
64
|
+
lines.push('{gray-fg}Press {/gray-fg}{green-fg}y{/green-fg}{gray-fg} to confirm, {/gray-fg}{red-fg}n{/red-fg}{gray-fg} or Esc to cancel{/gray-fg}');
|
|
65
|
+
this.box.setContent(lines.join('\n'));
|
|
66
|
+
this.screen.render();
|
|
67
|
+
}
|
|
68
|
+
close() {
|
|
69
|
+
this.box.destroy();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Focus the modal.
|
|
73
|
+
*/
|
|
74
|
+
focus() {
|
|
75
|
+
this.box.focus();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -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
|
+
}
|