git-watchtower 1.6.0 → 1.7.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/src/ui/ansi.js ADDED
@@ -0,0 +1,498 @@
1
+ /**
2
+ * ANSI escape codes and box drawing characters for terminal UI
3
+ * Provides consistent terminal styling across the application
4
+ */
5
+
6
+ // ANSI escape sequence components
7
+ const ESC = '\x1b';
8
+ const CSI = `${ESC}[`;
9
+
10
+ /**
11
+ * ANSI escape codes for terminal control and styling
12
+ */
13
+ const ansi = {
14
+ // Screen control
15
+ clearScreen: `${CSI}2J`,
16
+ clearLine: `${CSI}2K`,
17
+ clearToEndOfLine: `${CSI}K`,
18
+ clearToEndOfScreen: `${CSI}J`,
19
+
20
+ /**
21
+ * Move cursor to specific position (1-indexed)
22
+ * @param {number} row - Row number (1-based)
23
+ * @param {number} col - Column number (1-based)
24
+ * @returns {string}
25
+ */
26
+ moveTo: (row, col) => `${CSI}${row};${col}H`,
27
+
28
+ moveToTop: `${CSI}H`,
29
+ moveUp: (n = 1) => `${CSI}${n}A`,
30
+ moveDown: (n = 1) => `${CSI}${n}B`,
31
+ moveRight: (n = 1) => `${CSI}${n}C`,
32
+ moveLeft: (n = 1) => `${CSI}${n}D`,
33
+
34
+ // Cursor visibility
35
+ hideCursor: `${CSI}?25l`,
36
+ showCursor: `${CSI}?25h`,
37
+
38
+ // Alternate screen buffer (for full-screen TUI)
39
+ saveScreen: `${CSI}?1049h`,
40
+ restoreScreen: `${CSI}?1049l`,
41
+
42
+ // Save/restore cursor position
43
+ saveCursor: `${CSI}s`,
44
+ restoreCursor: `${CSI}u`,
45
+
46
+ // Text styles
47
+ reset: `${CSI}0m`,
48
+ bold: `${CSI}1m`,
49
+ dim: `${CSI}2m`,
50
+ italic: `${CSI}3m`,
51
+ underline: `${CSI}4m`,
52
+ blink: `${CSI}5m`,
53
+ inverse: `${CSI}7m`,
54
+ hidden: `${CSI}8m`,
55
+ strikethrough: `${CSI}9m`,
56
+
57
+ // Reset specific styles
58
+ resetBold: `${CSI}22m`,
59
+ resetDim: `${CSI}22m`,
60
+ resetItalic: `${CSI}23m`,
61
+ resetUnderline: `${CSI}24m`,
62
+ resetBlink: `${CSI}25m`,
63
+ resetInverse: `${CSI}27m`,
64
+ resetHidden: `${CSI}28m`,
65
+ resetStrikethrough: `${CSI}29m`,
66
+
67
+ // Foreground colors (standard)
68
+ black: `${CSI}30m`,
69
+ red: `${CSI}31m`,
70
+ green: `${CSI}32m`,
71
+ yellow: `${CSI}33m`,
72
+ blue: `${CSI}34m`,
73
+ magenta: `${CSI}35m`,
74
+ cyan: `${CSI}36m`,
75
+ white: `${CSI}37m`,
76
+ default: `${CSI}39m`,
77
+
78
+ // Foreground colors (bright)
79
+ gray: `${CSI}90m`,
80
+ brightRed: `${CSI}91m`,
81
+ brightGreen: `${CSI}92m`,
82
+ brightYellow: `${CSI}93m`,
83
+ brightBlue: `${CSI}94m`,
84
+ brightMagenta: `${CSI}95m`,
85
+ brightCyan: `${CSI}96m`,
86
+ brightWhite: `${CSI}97m`,
87
+
88
+ // Background colors (standard)
89
+ bgBlack: `${CSI}40m`,
90
+ bgRed: `${CSI}41m`,
91
+ bgGreen: `${CSI}42m`,
92
+ bgYellow: `${CSI}43m`,
93
+ bgBlue: `${CSI}44m`,
94
+ bgMagenta: `${CSI}45m`,
95
+ bgCyan: `${CSI}46m`,
96
+ bgWhite: `${CSI}47m`,
97
+ bgDefault: `${CSI}49m`,
98
+
99
+ // Background colors (bright)
100
+ bgGray: `${CSI}100m`,
101
+ bgBrightRed: `${CSI}101m`,
102
+ bgBrightGreen: `${CSI}102m`,
103
+ bgBrightYellow: `${CSI}103m`,
104
+ bgBrightBlue: `${CSI}104m`,
105
+ bgBrightMagenta: `${CSI}105m`,
106
+ bgBrightCyan: `${CSI}106m`,
107
+ bgBrightWhite: `${CSI}107m`,
108
+
109
+ /**
110
+ * Set foreground color using 256-color palette
111
+ * @param {number} n - Color number (0-255)
112
+ * @returns {string}
113
+ */
114
+ fg256: (n) => `${CSI}38;5;${n}m`,
115
+
116
+ /**
117
+ * Set background color using 256-color palette
118
+ * @param {number} n - Color number (0-255)
119
+ * @returns {string}
120
+ */
121
+ bg256: (n) => `${CSI}48;5;${n}m`,
122
+
123
+ /**
124
+ * Set foreground color using RGB
125
+ * @param {number} r - Red (0-255)
126
+ * @param {number} g - Green (0-255)
127
+ * @param {number} b - Blue (0-255)
128
+ * @returns {string}
129
+ */
130
+ fgRgb: (r, g, b) => `${CSI}38;2;${r};${g};${b}m`,
131
+
132
+ /**
133
+ * Set background color using RGB
134
+ * @param {number} r - Red (0-255)
135
+ * @param {number} g - Green (0-255)
136
+ * @param {number} b - Blue (0-255)
137
+ * @returns {string}
138
+ */
139
+ bgRgb: (r, g, b) => `${CSI}48;2;${r};${g};${b}m`,
140
+ };
141
+
142
+ /**
143
+ * Box drawing characters for terminal UI borders
144
+ */
145
+ const box = {
146
+ // Single line box (light)
147
+ topLeft: '┌',
148
+ topRight: '┐',
149
+ bottomLeft: '└',
150
+ bottomRight: '┘',
151
+ horizontal: '─',
152
+ vertical: '│',
153
+ teeRight: '├',
154
+ teeLeft: '┤',
155
+ teeDown: '┬',
156
+ teeUp: '┴',
157
+ cross: '┼',
158
+
159
+ // Double line box
160
+ dTopLeft: '╔',
161
+ dTopRight: '╗',
162
+ dBottomLeft: '╚',
163
+ dBottomRight: '╝',
164
+ dHorizontal: '═',
165
+ dVertical: '║',
166
+ dTeeRight: '╠',
167
+ dTeeLeft: '╣',
168
+ dTeeDown: '╦',
169
+ dTeeUp: '╩',
170
+ dCross: '╬',
171
+
172
+ // Rounded corners
173
+ rTopLeft: '╭',
174
+ rTopRight: '╮',
175
+ rBottomLeft: '╰',
176
+ rBottomRight: '╯',
177
+
178
+ // Heavy (thick) box
179
+ hTopLeft: '┏',
180
+ hTopRight: '┓',
181
+ hBottomLeft: '┗',
182
+ hBottomRight: '┛',
183
+ hHorizontal: '━',
184
+ hVertical: '┃',
185
+ };
186
+
187
+ /**
188
+ * Sparkline characters for activity visualization
189
+ */
190
+ const sparkline = {
191
+ chars: ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'],
192
+ empty: ' ',
193
+ };
194
+
195
+ /**
196
+ * Generate a sparkline visualization from data points
197
+ * @param {number[]} dataPoints - Array of numeric values
198
+ * @param {Object} [options] - Generation options
199
+ * @param {number} [options.min] - Minimum value (defaults to data min)
200
+ * @param {number} [options.max] - Maximum value (defaults to data max)
201
+ * @param {string} [options.emptyChar=' '] - Character for zero/empty values
202
+ * @returns {string} Sparkline string
203
+ */
204
+ function generateSparkline(dataPoints, options = {}) {
205
+ if (!Array.isArray(dataPoints) || dataPoints.length === 0) {
206
+ return '';
207
+ }
208
+
209
+ const { emptyChar = sparkline.empty } = options;
210
+ const chars = sparkline.chars;
211
+ const levels = chars.length;
212
+
213
+ // Filter to valid numbers
214
+ const validPoints = dataPoints.map((p) => (typeof p === 'number' && !isNaN(p) ? p : 0));
215
+
216
+ // Determine range
217
+ const dataMin = Math.min(...validPoints);
218
+ const dataMax = Math.max(...validPoints);
219
+ const min = options.min !== undefined ? options.min : dataMin;
220
+ const max = options.max !== undefined ? options.max : dataMax;
221
+
222
+ // Handle edge case where all values are the same
223
+ const range = max - min;
224
+ if (range === 0) {
225
+ // All same value - if all zeros, show empty; otherwise show middle level
226
+ return validPoints.map((p) => (p === 0 ? emptyChar : chars[Math.floor(levels / 2)])).join('');
227
+ }
228
+
229
+ // Map each point to a character
230
+ return validPoints
231
+ .map((value) => {
232
+ if (value === 0) {
233
+ return emptyChar;
234
+ }
235
+ // Normalize to 0-1 range, then map to character index
236
+ const normalized = (value - min) / range;
237
+ const index = Math.min(Math.floor(normalized * levels), levels - 1);
238
+ return chars[index];
239
+ })
240
+ .join('');
241
+ }
242
+
243
+ /**
244
+ * Status indicator characters
245
+ */
246
+ const indicators = {
247
+ bullet: '•',
248
+ circle: '○',
249
+ circleFilled: '●',
250
+ check: '✓',
251
+ cross: '✗',
252
+ star: '★',
253
+ starEmpty: '☆',
254
+ diamond: '◆',
255
+ diamondEmpty: '◇',
256
+ arrow: {
257
+ right: '→',
258
+ left: '←',
259
+ up: '↑',
260
+ down: '↓',
261
+ },
262
+ spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
263
+ };
264
+
265
+ /**
266
+ * Helper functions for working with ANSI codes
267
+ */
268
+
269
+ /**
270
+ * Strip ANSI codes from a string
271
+ * @param {string} str - String potentially containing ANSI codes
272
+ * @returns {string} String with ANSI codes removed
273
+ */
274
+ function stripAnsi(str) {
275
+ // eslint-disable-next-line no-control-regex
276
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
277
+ }
278
+
279
+ /**
280
+ * Get the visible length of a string (excluding ANSI codes)
281
+ * @param {string} str - String potentially containing ANSI codes
282
+ * @returns {number} Visible character count
283
+ */
284
+ function visibleLength(str) {
285
+ return stripAnsi(str).length;
286
+ }
287
+
288
+ /**
289
+ * Truncate a string to a maximum visible length, preserving ANSI codes
290
+ * @param {string} str - String to truncate
291
+ * @param {number} maxLen - Maximum visible length
292
+ * @param {string} [suffix='…'] - Suffix to append if truncated
293
+ * @returns {string} Truncated string
294
+ */
295
+ function truncate(str, maxLen, suffix = '…') {
296
+ const visible = stripAnsi(str);
297
+ if (visible.length <= maxLen) {
298
+ return str;
299
+ }
300
+
301
+ // Simple approach: strip ANSI, truncate, add suffix and reset
302
+ const truncated = visible.slice(0, maxLen - suffix.length);
303
+ return truncated + suffix + ansi.reset;
304
+ }
305
+
306
+ /**
307
+ * Pad a string to a specific visible length
308
+ * @param {string} str - String to pad
309
+ * @param {number} len - Target visible length
310
+ * @param {string} [char=' '] - Padding character
311
+ * @param {'left' | 'right' | 'center'} [align='right'] - Alignment
312
+ * @returns {string} Padded string
313
+ */
314
+ function pad(str, len, char = ' ', align = 'right') {
315
+ const visible = visibleLength(str);
316
+ if (visible >= len) {
317
+ return str;
318
+ }
319
+
320
+ const padding = char.repeat(len - visible);
321
+
322
+ switch (align) {
323
+ case 'left':
324
+ return padding + str;
325
+ case 'center': {
326
+ const left = Math.floor(padding.length / 2);
327
+ const right = padding.length - left;
328
+ return char.repeat(left) + str + char.repeat(right);
329
+ }
330
+ case 'right':
331
+ default:
332
+ return str + padding;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Wrap text to a maximum width
338
+ * @param {string} text - Text to wrap
339
+ * @param {number} width - Maximum line width
340
+ * @returns {string[]} Array of wrapped lines
341
+ */
342
+ function wordWrap(text, width) {
343
+ const words = text.split(' ');
344
+ const lines = [];
345
+ let currentLine = '';
346
+
347
+ for (const word of words) {
348
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
349
+ if (visibleLength(testLine) <= width) {
350
+ currentLine = testLine;
351
+ } else {
352
+ if (currentLine) {
353
+ lines.push(currentLine);
354
+ }
355
+ currentLine = word;
356
+ }
357
+ }
358
+
359
+ if (currentLine) {
360
+ lines.push(currentLine);
361
+ }
362
+
363
+ return lines;
364
+ }
365
+
366
+ /**
367
+ * Create a horizontal line
368
+ * @param {number} width - Line width
369
+ * @param {string} [char=box.horizontal] - Character to use
370
+ * @returns {string}
371
+ */
372
+ function horizontalLine(width, char = box.horizontal) {
373
+ return char.repeat(width);
374
+ }
375
+
376
+ /**
377
+ * Create a styled text string
378
+ * @param {string} text - Text to style
379
+ * @param {...string} styles - ANSI style codes to apply
380
+ * @returns {string}
381
+ */
382
+ function style(text, ...styles) {
383
+ if (styles.length === 0) {
384
+ return text;
385
+ }
386
+ return styles.join('') + text + ansi.reset;
387
+ }
388
+
389
+ /**
390
+ * Pad a string on the right, truncating if too long (uses raw string length)
391
+ * @param {string} str - String to pad
392
+ * @param {number} len - Target length
393
+ * @returns {string}
394
+ */
395
+ function padRight(str, len) {
396
+ if (str.length >= len) return str.substring(0, len);
397
+ return str + ' '.repeat(len - str.length);
398
+ }
399
+
400
+ /**
401
+ * Pad a string on the left, truncating if too long (uses raw string length)
402
+ * @param {string} str - String to pad
403
+ * @param {number} len - Target length
404
+ * @returns {string}
405
+ */
406
+ function padLeft(str, len) {
407
+ if (str.length >= len) return str.substring(0, len);
408
+ return ' '.repeat(len - str.length) + str;
409
+ }
410
+
411
+ /**
412
+ * Calculate maximum branches that fit on screen
413
+ * @param {number} terminalHeight - Terminal height in rows
414
+ * @param {number} [maxLogEntries=10] - Max activity log entries shown
415
+ * @returns {number}
416
+ */
417
+ function getMaxBranchesForScreen(terminalHeight, maxLogEntries = 10) {
418
+ // header(2) + branch box + log box(~12) + footer(2)
419
+ // Each branch takes 2 rows, plus 4 for box borders
420
+ const availableHeight = terminalHeight - 2 - maxLogEntries - 5 - 2;
421
+ return Math.max(1, Math.floor(availableHeight / 2));
422
+ }
423
+
424
+ /**
425
+ * Draw a box at a specific position (returns ANSI string)
426
+ * @param {number} row - Starting row
427
+ * @param {number} col - Starting column
428
+ * @param {number} width - Box width
429
+ * @param {number} height - Box height
430
+ * @param {string} [title=''] - Optional title
431
+ * @param {string} [titleColor] - ANSI color code for title
432
+ * @returns {string} ANSI escape sequence string for the box
433
+ */
434
+ function drawBox(row, col, width, height, title = '', titleColor = ansi.cyan) {
435
+ let out = '';
436
+ // Top border
437
+ out += ansi.moveTo(row, col);
438
+ out += ansi.gray + box.topLeft + box.horizontal.repeat(width - 2) + box.topRight + ansi.reset;
439
+
440
+ // Title
441
+ if (title) {
442
+ out += ansi.moveTo(row, col + 2);
443
+ out += ansi.gray + ' ' + titleColor + title + ansi.gray + ' ' + ansi.reset;
444
+ }
445
+
446
+ // Sides
447
+ for (let i = 1; i < height - 1; i++) {
448
+ out += ansi.moveTo(row + i, col);
449
+ out += ansi.gray + box.vertical + ansi.reset;
450
+ out += ansi.moveTo(row + i, col + width - 1);
451
+ out += ansi.gray + box.vertical + ansi.reset;
452
+ }
453
+
454
+ // Bottom border
455
+ out += ansi.moveTo(row + height - 1, col);
456
+ out += ansi.gray + box.bottomLeft + box.horizontal.repeat(width - 2) + box.bottomRight + ansi.reset;
457
+ return out;
458
+ }
459
+
460
+ /**
461
+ * Clear a rectangular area (returns ANSI string)
462
+ * @param {number} row - Starting row
463
+ * @param {number} col - Starting column
464
+ * @param {number} width - Area width
465
+ * @param {number} height - Area height
466
+ * @returns {string}
467
+ */
468
+ function clearArea(row, col, width, height) {
469
+ let out = '';
470
+ for (let i = 0; i < height; i++) {
471
+ out += ansi.moveTo(row + i, col);
472
+ out += ' '.repeat(width);
473
+ }
474
+ return out;
475
+ }
476
+
477
+ module.exports = {
478
+ ansi,
479
+ box,
480
+ sparkline,
481
+ generateSparkline,
482
+ indicators,
483
+ stripAnsi,
484
+ visibleLength,
485
+ truncate,
486
+ pad,
487
+ padRight,
488
+ padLeft,
489
+ getMaxBranchesForScreen,
490
+ drawBox,
491
+ clearArea,
492
+ wordWrap,
493
+ horizontalLine,
494
+ style,
495
+ // Export constants for direct use
496
+ ESC,
497
+ CSI,
498
+ };
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Keyboard input handling - key constants and mode management
3
+ * @module ui/keybindings
4
+ */
5
+
6
+ /**
7
+ * Key constants for special keys
8
+ */
9
+ const KEYS = {
10
+ UP: '\u001b[A',
11
+ DOWN: '\u001b[B',
12
+ ENTER: '\r',
13
+ NEWLINE: '\n',
14
+ ESCAPE: '\u001b',
15
+ BACKSPACE: '\u007f',
16
+ BACKSPACE_ALT: '\b',
17
+ CTRL_C: '\u0003',
18
+ };
19
+
20
+ /**
21
+ * UI mode identifiers
22
+ */
23
+ const MODES = {
24
+ NORMAL: 'normal',
25
+ SEARCH: 'search',
26
+ PREVIEW: 'preview',
27
+ HISTORY: 'history',
28
+ INFO: 'info',
29
+ LOG_VIEW: 'log_view',
30
+ ACTION: 'action',
31
+ };
32
+
33
+ /**
34
+ * Determine the current UI mode from state flags.
35
+ * @param {object} state
36
+ * @param {boolean} state.searchMode
37
+ * @param {boolean} state.previewMode
38
+ * @param {boolean} state.historyMode
39
+ * @param {boolean} state.infoMode
40
+ * @param {boolean} state.logViewMode
41
+ * @param {boolean} state.actionMode
42
+ * @returns {string} One of MODES values
43
+ */
44
+ function getCurrentMode(state) {
45
+ if (state.searchMode) return MODES.SEARCH;
46
+ if (state.previewMode) return MODES.PREVIEW;
47
+ if (state.historyMode) return MODES.HISTORY;
48
+ if (state.infoMode) return MODES.INFO;
49
+ if (state.logViewMode) return MODES.LOG_VIEW;
50
+ if (state.actionMode) return MODES.ACTION;
51
+ return MODES.NORMAL;
52
+ }
53
+
54
+ /**
55
+ * Check if a key is a printable character.
56
+ * @param {string} key
57
+ * @returns {boolean}
58
+ */
59
+ function isPrintableChar(key) {
60
+ return key.length === 1 && key >= ' ' && key <= '~';
61
+ }
62
+
63
+ /**
64
+ * Check if a key is a navigation key (up/down arrows or j/k).
65
+ * @param {string} key
66
+ * @returns {boolean}
67
+ */
68
+ function isNavKey(key) {
69
+ return key === KEYS.UP || key === KEYS.DOWN || key === 'k' || key === 'j';
70
+ }
71
+
72
+ /**
73
+ * Check if a key is the escape key.
74
+ * @param {string} key
75
+ * @returns {boolean}
76
+ */
77
+ function isEscapeKey(key) {
78
+ return key === KEYS.ESCAPE;
79
+ }
80
+
81
+ /**
82
+ * Check if a key is the enter key.
83
+ * @param {string} key
84
+ * @returns {boolean}
85
+ */
86
+ function isEnterKey(key) {
87
+ return key === KEYS.ENTER || key === KEYS.NEWLINE;
88
+ }
89
+
90
+ /**
91
+ * Check if a key is a backspace key.
92
+ * @param {string} key
93
+ * @returns {boolean}
94
+ */
95
+ function isBackspaceKey(key) {
96
+ return key === KEYS.BACKSPACE || key === KEYS.BACKSPACE_ALT;
97
+ }
98
+
99
+ /**
100
+ * Check if a key is a digit (0-9).
101
+ * @param {string} key
102
+ * @returns {boolean}
103
+ */
104
+ function isDigitKey(key) {
105
+ return key.length === 1 && key >= '0' && key <= '9';
106
+ }
107
+
108
+ /**
109
+ * Determine the normal mode action for a given key.
110
+ * Returns an action name string, or null if the key is unhandled.
111
+ * @param {string} key
112
+ * @returns {string|null} Action name
113
+ */
114
+ function getNormalModeAction(key) {
115
+ switch (key) {
116
+ case KEYS.UP:
117
+ case 'k':
118
+ return 'move_up';
119
+ case KEYS.DOWN:
120
+ case 'j':
121
+ return 'move_down';
122
+ case KEYS.ENTER:
123
+ case KEYS.NEWLINE:
124
+ return 'select_branch';
125
+ case 'v':
126
+ return 'preview';
127
+ case '/':
128
+ return 'search';
129
+ case 'h':
130
+ return 'history';
131
+ case 'i':
132
+ return 'info';
133
+ case 'u':
134
+ return 'undo';
135
+ case 'p':
136
+ return 'pull';
137
+ case 'r':
138
+ return 'reload_browsers';
139
+ case 'R':
140
+ return 'restart_server';
141
+ case 'l':
142
+ return 'view_logs';
143
+ case 'o':
144
+ return 'open_browser';
145
+ case 'b':
146
+ return 'branch_actions';
147
+ case 'f':
148
+ return 'fetch';
149
+ case 's':
150
+ return 'toggle_sound';
151
+ case 'S':
152
+ return 'stash';
153
+ case 'c':
154
+ return 'toggle_casino';
155
+ case 'd':
156
+ return 'cleanup_branches';
157
+ case 'q':
158
+ case KEYS.CTRL_C:
159
+ return 'quit';
160
+ case KEYS.ESCAPE:
161
+ return 'escape';
162
+ case '+':
163
+ case '=':
164
+ return 'increase_visible';
165
+ case '-':
166
+ case '_':
167
+ return 'decrease_visible';
168
+ default:
169
+ if (isDigitKey(key)) return 'set_visible_count';
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Apply a search query to filter branches.
176
+ * @param {Array<{name: string}>} branches - Full branch list
177
+ * @param {string} query - Search query
178
+ * @returns {Array<{name: string}>|null} Filtered branches, or null if empty query
179
+ */
180
+ function filterBranches(branches, query) {
181
+ if (!query) return null;
182
+ const lowerQuery = query.toLowerCase();
183
+ return branches.filter(b => b.name.toLowerCase().includes(lowerQuery));
184
+ }
185
+
186
+ module.exports = {
187
+ KEYS,
188
+ MODES,
189
+ getCurrentMode,
190
+ isPrintableChar,
191
+ isNavKey,
192
+ isEscapeKey,
193
+ isEnterKey,
194
+ isBackspaceKey,
195
+ isDigitKey,
196
+ getNormalModeAction,
197
+ filterBranches,
198
+ };