diffstalker 0.2.2 → 0.2.4
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/dist/App.js +299 -664
- package/dist/KeyBindings.js +125 -39
- package/dist/ModalController.js +166 -0
- package/dist/MouseHandlers.js +43 -25
- 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 +27 -40
- package/dist/core/GitStateManager.js +28 -630
- package/dist/core/HistoryManager.js +72 -0
- package/dist/core/RemoteOperationManager.js +109 -0
- package/dist/core/WorkingTreeManager.js +412 -0
- package/dist/git/status.js +95 -0
- package/dist/index.js +59 -54
- package/dist/state/FocusRing.js +40 -0
- package/dist/state/UIState.js +82 -48
- package/dist/types/remote.js +5 -0
- package/dist/ui/PaneRenderers.js +11 -4
- package/dist/ui/modals/BaseBranchPicker.js +4 -7
- package/dist/ui/modals/CommitActionConfirm.js +66 -0
- package/dist/ui/modals/DiscardConfirm.js +4 -7
- package/dist/ui/modals/FileFinder.js +33 -27
- package/dist/ui/modals/HotkeysModal.js +32 -13
- 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 +52 -14
- 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/ui/widgets/Header.js +37 -3
- 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.3.json +243 -0
- package/metrics/v0.2.4.json +236 -0
- package/package.json +5 -2
- 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
|
@@ -4,7 +4,7 @@ import { formatHistoryView } from './widgets/HistoryView.js';
|
|
|
4
4
|
import { formatCompareListView } from './widgets/CompareListView.js';
|
|
5
5
|
import { formatExplorerView } from './widgets/ExplorerView.js';
|
|
6
6
|
import { formatDiff, formatCombinedDiff, formatHistoryDiff } from './widgets/DiffView.js';
|
|
7
|
-
import { formatCommitPanel } from './widgets/CommitPanel.js';
|
|
7
|
+
import { formatCommitPanel, getCommitPanelTotalRows } from './widgets/CommitPanel.js';
|
|
8
8
|
import { formatExplorerContent } from './widgets/ExplorerContent.js';
|
|
9
9
|
/**
|
|
10
10
|
* Render the top pane content for the current tab.
|
|
@@ -31,10 +31,17 @@ 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
|
-
const
|
|
37
|
-
|
|
36
|
+
const panelOpts = {
|
|
37
|
+
state: commitFlowState,
|
|
38
|
+
stagedCount,
|
|
39
|
+
width,
|
|
40
|
+
focusedZone,
|
|
41
|
+
};
|
|
42
|
+
const totalRows = getCommitPanelTotalRows(panelOpts);
|
|
43
|
+
const content = formatCommitPanel(commitFlowState, stagedCount, width, state.diffScrollOffset, bottomPaneHeight, focusedZone);
|
|
44
|
+
return { content, totalRows, hunkCount: 0, hunkBoundaries: [] };
|
|
38
45
|
}
|
|
39
46
|
if (state.bottomTab === 'history') {
|
|
40
47
|
const selectedCommit = historyState?.selectedCommit ?? null;
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import blessed from 'neo-blessed';
|
|
2
|
+
/**
|
|
3
|
+
* CommitActionConfirm modal for confirming cherry-pick or revert.
|
|
4
|
+
*/
|
|
5
|
+
export class CommitActionConfirm {
|
|
6
|
+
box;
|
|
7
|
+
screen;
|
|
8
|
+
onConfirm;
|
|
9
|
+
onCancel;
|
|
10
|
+
constructor(screen, verb, commit, onConfirm, onCancel) {
|
|
11
|
+
this.screen = screen;
|
|
12
|
+
this.onConfirm = onConfirm;
|
|
13
|
+
this.onCancel = onCancel;
|
|
14
|
+
const width = Math.min(60, screen.width - 6);
|
|
15
|
+
const height = 8;
|
|
16
|
+
this.box = blessed.box({
|
|
17
|
+
parent: screen,
|
|
18
|
+
top: 'center',
|
|
19
|
+
left: 'center',
|
|
20
|
+
width,
|
|
21
|
+
height,
|
|
22
|
+
border: {
|
|
23
|
+
type: 'line',
|
|
24
|
+
},
|
|
25
|
+
style: {
|
|
26
|
+
border: {
|
|
27
|
+
fg: 'yellow',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
tags: true,
|
|
31
|
+
keys: true,
|
|
32
|
+
});
|
|
33
|
+
this.setupKeyHandlers();
|
|
34
|
+
this.renderContent(verb, commit, width);
|
|
35
|
+
}
|
|
36
|
+
setupKeyHandlers() {
|
|
37
|
+
this.box.key(['y', 'Y'], () => {
|
|
38
|
+
this.destroy();
|
|
39
|
+
this.onConfirm();
|
|
40
|
+
});
|
|
41
|
+
this.box.key(['n', 'N', 'escape'], () => {
|
|
42
|
+
this.destroy();
|
|
43
|
+
this.onCancel();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
renderContent(verb, commit, width) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
const innerWidth = width - 6;
|
|
49
|
+
lines.push(`{bold}{yellow-fg} ${verb} commit?{/yellow-fg}{/bold}`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
const msg = commit.message.length > innerWidth
|
|
52
|
+
? commit.message.slice(0, innerWidth - 3) + '\u2026'
|
|
53
|
+
: commit.message;
|
|
54
|
+
lines.push(`{yellow-fg}${commit.shortHash}{/yellow-fg} ${msg}`);
|
|
55
|
+
lines.push('');
|
|
56
|
+
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}');
|
|
57
|
+
this.box.setContent(lines.join('\n'));
|
|
58
|
+
this.screen.render();
|
|
59
|
+
}
|
|
60
|
+
destroy() {
|
|
61
|
+
this.box.destroy();
|
|
62
|
+
}
|
|
63
|
+
focus() {
|
|
64
|
+
this.box.focus();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import blessed from 'neo-blessed';
|
|
2
2
|
import { Fzf } from 'fzf';
|
|
3
3
|
const MAX_RESULTS = 15;
|
|
4
|
+
const DEBOUNCE_MS = 15;
|
|
4
5
|
/**
|
|
5
|
-
* Highlight matched characters in a display path
|
|
6
|
+
* Highlight matched characters in a display path.
|
|
6
7
|
* The positions set refers to indices in the original full path,
|
|
7
8
|
* so we need an offset when the display path is truncated.
|
|
8
9
|
*/
|
|
@@ -25,19 +26,20 @@ export class FileFinder {
|
|
|
25
26
|
box;
|
|
26
27
|
textbox;
|
|
27
28
|
screen;
|
|
28
|
-
fzf;
|
|
29
29
|
allPaths;
|
|
30
30
|
results = [];
|
|
31
31
|
selectedIndex = 0;
|
|
32
32
|
query = '';
|
|
33
33
|
onSelect;
|
|
34
34
|
onCancel;
|
|
35
|
+
debounceTimer = null;
|
|
36
|
+
fzf;
|
|
35
37
|
constructor(screen, allPaths, onSelect, onCancel) {
|
|
36
38
|
this.screen = screen;
|
|
37
39
|
this.allPaths = allPaths;
|
|
38
|
-
this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, forward: false });
|
|
39
40
|
this.onSelect = onSelect;
|
|
40
41
|
this.onCancel = onCancel;
|
|
42
|
+
this.fzf = new Fzf(allPaths, { limit: MAX_RESULTS, casing: 'smart-case' });
|
|
41
43
|
// Create modal box
|
|
42
44
|
const width = Math.min(80, screen.width - 10);
|
|
43
45
|
const height = MAX_RESULTS + 6; // results + input + header + borders + padding
|
|
@@ -73,69 +75,74 @@ export class FileFinder {
|
|
|
73
75
|
});
|
|
74
76
|
// Setup key handlers
|
|
75
77
|
this.setupKeyHandlers();
|
|
76
|
-
// Initial render with
|
|
78
|
+
// Initial render with first N files
|
|
77
79
|
this.updateResults();
|
|
78
|
-
this.
|
|
80
|
+
this.renderContent();
|
|
79
81
|
}
|
|
80
82
|
setupKeyHandlers() {
|
|
81
83
|
// Handle escape to cancel
|
|
82
84
|
this.textbox.key(['escape'], () => {
|
|
83
|
-
this.
|
|
85
|
+
this.destroy();
|
|
84
86
|
this.onCancel();
|
|
85
87
|
});
|
|
86
88
|
// Handle enter to select
|
|
87
89
|
this.textbox.key(['enter'], () => {
|
|
88
90
|
if (this.results.length > 0) {
|
|
89
91
|
const selected = this.results[this.selectedIndex];
|
|
90
|
-
this.
|
|
91
|
-
this.onSelect(selected.
|
|
92
|
+
this.destroy();
|
|
93
|
+
this.onSelect(selected.path);
|
|
92
94
|
}
|
|
93
95
|
});
|
|
94
96
|
// Handle up/down for navigation (Ctrl+j/k since j/k are for typing)
|
|
95
97
|
this.textbox.key(['C-j', 'down'], () => {
|
|
96
98
|
this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
|
|
97
|
-
this.
|
|
99
|
+
this.renderContent();
|
|
98
100
|
});
|
|
99
101
|
this.textbox.key(['C-k', 'up'], () => {
|
|
100
102
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
101
|
-
this.
|
|
103
|
+
this.renderContent();
|
|
102
104
|
});
|
|
103
105
|
// Handle tab for next result
|
|
104
106
|
this.textbox.key(['tab'], () => {
|
|
105
107
|
this.selectedIndex = (this.selectedIndex + 1) % Math.max(1, this.results.length);
|
|
106
|
-
this.
|
|
108
|
+
this.renderContent();
|
|
107
109
|
});
|
|
108
110
|
// Handle shift-tab for previous result
|
|
109
111
|
this.textbox.key(['S-tab'], () => {
|
|
110
112
|
this.selectedIndex =
|
|
111
113
|
(this.selectedIndex - 1 + this.results.length) % Math.max(1, this.results.length);
|
|
112
|
-
this.
|
|
114
|
+
this.renderContent();
|
|
113
115
|
});
|
|
114
|
-
// Update results on keypress
|
|
116
|
+
// Update results on keypress with debounce
|
|
115
117
|
this.textbox.on('keypress', () => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
if (this.debounceTimer)
|
|
119
|
+
clearTimeout(this.debounceTimer);
|
|
120
|
+
this.debounceTimer = setTimeout(() => {
|
|
118
121
|
const newQuery = this.textbox.getValue() || '';
|
|
119
122
|
if (newQuery !== this.query) {
|
|
120
123
|
this.query = newQuery;
|
|
121
124
|
this.selectedIndex = 0;
|
|
122
125
|
this.updateResults();
|
|
123
|
-
this.
|
|
126
|
+
this.renderContent();
|
|
124
127
|
}
|
|
125
|
-
});
|
|
128
|
+
}, DEBOUNCE_MS);
|
|
126
129
|
});
|
|
127
130
|
}
|
|
128
131
|
updateResults() {
|
|
129
132
|
if (!this.query) {
|
|
130
|
-
// fzf returns nothing for empty query, show first N files
|
|
131
133
|
this.results = this.allPaths
|
|
132
134
|
.slice(0, MAX_RESULTS)
|
|
133
|
-
.map((
|
|
135
|
+
.map((p) => ({ path: p, positions: new Set(), score: 0 }));
|
|
134
136
|
return;
|
|
135
137
|
}
|
|
136
|
-
|
|
138
|
+
const entries = this.fzf.find(this.query);
|
|
139
|
+
this.results = entries.map((entry) => ({
|
|
140
|
+
path: entry.item,
|
|
141
|
+
score: entry.score,
|
|
142
|
+
positions: entry.positions,
|
|
143
|
+
}));
|
|
137
144
|
}
|
|
138
|
-
|
|
145
|
+
renderContent() {
|
|
139
146
|
const lines = [];
|
|
140
147
|
const width = this.box.width - 4;
|
|
141
148
|
// Header
|
|
@@ -151,7 +158,7 @@ export class FileFinder {
|
|
|
151
158
|
const result = this.results[i];
|
|
152
159
|
const isSelected = i === this.selectedIndex;
|
|
153
160
|
// Truncate path if needed
|
|
154
|
-
const fullPath = result.
|
|
161
|
+
const fullPath = result.path;
|
|
155
162
|
const maxLen = width - 4;
|
|
156
163
|
let displayPath = fullPath;
|
|
157
164
|
let offset = 0;
|
|
@@ -161,7 +168,7 @@ export class FileFinder {
|
|
|
161
168
|
// Account for the '…' prefix: display index 0 is '…', actual content starts at 1
|
|
162
169
|
offset = offset - 1;
|
|
163
170
|
}
|
|
164
|
-
// Highlight matched characters
|
|
171
|
+
// Highlight matched characters
|
|
165
172
|
const highlighted = highlightMatch(displayPath, result.positions, offset);
|
|
166
173
|
if (isSelected) {
|
|
167
174
|
lines.push(`{cyan-fg}{bold}> ${highlighted}{/bold}{/cyan-fg}`);
|
|
@@ -180,13 +187,12 @@ export class FileFinder {
|
|
|
180
187
|
this.box.setContent(lines.join('\n'));
|
|
181
188
|
this.screen.render();
|
|
182
189
|
}
|
|
183
|
-
|
|
190
|
+
destroy() {
|
|
191
|
+
if (this.debounceTimer)
|
|
192
|
+
clearTimeout(this.debounceTimer);
|
|
184
193
|
this.textbox.destroy();
|
|
185
194
|
this.box.destroy();
|
|
186
195
|
}
|
|
187
|
-
/**
|
|
188
|
-
* Focus the modal input.
|
|
189
|
-
*/
|
|
190
196
|
focus() {
|
|
191
197
|
this.textbox.focus();
|
|
192
198
|
}
|
|
@@ -4,7 +4,8 @@ const hotkeyGroups = [
|
|
|
4
4
|
title: 'Navigation',
|
|
5
5
|
entries: [
|
|
6
6
|
{ key: 'j/k', description: 'Move up/down' },
|
|
7
|
-
{ key: 'Tab', description: '
|
|
7
|
+
{ key: 'Tab', description: 'Next focus zone' },
|
|
8
|
+
{ key: 'Shift+Tab', description: 'Previous focus zone' },
|
|
8
9
|
],
|
|
9
10
|
},
|
|
10
11
|
{
|
|
@@ -21,7 +22,7 @@ const hotkeyGroups = [
|
|
|
21
22
|
title: 'Actions',
|
|
22
23
|
entries: [
|
|
23
24
|
{ key: 'c', description: 'Commit panel' },
|
|
24
|
-
{ key: 'r', description: '
|
|
25
|
+
{ key: 'r', description: 'Repo picker' },
|
|
25
26
|
{ key: 'q', description: 'Quit' },
|
|
26
27
|
],
|
|
27
28
|
},
|
|
@@ -63,6 +64,21 @@ const hotkeyGroups = [
|
|
|
63
64
|
{ key: 'g', description: 'Show changes only' },
|
|
64
65
|
],
|
|
65
66
|
},
|
|
67
|
+
{
|
|
68
|
+
title: 'Commit Panel',
|
|
69
|
+
entries: [
|
|
70
|
+
{ key: 'i/Enter', description: 'Edit message' },
|
|
71
|
+
{ key: 'a', description: 'Toggle amend' },
|
|
72
|
+
{ key: 'Ctrl+a', description: 'Toggle amend (typing)' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
title: 'History',
|
|
77
|
+
entries: [
|
|
78
|
+
{ key: 'p', description: 'Cherry-pick commit' },
|
|
79
|
+
{ key: 'v', description: 'Revert commit' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
66
82
|
{
|
|
67
83
|
title: 'Compare',
|
|
68
84
|
entries: [
|
|
@@ -87,6 +103,7 @@ export class HotkeysModal {
|
|
|
87
103
|
box;
|
|
88
104
|
screen;
|
|
89
105
|
onClose;
|
|
106
|
+
screenClickHandler = null;
|
|
90
107
|
constructor(screen, onClose) {
|
|
91
108
|
this.screen = screen;
|
|
92
109
|
this.onClose = onClose;
|
|
@@ -135,15 +152,16 @@ export class HotkeysModal {
|
|
|
135
152
|
}
|
|
136
153
|
}
|
|
137
154
|
setupKeyHandlers() {
|
|
138
|
-
this.box.key(['escape', 'enter'
|
|
139
|
-
this.
|
|
155
|
+
this.box.key(['escape', 'enter'], () => {
|
|
156
|
+
this.destroy();
|
|
140
157
|
this.onClose();
|
|
141
158
|
});
|
|
142
|
-
// Close on click
|
|
143
|
-
this.
|
|
144
|
-
this.
|
|
159
|
+
// Close on any mouse click (screen-level catches clicks outside the modal too)
|
|
160
|
+
this.screenClickHandler = () => {
|
|
161
|
+
this.destroy();
|
|
145
162
|
this.onClose();
|
|
146
|
-
}
|
|
163
|
+
};
|
|
164
|
+
this.screen.on('click', this.screenClickHandler);
|
|
147
165
|
}
|
|
148
166
|
/**
|
|
149
167
|
* Calculate the visible width of a string (excluding blessed tags).
|
|
@@ -191,7 +209,7 @@ export class HotkeysModal {
|
|
|
191
209
|
}
|
|
192
210
|
// Footer
|
|
193
211
|
lines.push('');
|
|
194
|
-
lines.push('{gray-fg}Press Esc, Enter, or
|
|
212
|
+
lines.push('{gray-fg}Press Esc, Enter, ?, or click to close{/gray-fg}');
|
|
195
213
|
this.box.setContent(lines.join('\n'));
|
|
196
214
|
this.screen.render();
|
|
197
215
|
}
|
|
@@ -206,12 +224,13 @@ export class HotkeysModal {
|
|
|
206
224
|
}
|
|
207
225
|
return lines;
|
|
208
226
|
}
|
|
209
|
-
|
|
227
|
+
destroy() {
|
|
228
|
+
if (this.screenClickHandler) {
|
|
229
|
+
this.screen.removeListener('click', this.screenClickHandler);
|
|
230
|
+
this.screenClickHandler = null;
|
|
231
|
+
}
|
|
210
232
|
this.box.destroy();
|
|
211
233
|
}
|
|
212
|
-
/**
|
|
213
|
-
* Focus the modal.
|
|
214
|
-
*/
|
|
215
234
|
focus() {
|
|
216
235
|
this.box.focus();
|
|
217
236
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|