diffstalker 0.2.3 → 0.2.5
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 +2 -2
- package/.githooks/pre-push +2 -2
- package/.github/workflows/release.yml +3 -0
- package/CHANGELOG.md +6 -0
- package/dist/App.js +278 -758
- package/dist/KeyBindings.js +103 -91
- package/dist/ModalController.js +166 -0
- package/dist/MouseHandlers.js +37 -30
- package/dist/NavigationController.js +290 -0
- package/dist/StagingOperations.js +199 -0
- package/dist/config.js +39 -0
- package/dist/core/CompareManager.js +134 -0
- package/dist/core/ExplorerStateManager.js +7 -3
- package/dist/core/GitStateManager.js +28 -771
- package/dist/core/HistoryManager.js +72 -0
- package/dist/core/RemoteOperationManager.js +109 -0
- package/dist/core/WorkingTreeManager.js +412 -0
- package/dist/index.js +57 -57
- package/dist/state/FocusRing.js +40 -0
- package/dist/state/UIState.js +82 -48
- package/dist/ui/PaneRenderers.js +3 -6
- package/dist/ui/modals/BaseBranchPicker.js +4 -7
- package/dist/ui/modals/CommitActionConfirm.js +4 -4
- package/dist/ui/modals/DiscardConfirm.js +4 -7
- package/dist/ui/modals/FileFinder.js +3 -6
- package/dist/ui/modals/HotkeysModal.js +24 -21
- package/dist/ui/modals/Modal.js +1 -0
- package/dist/ui/modals/RepoPicker.js +109 -0
- package/dist/ui/modals/ThemePicker.js +4 -7
- package/dist/ui/widgets/CommitPanel.js +26 -94
- package/dist/ui/widgets/CompareListView.js +1 -11
- package/dist/ui/widgets/DiffView.js +2 -27
- package/dist/ui/widgets/ExplorerContent.js +1 -4
- package/dist/ui/widgets/ExplorerView.js +1 -11
- package/dist/ui/widgets/FileList.js +2 -8
- package/dist/ui/widgets/Footer.js +1 -0
- package/dist/utils/ansi.js +38 -0
- package/dist/utils/ansiTruncate.js +1 -5
- package/dist/utils/displayRows.js +72 -59
- package/dist/utils/fileCategories.js +7 -0
- package/dist/utils/fileResolution.js +23 -0
- package/dist/utils/languageDetection.js +3 -2
- package/dist/utils/logger.js +32 -0
- package/metrics/v0.2.4.json +236 -0
- package/metrics/v0.2.5.json +236 -0
- package/package.json +1 -1
- package/dist/ui/modals/BranchPicker.js +0 -157
- package/dist/ui/modals/SoftResetConfirm.js +0 -68
- package/dist/ui/modals/StashListModal.js +0 -98
- package/dist/utils/layoutCalculations.js +0 -100
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic ring/cycle data structure for focus zone navigation.
|
|
3
|
+
* Tab cycles forward, Shift-Tab cycles backward, wrapping at boundaries.
|
|
4
|
+
*/
|
|
5
|
+
export class FocusRing {
|
|
6
|
+
items;
|
|
7
|
+
index;
|
|
8
|
+
constructor(items, initialIndex = 0) {
|
|
9
|
+
this.items = items;
|
|
10
|
+
this.index = Math.min(initialIndex, Math.max(0, items.length - 1));
|
|
11
|
+
}
|
|
12
|
+
current() {
|
|
13
|
+
return this.items[this.index];
|
|
14
|
+
}
|
|
15
|
+
next() {
|
|
16
|
+
this.index = (this.index + 1) % this.items.length;
|
|
17
|
+
return this.items[this.index];
|
|
18
|
+
}
|
|
19
|
+
prev() {
|
|
20
|
+
this.index = (this.index - 1 + this.items.length) % this.items.length;
|
|
21
|
+
return this.items[this.index];
|
|
22
|
+
}
|
|
23
|
+
setCurrent(item) {
|
|
24
|
+
const idx = this.items.indexOf(item);
|
|
25
|
+
if (idx === -1)
|
|
26
|
+
return false;
|
|
27
|
+
this.index = idx;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
setItems(items, defaultItem) {
|
|
31
|
+
this.items = items;
|
|
32
|
+
if (defaultItem !== undefined) {
|
|
33
|
+
const idx = items.indexOf(defaultItem);
|
|
34
|
+
this.index = idx !== -1 ? idx : 0;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
this.index = Math.min(this.index, Math.max(0, items.length - 1));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/state/UIState.js
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { FocusRing } from './FocusRing.js';
|
|
3
|
+
/** Map each focus zone to its derived currentPane value. */
|
|
4
|
+
export const ZONE_TO_PANE = {
|
|
5
|
+
fileList: 'files',
|
|
6
|
+
diffView: 'diff',
|
|
7
|
+
commitMessage: 'commit',
|
|
8
|
+
commitAmend: 'commit',
|
|
9
|
+
historyList: 'history',
|
|
10
|
+
historyDiff: 'diff',
|
|
11
|
+
compareList: 'compare',
|
|
12
|
+
compareDiff: 'diff',
|
|
13
|
+
explorerTree: 'explorer',
|
|
14
|
+
explorerContent: 'diff',
|
|
15
|
+
};
|
|
16
|
+
/** Ordered list of focus zones per tab (Tab order). */
|
|
17
|
+
export const TAB_ZONES = {
|
|
18
|
+
diff: ['fileList', 'diffView'],
|
|
19
|
+
commit: ['fileList', 'commitMessage', 'commitAmend'],
|
|
20
|
+
history: ['historyList', 'historyDiff'],
|
|
21
|
+
compare: ['compareList', 'compareDiff'],
|
|
22
|
+
explorer: ['explorerTree', 'explorerContent'],
|
|
23
|
+
};
|
|
24
|
+
/** Default focus zone when switching to each tab. */
|
|
25
|
+
export const DEFAULT_TAB_ZONE = {
|
|
26
|
+
diff: 'fileList',
|
|
27
|
+
commit: 'commitMessage',
|
|
28
|
+
history: 'historyList',
|
|
29
|
+
compare: 'compareList',
|
|
30
|
+
explorer: 'explorerTree',
|
|
31
|
+
};
|
|
2
32
|
const DEFAULT_STATE = {
|
|
33
|
+
focusedZone: 'fileList',
|
|
3
34
|
currentPane: 'files',
|
|
4
35
|
bottomTab: 'diff',
|
|
5
36
|
selectedIndex: 0,
|
|
@@ -21,8 +52,6 @@ const DEFAULT_STATE = {
|
|
|
21
52
|
hideGitignored: true,
|
|
22
53
|
flatViewMode: false,
|
|
23
54
|
splitRatio: 0.4,
|
|
24
|
-
activeModal: null,
|
|
25
|
-
pendingDiscard: null,
|
|
26
55
|
commitInputFocused: false,
|
|
27
56
|
};
|
|
28
57
|
/**
|
|
@@ -31,37 +60,54 @@ const DEFAULT_STATE = {
|
|
|
31
60
|
*/
|
|
32
61
|
export class UIState extends EventEmitter {
|
|
33
62
|
_state;
|
|
63
|
+
focusRing;
|
|
34
64
|
constructor(initialState = {}) {
|
|
35
65
|
super();
|
|
36
66
|
this._state = { ...DEFAULT_STATE, ...initialState };
|
|
67
|
+
const tab = this._state.bottomTab;
|
|
68
|
+
const zones = TAB_ZONES[tab];
|
|
69
|
+
this.focusRing = new FocusRing(zones);
|
|
70
|
+
if (this._state.focusedZone) {
|
|
71
|
+
this.focusRing.setCurrent(this._state.focusedZone);
|
|
72
|
+
}
|
|
73
|
+
// Ensure currentPane is in sync
|
|
74
|
+
this._state.currentPane = ZONE_TO_PANE[this._state.focusedZone];
|
|
37
75
|
}
|
|
38
76
|
get state() {
|
|
39
77
|
return this._state;
|
|
40
78
|
}
|
|
41
79
|
update(partial) {
|
|
42
80
|
this._state = { ...this._state, ...partial };
|
|
81
|
+
// Derive currentPane from focusedZone
|
|
82
|
+
this._state.currentPane = ZONE_TO_PANE[this._state.focusedZone];
|
|
43
83
|
this.emit('change', this._state);
|
|
44
84
|
}
|
|
45
85
|
// Navigation
|
|
46
86
|
setPane(pane) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
87
|
+
// Map pane to the first matching zone for the current tab
|
|
88
|
+
const zones = TAB_ZONES[this._state.bottomTab];
|
|
89
|
+
const zone = zones.find((z) => ZONE_TO_PANE[z] === pane);
|
|
90
|
+
if (zone) {
|
|
91
|
+
this.setFocusedZone(zone);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
setFocusedZone(zone) {
|
|
95
|
+
if (this._state.focusedZone !== zone) {
|
|
96
|
+
this.focusRing.setCurrent(zone);
|
|
97
|
+
const oldPane = this._state.currentPane;
|
|
98
|
+
this.update({ focusedZone: zone });
|
|
99
|
+
if (this._state.currentPane !== oldPane) {
|
|
100
|
+
this.emit('pane-change', this._state.currentPane);
|
|
101
|
+
}
|
|
50
102
|
}
|
|
51
103
|
}
|
|
52
104
|
setTab(tab) {
|
|
53
105
|
if (this._state.bottomTab !== tab) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
diff: 'files',
|
|
57
|
-
commit: 'commit',
|
|
58
|
-
history: 'history',
|
|
59
|
-
compare: 'compare',
|
|
60
|
-
explorer: 'explorer',
|
|
61
|
-
};
|
|
106
|
+
const defaultZone = DEFAULT_TAB_ZONE[tab];
|
|
107
|
+
this.focusRing.setItems(TAB_ZONES[tab], defaultZone);
|
|
62
108
|
this.update({
|
|
63
109
|
bottomTab: tab,
|
|
64
|
-
|
|
110
|
+
focusedZone: defaultZone,
|
|
65
111
|
});
|
|
66
112
|
this.emit('tab-change', tab);
|
|
67
113
|
}
|
|
@@ -155,51 +201,39 @@ export class UIState extends EventEmitter {
|
|
|
155
201
|
setSplitRatio(ratio) {
|
|
156
202
|
this.update({ splitRatio: Math.min(0.85, Math.max(0.15, ratio)) });
|
|
157
203
|
}
|
|
158
|
-
// Modals
|
|
159
|
-
openModal(modal) {
|
|
160
|
-
this.update({ activeModal: modal });
|
|
161
|
-
this.emit('modal-change', modal);
|
|
162
|
-
}
|
|
163
|
-
closeModal() {
|
|
164
|
-
this.update({ activeModal: null });
|
|
165
|
-
this.emit('modal-change', null);
|
|
166
|
-
}
|
|
167
|
-
toggleModal(modal) {
|
|
168
|
-
if (this._state.activeModal === modal) {
|
|
169
|
-
this.closeModal();
|
|
170
|
-
}
|
|
171
|
-
else {
|
|
172
|
-
this.openModal(modal);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
// Discard confirmation
|
|
176
|
-
setPendingDiscard(file) {
|
|
177
|
-
this.update({ pendingDiscard: file });
|
|
178
|
-
}
|
|
179
204
|
// Commit input focus
|
|
180
205
|
setCommitInputFocused(focused) {
|
|
181
206
|
this.update({ commitInputFocused: focused });
|
|
182
207
|
}
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.setPane(currentPane === 'history' ? 'diff' : 'history');
|
|
208
|
+
// Focus zone cycling
|
|
209
|
+
advanceFocus() {
|
|
210
|
+
const zone = this.focusRing.next();
|
|
211
|
+
const oldPane = this._state.currentPane;
|
|
212
|
+
this.update({ focusedZone: zone });
|
|
213
|
+
if (this._state.currentPane !== oldPane) {
|
|
214
|
+
this.emit('pane-change', this._state.currentPane);
|
|
191
215
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
216
|
+
}
|
|
217
|
+
retreatFocus() {
|
|
218
|
+
const zone = this.focusRing.prev();
|
|
219
|
+
const oldPane = this._state.currentPane;
|
|
220
|
+
this.update({ focusedZone: zone });
|
|
221
|
+
if (this._state.currentPane !== oldPane) {
|
|
222
|
+
this.emit('pane-change', this._state.currentPane);
|
|
197
223
|
}
|
|
198
224
|
}
|
|
225
|
+
/** Backward compat alias for advanceFocus(). */
|
|
226
|
+
togglePane() {
|
|
227
|
+
this.advanceFocus();
|
|
228
|
+
}
|
|
199
229
|
// Reset repo-specific state when switching repositories
|
|
200
230
|
resetForNewRepo() {
|
|
231
|
+
const defaultZone = DEFAULT_TAB_ZONE[this._state.bottomTab];
|
|
232
|
+
this.focusRing.setItems(TAB_ZONES[this._state.bottomTab], defaultZone);
|
|
201
233
|
this._state = {
|
|
202
234
|
...this._state,
|
|
235
|
+
focusedZone: defaultZone,
|
|
236
|
+
currentPane: ZONE_TO_PANE[defaultZone],
|
|
203
237
|
selectedIndex: 0,
|
|
204
238
|
fileListScrollOffset: 0,
|
|
205
239
|
diffScrollOffset: 0,
|
package/dist/ui/PaneRenderers.js
CHANGED
|
@@ -31,19 +31,16 @@ export function renderTopPane(state, files, historyCommits, compareDiff, compare
|
|
|
31
31
|
/**
|
|
32
32
|
* Render the bottom pane content for the current tab.
|
|
33
33
|
*/
|
|
34
|
-
export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight, selectedHunkIndex, isFileStaged, combinedFileDiffs,
|
|
34
|
+
export function renderBottomPane(state, diff, historyState, compareSelectionState, explorerSelectedFile, commitFlowState, stagedCount, currentTheme, width, bottomPaneHeight, selectedHunkIndex, isFileStaged, combinedFileDiffs, focusedZone) {
|
|
35
35
|
if (state.bottomTab === 'commit') {
|
|
36
36
|
const panelOpts = {
|
|
37
37
|
state: commitFlowState,
|
|
38
38
|
stagedCount,
|
|
39
39
|
width,
|
|
40
|
-
|
|
41
|
-
remoteState,
|
|
42
|
-
stashList,
|
|
43
|
-
headCommit,
|
|
40
|
+
focusedZone,
|
|
44
41
|
};
|
|
45
42
|
const totalRows = getCommitPanelTotalRows(panelOpts);
|
|
46
|
-
const content = formatCommitPanel(commitFlowState, stagedCount, width,
|
|
43
|
+
const content = formatCommitPanel(commitFlowState, stagedCount, width, state.diffScrollOffset, bottomPaneHeight, focusedZone);
|
|
47
44
|
return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
|
|
48
45
|
}
|
|
49
46
|
if (state.bottomTab === 'history') {
|
|
@@ -49,14 +49,14 @@ export class BaseBranchPicker {
|
|
|
49
49
|
this.render();
|
|
50
50
|
}
|
|
51
51
|
setupKeyHandlers() {
|
|
52
|
-
this.box.key(['escape'
|
|
53
|
-
this.
|
|
52
|
+
this.box.key(['escape'], () => {
|
|
53
|
+
this.destroy();
|
|
54
54
|
this.onCancel();
|
|
55
55
|
});
|
|
56
56
|
this.box.key(['enter', 'space'], () => {
|
|
57
57
|
const selected = this.branches[this.selectedIndex];
|
|
58
58
|
if (selected) {
|
|
59
|
-
this.
|
|
59
|
+
this.destroy();
|
|
60
60
|
this.onSelect(selected);
|
|
61
61
|
}
|
|
62
62
|
});
|
|
@@ -98,12 +98,9 @@ export class BaseBranchPicker {
|
|
|
98
98
|
this.box.setContent(lines.join('\n'));
|
|
99
99
|
this.screen.render();
|
|
100
100
|
}
|
|
101
|
-
|
|
101
|
+
destroy() {
|
|
102
102
|
this.box.destroy();
|
|
103
103
|
}
|
|
104
|
-
/**
|
|
105
|
-
* Focus the modal.
|
|
106
|
-
*/
|
|
107
104
|
focus() {
|
|
108
105
|
this.box.focus();
|
|
109
106
|
}
|
|
@@ -35,11 +35,11 @@ export class CommitActionConfirm {
|
|
|
35
35
|
}
|
|
36
36
|
setupKeyHandlers() {
|
|
37
37
|
this.box.key(['y', 'Y'], () => {
|
|
38
|
-
this.
|
|
38
|
+
this.destroy();
|
|
39
39
|
this.onConfirm();
|
|
40
40
|
});
|
|
41
|
-
this.box.key(['n', 'N', 'escape'
|
|
42
|
-
this.
|
|
41
|
+
this.box.key(['n', 'N', 'escape'], () => {
|
|
42
|
+
this.destroy();
|
|
43
43
|
this.onCancel();
|
|
44
44
|
});
|
|
45
45
|
}
|
|
@@ -57,7 +57,7 @@ export class CommitActionConfirm {
|
|
|
57
57
|
this.box.setContent(lines.join('\n'));
|
|
58
58
|
this.screen.render();
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
destroy() {
|
|
61
61
|
this.box.destroy();
|
|
62
62
|
}
|
|
63
63
|
focus() {
|
|
@@ -40,11 +40,11 @@ export class DiscardConfirm {
|
|
|
40
40
|
}
|
|
41
41
|
setupKeyHandlers() {
|
|
42
42
|
this.box.key(['y', 'Y'], () => {
|
|
43
|
-
this.
|
|
43
|
+
this.destroy();
|
|
44
44
|
this.onConfirm();
|
|
45
45
|
});
|
|
46
|
-
this.box.key(['n', 'N', 'escape'
|
|
47
|
-
this.
|
|
46
|
+
this.box.key(['n', 'N', 'escape'], () => {
|
|
47
|
+
this.destroy();
|
|
48
48
|
this.onCancel();
|
|
49
49
|
});
|
|
50
50
|
}
|
|
@@ -65,12 +65,9 @@ export class DiscardConfirm {
|
|
|
65
65
|
this.box.setContent(lines.join('\n'));
|
|
66
66
|
this.screen.render();
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
destroy() {
|
|
69
69
|
this.box.destroy();
|
|
70
70
|
}
|
|
71
|
-
/**
|
|
72
|
-
* Focus the modal.
|
|
73
|
-
*/
|
|
74
71
|
focus() {
|
|
75
72
|
this.box.focus();
|
|
76
73
|
}
|
|
@@ -82,14 +82,14 @@ export class FileFinder {
|
|
|
82
82
|
setupKeyHandlers() {
|
|
83
83
|
// Handle escape to cancel
|
|
84
84
|
this.textbox.key(['escape'], () => {
|
|
85
|
-
this.
|
|
85
|
+
this.destroy();
|
|
86
86
|
this.onCancel();
|
|
87
87
|
});
|
|
88
88
|
// Handle enter to select
|
|
89
89
|
this.textbox.key(['enter'], () => {
|
|
90
90
|
if (this.results.length > 0) {
|
|
91
91
|
const selected = this.results[this.selectedIndex];
|
|
92
|
-
this.
|
|
92
|
+
this.destroy();
|
|
93
93
|
this.onSelect(selected.path);
|
|
94
94
|
}
|
|
95
95
|
});
|
|
@@ -187,15 +187,12 @@ export class FileFinder {
|
|
|
187
187
|
this.box.setContent(lines.join('\n'));
|
|
188
188
|
this.screen.render();
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
destroy() {
|
|
191
191
|
if (this.debounceTimer)
|
|
192
192
|
clearTimeout(this.debounceTimer);
|
|
193
193
|
this.textbox.destroy();
|
|
194
194
|
this.box.destroy();
|
|
195
195
|
}
|
|
196
|
-
/**
|
|
197
|
-
* Focus the modal input.
|
|
198
|
-
*/
|
|
199
196
|
focus() {
|
|
200
197
|
this.textbox.focus();
|
|
201
198
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import blessed from 'neo-blessed';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const { version } = require('../../../package.json');
|
|
2
5
|
const hotkeyGroups = [
|
|
3
6
|
{
|
|
4
7
|
title: 'Navigation',
|
|
5
8
|
entries: [
|
|
6
9
|
{ key: 'j/k', description: 'Move up/down' },
|
|
7
|
-
{ key: 'Tab', description: '
|
|
10
|
+
{ key: 'Tab', description: 'Next focus zone' },
|
|
11
|
+
{ key: 'Shift+Tab', description: 'Previous focus zone' },
|
|
8
12
|
],
|
|
9
13
|
},
|
|
10
14
|
{
|
|
@@ -21,12 +25,8 @@ const hotkeyGroups = [
|
|
|
21
25
|
title: 'Actions',
|
|
22
26
|
entries: [
|
|
23
27
|
{ key: 'c', description: 'Commit panel' },
|
|
24
|
-
{ key: 'r', description: '
|
|
28
|
+
{ key: 'r', description: 'Repo picker' },
|
|
25
29
|
{ key: 'q', description: 'Quit' },
|
|
26
|
-
{ key: 'P', description: 'Push to remote' },
|
|
27
|
-
{ key: 'F', description: 'Fetch from remote' },
|
|
28
|
-
{ key: 'R', description: 'Pull --rebase' },
|
|
29
|
-
{ key: 'S', description: 'Stash save (global)' },
|
|
30
30
|
],
|
|
31
31
|
},
|
|
32
32
|
{
|
|
@@ -73,10 +73,6 @@ const hotkeyGroups = [
|
|
|
73
73
|
{ key: 'i/Enter', description: 'Edit message' },
|
|
74
74
|
{ key: 'a', description: 'Toggle amend' },
|
|
75
75
|
{ key: 'Ctrl+a', description: 'Toggle amend (typing)' },
|
|
76
|
-
{ key: 'o', description: 'Pop stash' },
|
|
77
|
-
{ key: 'l', description: 'Stash list modal' },
|
|
78
|
-
{ key: 'b', description: 'Branch picker' },
|
|
79
|
-
{ key: 'X', description: 'Soft reset HEAD~1' },
|
|
80
76
|
],
|
|
81
77
|
},
|
|
82
78
|
{
|
|
@@ -110,6 +106,7 @@ export class HotkeysModal {
|
|
|
110
106
|
box;
|
|
111
107
|
screen;
|
|
112
108
|
onClose;
|
|
109
|
+
screenClickHandler = null;
|
|
113
110
|
constructor(screen, onClose) {
|
|
114
111
|
this.screen = screen;
|
|
115
112
|
this.onClose = onClose;
|
|
@@ -158,15 +155,16 @@ export class HotkeysModal {
|
|
|
158
155
|
}
|
|
159
156
|
}
|
|
160
157
|
setupKeyHandlers() {
|
|
161
|
-
this.box.key(['escape', 'enter'
|
|
162
|
-
this.
|
|
158
|
+
this.box.key(['escape', 'enter'], () => {
|
|
159
|
+
this.destroy();
|
|
163
160
|
this.onClose();
|
|
164
161
|
});
|
|
165
|
-
// Close on click
|
|
166
|
-
this.
|
|
167
|
-
this.
|
|
162
|
+
// Close on any mouse click (screen-level catches clicks outside the modal too)
|
|
163
|
+
this.screenClickHandler = () => {
|
|
164
|
+
this.destroy();
|
|
168
165
|
this.onClose();
|
|
169
|
-
}
|
|
166
|
+
};
|
|
167
|
+
this.screen.on('click', this.screenClickHandler);
|
|
170
168
|
}
|
|
171
169
|
/**
|
|
172
170
|
* Calculate the visible width of a string (excluding blessed tags).
|
|
@@ -214,7 +212,11 @@ export class HotkeysModal {
|
|
|
214
212
|
}
|
|
215
213
|
// Footer
|
|
216
214
|
lines.push('');
|
|
217
|
-
|
|
215
|
+
const closeHint = 'Press Esc, Enter, ?, or click to close';
|
|
216
|
+
const versionLabel = `v${version}`;
|
|
217
|
+
const innerWidth = width - 4; // account for border + padding
|
|
218
|
+
const gap = Math.max(1, innerWidth - closeHint.length - versionLabel.length);
|
|
219
|
+
lines.push(`{gray-fg}${closeHint}${' '.repeat(gap)}${versionLabel}{/gray-fg}`);
|
|
218
220
|
this.box.setContent(lines.join('\n'));
|
|
219
221
|
this.screen.render();
|
|
220
222
|
}
|
|
@@ -229,12 +231,13 @@ export class HotkeysModal {
|
|
|
229
231
|
}
|
|
230
232
|
return lines;
|
|
231
233
|
}
|
|
232
|
-
|
|
234
|
+
destroy() {
|
|
235
|
+
if (this.screenClickHandler) {
|
|
236
|
+
this.screen.removeListener('click', this.screenClickHandler);
|
|
237
|
+
this.screenClickHandler = null;
|
|
238
|
+
}
|
|
233
239
|
this.box.destroy();
|
|
234
240
|
}
|
|
235
|
-
/**
|
|
236
|
-
* Focus the modal.
|
|
237
|
-
*/
|
|
238
241
|
focus() {
|
|
239
242
|
this.box.focus();
|
|
240
243
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
import { abbreviateHomePath } from '../../config.js';
|
|
3
|
+
/**
|
|
4
|
+
* RepoPicker modal for switching between recently-visited repositories.
|
|
5
|
+
*/
|
|
6
|
+
export class RepoPicker {
|
|
7
|
+
box;
|
|
8
|
+
screen;
|
|
9
|
+
repos;
|
|
10
|
+
selectedIndex;
|
|
11
|
+
currentRepo;
|
|
12
|
+
onSelect;
|
|
13
|
+
onCancel;
|
|
14
|
+
constructor(screen, repos, currentRepo, onSelect, onCancel) {
|
|
15
|
+
this.screen = screen;
|
|
16
|
+
this.repos = repos;
|
|
17
|
+
this.currentRepo = currentRepo;
|
|
18
|
+
this.onSelect = onSelect;
|
|
19
|
+
this.onCancel = onCancel;
|
|
20
|
+
// Find current repo index
|
|
21
|
+
this.selectedIndex = repos.indexOf(currentRepo);
|
|
22
|
+
if (this.selectedIndex < 0)
|
|
23
|
+
this.selectedIndex = 0;
|
|
24
|
+
// Create modal box
|
|
25
|
+
const screenWidth = screen.width;
|
|
26
|
+
const width = Math.min(70, screenWidth - 4);
|
|
27
|
+
const maxVisibleRepos = Math.min(repos.length, 15);
|
|
28
|
+
const height = maxVisibleRepos + 6; // repos + header + footer + borders + padding
|
|
29
|
+
this.box = blessed.box({
|
|
30
|
+
parent: screen,
|
|
31
|
+
top: 'center',
|
|
32
|
+
left: 'center',
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
border: {
|
|
36
|
+
type: 'line',
|
|
37
|
+
},
|
|
38
|
+
style: {
|
|
39
|
+
border: {
|
|
40
|
+
fg: 'cyan',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
tags: true,
|
|
44
|
+
keys: true,
|
|
45
|
+
scrollable: true,
|
|
46
|
+
alwaysScroll: true,
|
|
47
|
+
});
|
|
48
|
+
// Setup key handlers
|
|
49
|
+
this.setupKeyHandlers();
|
|
50
|
+
// Initial render
|
|
51
|
+
this.render();
|
|
52
|
+
}
|
|
53
|
+
setupKeyHandlers() {
|
|
54
|
+
this.box.key(['escape'], () => {
|
|
55
|
+
this.destroy();
|
|
56
|
+
this.onCancel();
|
|
57
|
+
});
|
|
58
|
+
this.box.key(['enter', 'space'], () => {
|
|
59
|
+
const selected = this.repos[this.selectedIndex];
|
|
60
|
+
if (selected) {
|
|
61
|
+
this.destroy();
|
|
62
|
+
this.onSelect(selected);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
this.box.key(['up', 'k'], () => {
|
|
66
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
67
|
+
this.render();
|
|
68
|
+
});
|
|
69
|
+
this.box.key(['down', 'j'], () => {
|
|
70
|
+
this.selectedIndex = Math.min(this.repos.length - 1, this.selectedIndex + 1);
|
|
71
|
+
this.render();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
render() {
|
|
75
|
+
const lines = [];
|
|
76
|
+
// Header
|
|
77
|
+
lines.push('{bold}{cyan-fg} Recent Repositories{/cyan-fg}{/bold}');
|
|
78
|
+
lines.push('');
|
|
79
|
+
if (this.repos.length === 0) {
|
|
80
|
+
lines.push('{gray-fg}No recent repositories{/gray-fg}');
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Repo list
|
|
84
|
+
for (let i = 0; i < this.repos.length; i++) {
|
|
85
|
+
const repo = this.repos[i];
|
|
86
|
+
const isSelected = i === this.selectedIndex;
|
|
87
|
+
const isCurrent = repo === this.currentRepo;
|
|
88
|
+
let line = isSelected ? '{cyan-fg}{bold}> ' : ' ';
|
|
89
|
+
line += abbreviateHomePath(repo);
|
|
90
|
+
if (isSelected)
|
|
91
|
+
line += '{/bold}{/cyan-fg}';
|
|
92
|
+
if (isCurrent)
|
|
93
|
+
line += ' {gray-fg}(current){/gray-fg}';
|
|
94
|
+
lines.push(line);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Footer
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push('{gray-fg}j/k: navigate | Enter: select | Esc: cancel{/gray-fg}');
|
|
100
|
+
this.box.setContent(lines.join('\n'));
|
|
101
|
+
this.screen.render();
|
|
102
|
+
}
|
|
103
|
+
destroy() {
|
|
104
|
+
this.box.destroy();
|
|
105
|
+
}
|
|
106
|
+
focus() {
|
|
107
|
+
this.box.focus();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -45,13 +45,13 @@ export class ThemePicker {
|
|
|
45
45
|
this.render();
|
|
46
46
|
}
|
|
47
47
|
setupKeyHandlers() {
|
|
48
|
-
this.box.key(['escape'
|
|
49
|
-
this.
|
|
48
|
+
this.box.key(['escape'], () => {
|
|
49
|
+
this.destroy();
|
|
50
50
|
this.onCancel();
|
|
51
51
|
});
|
|
52
52
|
this.box.key(['enter', 'space'], () => {
|
|
53
53
|
const selected = themeOrder[this.selectedIndex];
|
|
54
|
-
this.
|
|
54
|
+
this.destroy();
|
|
55
55
|
this.onSelect(selected);
|
|
56
56
|
});
|
|
57
57
|
this.box.key(['up', 'k'], () => {
|
|
@@ -94,12 +94,9 @@ export class ThemePicker {
|
|
|
94
94
|
this.box.setContent(lines.join('\n'));
|
|
95
95
|
this.screen.render();
|
|
96
96
|
}
|
|
97
|
-
|
|
97
|
+
destroy() {
|
|
98
98
|
this.box.destroy();
|
|
99
99
|
}
|
|
100
|
-
/**
|
|
101
|
-
* Focus the modal.
|
|
102
|
-
*/
|
|
103
100
|
focus() {
|
|
104
101
|
this.box.focus();
|
|
105
102
|
}
|