git-watchtower 1.6.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Extracted keyboard action handlers (pure state reducers).
3
+ *
4
+ * Each function takes the current state object (and optional context)
5
+ * and returns an object of state updates, or null when no change is needed.
6
+ * Async side-effects (git operations, server calls) are intentionally
7
+ * excluded -- only synchronous state mutations live here.
8
+ *
9
+ * @module ui/actions
10
+ */
11
+
12
+ const {
13
+ isPrintableChar,
14
+ isBackspaceKey,
15
+ isEscapeKey,
16
+ isEnterKey,
17
+ KEYS,
18
+ filterBranches,
19
+ } = require('./keybindings');
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Return the branch list that is currently visible (filtered or full).
27
+ * @param {object} state
28
+ * @returns {Array<{name: string}>}
29
+ */
30
+ function getDisplayBranches(state) {
31
+ return state.filteredBranches !== null ? state.filteredBranches : state.branches;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Navigation
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Move the selection cursor up by one row.
40
+ * @param {object} state
41
+ * @returns {object|null} State updates, or null if already at the top.
42
+ */
43
+ function moveUp(state) {
44
+ const displayBranches = getDisplayBranches(state);
45
+ if (state.selectedIndex > 0) {
46
+ const newIndex = state.selectedIndex - 1;
47
+ return {
48
+ selectedIndex: newIndex,
49
+ selectedBranchName: displayBranches[newIndex] ? displayBranches[newIndex].name : null,
50
+ };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Move the selection cursor down by one row.
57
+ * @param {object} state
58
+ * @returns {object|null} State updates, or null if already at the bottom.
59
+ */
60
+ function moveDown(state) {
61
+ const displayBranches = getDisplayBranches(state);
62
+ if (state.selectedIndex < displayBranches.length - 1) {
63
+ const newIndex = state.selectedIndex + 1;
64
+ return {
65
+ selectedIndex: newIndex,
66
+ selectedBranchName: displayBranches[newIndex] ? displayBranches[newIndex].name : null,
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Search mode
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Enter search (filter) mode, resetting the query and cursor.
78
+ * @param {object} state
79
+ * @returns {object} State updates.
80
+ */
81
+ function enterSearchMode(state) {
82
+ return {
83
+ searchMode: true,
84
+ searchQuery: '',
85
+ selectedIndex: 0,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Process a single keypress while in search mode.
91
+ *
92
+ * - Escape cancels the search and clears the filter.
93
+ * - Enter confirms the current filter and exits search mode.
94
+ * - Backspace removes the last character from the query.
95
+ * - Printable characters are appended to the query.
96
+ *
97
+ * @param {object} state
98
+ * @param {string} key - The raw key string from stdin.
99
+ * @returns {object|null} State updates, or null if the key was not handled.
100
+ */
101
+ function handleSearchInput(state, key) {
102
+ if (isEscapeKey(key) || isEnterKey(key)) {
103
+ const updates = { searchMode: false };
104
+ if (isEscapeKey(key)) {
105
+ updates.searchQuery = '';
106
+ updates.filteredBranches = null;
107
+ }
108
+ return updates;
109
+ }
110
+
111
+ if (isBackspaceKey(key)) {
112
+ const newQuery = state.searchQuery.slice(0, -1);
113
+ const filtered = filterBranches(state.branches, newQuery);
114
+ let newIndex = state.selectedIndex;
115
+ if (filtered && newIndex >= filtered.length) {
116
+ newIndex = Math.max(0, filtered.length - 1);
117
+ }
118
+ return {
119
+ searchQuery: newQuery,
120
+ filteredBranches: filtered,
121
+ selectedIndex: newIndex,
122
+ };
123
+ }
124
+
125
+ if (isPrintableChar(key)) {
126
+ const newQuery = state.searchQuery + key;
127
+ const filtered = filterBranches(state.branches, newQuery);
128
+ let newIndex = state.selectedIndex;
129
+ if (filtered && newIndex >= filtered.length) {
130
+ newIndex = Math.max(0, filtered.length - 1);
131
+ }
132
+ return {
133
+ searchQuery: newQuery,
134
+ filteredBranches: filtered,
135
+ selectedIndex: newIndex,
136
+ };
137
+ }
138
+
139
+ return null; // Key not handled
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Modal toggles
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Toggle the diff/preview panel.
148
+ * When opening, the caller is responsible for loading preview data
149
+ * asynchronously after applying the returned state updates.
150
+ * @param {object} state
151
+ * @returns {object} State updates.
152
+ */
153
+ function togglePreview(state) {
154
+ if (state.previewMode) {
155
+ return { previewMode: false, previewData: null };
156
+ }
157
+ // Opening preview requires async data loading -- return flag indicating need.
158
+ return { previewMode: true };
159
+ }
160
+
161
+ /**
162
+ * Toggle the commit history panel.
163
+ * @param {object} state
164
+ * @returns {object} State updates.
165
+ */
166
+ function toggleHistory(state) {
167
+ return { historyMode: !state.historyMode };
168
+ }
169
+
170
+ /**
171
+ * Toggle the info/help panel.
172
+ * @param {object} state
173
+ * @returns {object} State updates.
174
+ */
175
+ function toggleInfo(state) {
176
+ return { infoMode: !state.infoMode };
177
+ }
178
+
179
+ /**
180
+ * Toggle the log viewer panel.
181
+ * No-ops when running without a server (`state.noServer`).
182
+ * @param {object} state
183
+ * @returns {object|null} State updates, or null if no server is configured.
184
+ */
185
+ function toggleLogView(state) {
186
+ if (state.noServer) return null;
187
+ if (state.logViewMode) {
188
+ return { logViewMode: false, logScrollOffset: 0 };
189
+ }
190
+ return { logViewMode: true, logScrollOffset: 0 };
191
+ }
192
+
193
+ /**
194
+ * Close the action confirmation modal, clearing its data and loading flag.
195
+ * @param {object} state
196
+ * @returns {object} State updates.
197
+ */
198
+ function closeActionModal(state) {
199
+ return { actionMode: false, actionData: null, actionLoading: false };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Log view actions
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * Switch the active tab inside the log viewer.
208
+ * @param {object} state
209
+ * @param {string} tab - The tab identifier (e.g. 'server' or 'activity').
210
+ * @returns {object} State updates.
211
+ */
212
+ function switchLogTab(state, tab) {
213
+ return { logViewTab: tab, logScrollOffset: 0 };
214
+ }
215
+
216
+ /**
217
+ * Scroll the log viewer up or down by one line.
218
+ * @param {object} state
219
+ * @param {'up'|'down'} direction
220
+ * @returns {object} State updates.
221
+ */
222
+ function scrollLog(state, direction) {
223
+ const logData = state.logViewTab === 'server' ? state.serverLogBuffer : state.activityLog;
224
+ const maxScroll = Math.max(0, logData.length - 10);
225
+
226
+ if (direction === 'up') {
227
+ return { logScrollOffset: Math.min(state.logScrollOffset + 1, maxScroll) };
228
+ } else {
229
+ return { logScrollOffset: Math.max(0, state.logScrollOffset - 1) };
230
+ }
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Settings
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Toggle the notification sound on or off.
239
+ * @param {object} state
240
+ * @returns {object} State updates.
241
+ */
242
+ function toggleSound(state) {
243
+ return { soundEnabled: !state.soundEnabled };
244
+ }
245
+
246
+ /**
247
+ * Set the number of visible branches to an exact value.
248
+ * @param {object} state
249
+ * @param {number} count
250
+ * @returns {object} State updates.
251
+ */
252
+ function setVisibleBranchCount(state, count) {
253
+ return { visibleBranchCount: count };
254
+ }
255
+
256
+ /**
257
+ * Increase the visible branch count by one, up to a screen-imposed maximum.
258
+ * @param {object} state
259
+ * @param {number} maxForScreen - Maximum branches that fit on the current terminal.
260
+ * @returns {object|null} State updates, or null if already at max.
261
+ */
262
+ function increaseVisibleBranches(state, maxForScreen) {
263
+ if (state.visibleBranchCount < maxForScreen) {
264
+ return { visibleBranchCount: state.visibleBranchCount + 1 };
265
+ }
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * Decrease the visible branch count by one, with a minimum of 1.
271
+ * @param {object} state
272
+ * @returns {object|null} State updates, or null if already at minimum.
273
+ */
274
+ function decreaseVisibleBranches(state) {
275
+ if (state.visibleBranchCount > 1) {
276
+ return { visibleBranchCount: state.visibleBranchCount - 1 };
277
+ }
278
+ return null;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Cleanup confirm modal
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Open the cleanup confirmation modal.
287
+ * @param {object} state
288
+ * @param {string[]} goneBranches - Branch names to be cleaned up
289
+ * @returns {object} State updates.
290
+ */
291
+ function openCleanupConfirm(state, goneBranches) {
292
+ return {
293
+ cleanupConfirmMode: true,
294
+ cleanupBranches: goneBranches,
295
+ cleanupSelectedIndex: 0,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Close the cleanup confirmation modal.
301
+ * @param {object} state
302
+ * @returns {object} State updates.
303
+ */
304
+ function closeCleanupConfirm(state) {
305
+ return {
306
+ cleanupConfirmMode: false,
307
+ cleanupBranches: null,
308
+ cleanupSelectedIndex: 0,
309
+ };
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Dismiss flash / error
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /**
317
+ * Dismiss the current flash message.
318
+ * @param {object} state
319
+ * @returns {object|null} State updates, or null if there is no flash message.
320
+ */
321
+ function dismissFlash(state) {
322
+ if (state.flashMessage) {
323
+ return { flashMessage: null };
324
+ }
325
+ return null;
326
+ }
327
+
328
+ /**
329
+ * Dismiss the current error toast.
330
+ * @param {object} state
331
+ * @returns {object|null} State updates, or null if there is no error toast.
332
+ */
333
+ function dismissErrorToast(state) {
334
+ if (state.errorToast) {
335
+ return { errorToast: null };
336
+ }
337
+ return null;
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Escape handler (normal mode)
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /**
345
+ * Handle the Escape key in normal mode.
346
+ *
347
+ * If a search filter is active, clears it. Otherwise signals a quit by
348
+ * returning `{ _quit: true }`.
349
+ *
350
+ * @param {object} state
351
+ * @returns {object} State updates (may include `_quit: true`).
352
+ */
353
+ function handleEscape(state) {
354
+ if (state.searchQuery || state.filteredBranches) {
355
+ return { searchQuery: '', filteredBranches: null };
356
+ }
357
+ // Quit signal
358
+ return { _quit: true };
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Selection query
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /**
366
+ * Return the branch object that is currently highlighted, or null if the
367
+ * selection is out of range or the list is empty.
368
+ * @param {object} state
369
+ * @returns {object|null} The selected branch, or null.
370
+ */
371
+ function getSelectedBranch(state) {
372
+ const displayBranches = getDisplayBranches(state);
373
+ if (displayBranches.length > 0 && state.selectedIndex < displayBranches.length) {
374
+ return displayBranches[state.selectedIndex];
375
+ }
376
+ return null;
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Exports
381
+ // ---------------------------------------------------------------------------
382
+
383
+ module.exports = {
384
+ // helpers
385
+ getDisplayBranches,
386
+
387
+ // navigation
388
+ moveUp,
389
+ moveDown,
390
+
391
+ // search
392
+ enterSearchMode,
393
+ handleSearchInput,
394
+
395
+ // modal toggles
396
+ togglePreview,
397
+ toggleHistory,
398
+ toggleInfo,
399
+ toggleLogView,
400
+ closeActionModal,
401
+
402
+ // cleanup confirm
403
+ openCleanupConfirm,
404
+ closeCleanupConfirm,
405
+
406
+ // log view
407
+ switchLogTab,
408
+ scrollLog,
409
+
410
+ // settings
411
+ toggleSound,
412
+ setVisibleBranchCount,
413
+ increaseVisibleBranches,
414
+ decreaseVisibleBranches,
415
+
416
+ // dismiss
417
+ dismissFlash,
418
+ dismissErrorToast,
419
+
420
+ // escape
421
+ handleEscape,
422
+
423
+ // selection
424
+ getSelectedBranch,
425
+ };