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/bin/git-watchtower.js +89 -9
- package/package.json +6 -1
- package/sounds/README.md +34 -0
- package/src/casino/index.js +721 -0
- package/src/casino/sounds.js +245 -0
- package/src/cli/args.js +239 -0
- package/src/config/loader.js +329 -0
- package/src/config/schema.js +305 -0
- package/src/git/branch.js +428 -0
- package/src/git/commands.js +416 -0
- package/src/git/pr.js +111 -0
- package/src/git/remote.js +127 -0
- package/src/index.js +179 -0
- package/src/polling/engine.js +157 -0
- package/src/server/process.js +329 -0
- package/src/server/static.js +95 -0
- package/src/state/store.js +527 -0
- package/src/telemetry/analytics.js +142 -0
- package/src/telemetry/config.js +123 -0
- package/src/telemetry/index.js +93 -0
- package/src/ui/actions.js +425 -0
- package/src/ui/ansi.js +498 -0
- package/src/ui/keybindings.js +198 -0
- package/src/ui/renderer.js +1326 -0
- package/src/utils/async.js +219 -0
- package/src/utils/browser.js +40 -0
- package/src/utils/errors.js +490 -0
- package/src/utils/gitignore.js +174 -0
- package/src/utils/sound.js +33 -0
- package/src/utils/time.js +27 -0
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
|
+
};
|