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.
Files changed (47) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/dist/App.js +299 -664
  3. package/dist/KeyBindings.js +125 -39
  4. package/dist/ModalController.js +166 -0
  5. package/dist/MouseHandlers.js +43 -25
  6. package/dist/NavigationController.js +290 -0
  7. package/dist/StagingOperations.js +199 -0
  8. package/dist/config.js +39 -0
  9. package/dist/core/CompareManager.js +134 -0
  10. package/dist/core/ExplorerStateManager.js +27 -40
  11. package/dist/core/GitStateManager.js +28 -630
  12. package/dist/core/HistoryManager.js +72 -0
  13. package/dist/core/RemoteOperationManager.js +109 -0
  14. package/dist/core/WorkingTreeManager.js +412 -0
  15. package/dist/git/status.js +95 -0
  16. package/dist/index.js +59 -54
  17. package/dist/state/FocusRing.js +40 -0
  18. package/dist/state/UIState.js +82 -48
  19. package/dist/types/remote.js +5 -0
  20. package/dist/ui/PaneRenderers.js +11 -4
  21. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  22. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  23. package/dist/ui/modals/DiscardConfirm.js +4 -7
  24. package/dist/ui/modals/FileFinder.js +33 -27
  25. package/dist/ui/modals/HotkeysModal.js +32 -13
  26. package/dist/ui/modals/Modal.js +1 -0
  27. package/dist/ui/modals/RepoPicker.js +109 -0
  28. package/dist/ui/modals/ThemePicker.js +4 -7
  29. package/dist/ui/widgets/CommitPanel.js +52 -14
  30. package/dist/ui/widgets/CompareListView.js +1 -11
  31. package/dist/ui/widgets/DiffView.js +2 -27
  32. package/dist/ui/widgets/ExplorerContent.js +1 -4
  33. package/dist/ui/widgets/ExplorerView.js +1 -11
  34. package/dist/ui/widgets/FileList.js +2 -8
  35. package/dist/ui/widgets/Footer.js +1 -0
  36. package/dist/ui/widgets/Header.js +37 -3
  37. package/dist/utils/ansi.js +38 -0
  38. package/dist/utils/ansiTruncate.js +1 -5
  39. package/dist/utils/displayRows.js +72 -59
  40. package/dist/utils/fileCategories.js +7 -0
  41. package/dist/utils/fileResolution.js +23 -0
  42. package/dist/utils/languageDetection.js +3 -2
  43. package/dist/utils/logger.js +32 -0
  44. package/metrics/v0.2.3.json +243 -0
  45. package/metrics/v0.2.4.json +236 -0
  46. package/package.json +5 -2
  47. 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
+ }
@@ -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
- if (this._state.currentPane !== pane) {
48
- this.update({ currentPane: pane });
49
- this.emit('pane-change', pane);
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
- // Map tab to appropriate pane
55
- const paneMap = {
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
- currentPane: paneMap[tab],
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
- // Helper for toggling between panes
184
- togglePane() {
185
- const { bottomTab, currentPane } = this._state;
186
- if (bottomTab === 'diff' || bottomTab === 'commit') {
187
- this.setPane(currentPane === 'files' ? 'diff' : 'files');
188
- }
189
- else if (bottomTab === 'history') {
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
- else if (bottomTab === 'compare') {
193
- this.setPane(currentPane === 'compare' ? 'diff' : 'compare');
194
- }
195
- else if (bottomTab === 'explorer') {
196
- this.setPane(currentPane === 'explorer' ? 'diff' : 'explorer');
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,
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared type for remote operation state (push/fetch/pull).
3
+ * Lives in types/ so both ui/ and core/ can import it.
4
+ */
5
+ export {};
@@ -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 content = formatCommitPanel(commitFlowState, stagedCount, width);
37
- return { content, totalRows: 0, hunkCount: 0, hunkBoundaries: [] };
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', 'q'], () => {
53
- this.close();
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.close();
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
- close() {
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.close();
43
+ this.destroy();
44
44
  this.onConfirm();
45
45
  });
46
- this.box.key(['n', 'N', 'escape', 'q'], () => {
47
- this.close();
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
- close() {
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 using fzf position data.
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 all files
78
+ // Initial render with first N files
77
79
  this.updateResults();
78
- this.render();
80
+ this.renderContent();
79
81
  }
80
82
  setupKeyHandlers() {
81
83
  // Handle escape to cancel
82
84
  this.textbox.key(['escape'], () => {
83
- this.close();
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.close();
91
- this.onSelect(selected.item);
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.render();
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.render();
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.render();
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.render();
114
+ this.renderContent();
113
115
  });
114
- // Update results on keypress
116
+ // Update results on keypress with debounce
115
117
  this.textbox.on('keypress', () => {
116
- // Defer to next tick to get updated value
117
- setImmediate(() => {
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.render();
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((item) => ({ item, positions: new Set(), start: 0, end: 0, score: 0 }));
135
+ .map((p) => ({ path: p, positions: new Set(), score: 0 }));
134
136
  return;
135
137
  }
136
- this.results = this.fzf.find(this.query);
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
- render() {
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.item;
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 using fzf positions
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
- close() {
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: 'Toggle pane focus' },
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: 'Refresh' },
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', '?', 'q'], () => {
139
- this.close();
155
+ this.box.key(['escape', 'enter'], () => {
156
+ this.destroy();
140
157
  this.onClose();
141
158
  });
142
- // Close on click anywhere
143
- this.box.on('click', () => {
144
- this.close();
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 ? to close{/gray-fg}');
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
- close() {
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 {};