git-watchtower 1.6.1 → 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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Static server utilities — MIME types, live reload, diff parsing
3
+ * @module server/static
4
+ */
5
+
6
+ /**
7
+ * MIME type mapping by file extension.
8
+ */
9
+ const MIME_TYPES = {
10
+ '.html': 'text/html',
11
+ '.css': 'text/css',
12
+ '.js': 'application/javascript',
13
+ '.json': 'application/json',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.svg': 'image/svg+xml',
19
+ '.ico': 'image/x-icon',
20
+ '.webp': 'image/webp',
21
+ '.woff': 'font/woff',
22
+ '.woff2': 'font/woff2',
23
+ '.ttf': 'font/ttf',
24
+ '.xml': 'application/xml',
25
+ '.txt': 'text/plain',
26
+ '.md': 'text/markdown',
27
+ '.pdf': 'application/pdf',
28
+ };
29
+
30
+ /**
31
+ * Get MIME type for a file extension.
32
+ * @param {string} ext - File extension with dot (e.g., '.html')
33
+ * @returns {string} MIME type, defaults to 'application/octet-stream'
34
+ */
35
+ function getMimeType(ext) {
36
+ return MIME_TYPES[(ext || '').toLowerCase()] || 'application/octet-stream';
37
+ }
38
+
39
+ /**
40
+ * Live reload script to inject into HTML pages.
41
+ * Connects via Server-Sent Events (SSE) and reloads on 'reload' event.
42
+ */
43
+ const LIVE_RELOAD_SCRIPT = `
44
+ <script>
45
+ (function() {
46
+ var source = new EventSource('/livereload');
47
+ source.onmessage = function(e) {
48
+ if (e.data === 'reload') location.reload();
49
+ };
50
+ })();
51
+ </script>
52
+ </body>`;
53
+
54
+ /**
55
+ * Inject live reload script into HTML content.
56
+ * @param {string} html - HTML content
57
+ * @returns {string} HTML with live reload script injected before </body>
58
+ */
59
+ function injectLiveReload(html) {
60
+ if (html.includes('</body>')) {
61
+ return html.replace('</body>', LIVE_RELOAD_SCRIPT);
62
+ }
63
+ return html;
64
+ }
65
+
66
+ /**
67
+ * Parse git diff --stat output into { added, deleted } counts.
68
+ * @param {string} diffOutput - Output from `git diff --stat`
69
+ * @returns {{ added: number, deleted: number }}
70
+ */
71
+ function parseDiffStats(diffOutput) {
72
+ if (!diffOutput) return { added: 0, deleted: 0 };
73
+
74
+ // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
75
+ const match = diffOutput.match(/(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)/);
76
+ if (match) {
77
+ return { added: parseInt(match[1], 10), deleted: parseInt(match[2], 10) };
78
+ }
79
+
80
+ // Try to match just insertions or just deletions
81
+ const insertMatch = diffOutput.match(/(\d+) insertions?\(\+\)/);
82
+ const deleteMatch = diffOutput.match(/(\d+) deletions?\(-\)/);
83
+ return {
84
+ added: insertMatch ? parseInt(insertMatch[1], 10) : 0,
85
+ deleted: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
86
+ };
87
+ }
88
+
89
+ module.exports = {
90
+ MIME_TYPES,
91
+ getMimeType,
92
+ LIVE_RELOAD_SCRIPT,
93
+ injectLiveReload,
94
+ parseDiffStats,
95
+ };
@@ -0,0 +1,527 @@
1
+ /**
2
+ * Centralized state management for Git Watchtower
3
+ * Replaces scattered global variables with a single source of truth
4
+ */
5
+
6
+ /**
7
+ * @typedef {Object} Branch
8
+ * @property {string} name - Branch name
9
+ * @property {string} commit - Short commit hash
10
+ * @property {string} subject - Commit subject
11
+ * @property {Date} date - Commit date
12
+ * @property {boolean} isLocal - Is a local branch
13
+ * @property {boolean} hasRemote - Has a remote tracking branch
14
+ * @property {boolean} hasUpdates - Has updates available
15
+ * @property {boolean} isNew - Newly discovered branch
16
+ * @property {boolean} isDeleted - Branch was deleted
17
+ * @property {boolean} justUpdated - Was just updated
18
+ * @property {string} [sparkline] - Activity sparkline
19
+ */
20
+
21
+ /**
22
+ * @typedef {'normal' | 'search' | 'preview' | 'history' | 'logs' | 'info'} UIMode
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} FlashMessage
27
+ * @property {string} text - Message text
28
+ * @property {'info' | 'success' | 'warning' | 'error' | 'update'} type - Message type
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} ActivityLogEntry
33
+ * @property {string} message - Log message
34
+ * @property {'info' | 'success' | 'warning' | 'error' | 'update'} type - Entry type
35
+ * @property {Date} timestamp - When the entry was added
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} SwitchHistoryEntry
40
+ * @property {string} from - Previous branch name
41
+ * @property {string} to - New branch name
42
+ * @property {Date} timestamp - When the switch occurred
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} ServerLogEntry
47
+ * @property {string} timestamp - Time string
48
+ * @property {string} line - Log line content
49
+ * @property {boolean} isError - Is an error line
50
+ */
51
+
52
+ /**
53
+ * @typedef {Object} State
54
+ * @property {Branch[]} branches - All known branches
55
+ * @property {string|null} currentBranch - Current checked out branch
56
+ * @property {number} selectedIndex - Selected branch index
57
+ * @property {string|null} selectedBranchName - Selected branch name (for persistence)
58
+ * @property {Branch[]|null} filteredBranches - Filtered branch list (null = no filter)
59
+ * @property {boolean} isDetachedHead - In detached HEAD state
60
+ * @property {boolean} hasMergeConflict - Has merge conflicts
61
+ * @property {UIMode} mode - Current UI mode (legacy)
62
+ * @property {boolean} searchMode - Search mode active
63
+ * @property {string} searchQuery - Current search query
64
+ * @property {boolean} previewMode - Preview pane active
65
+ * @property {Object|null} previewData - Preview pane data
66
+ * @property {boolean} historyMode - History view active
67
+ * @property {boolean} infoMode - Info view active
68
+ * @property {boolean} logViewMode - Log view active
69
+ * @property {string} logViewTab - Active log tab ('server' | 'activity')
70
+ * @property {boolean} actionMode - Action modal active
71
+ * @property {Object|null} actionData - Action modal data
72
+ * @property {boolean} actionLoading - Action modal loading state
73
+ * @property {FlashMessage|null} flashMessage - Current flash message
74
+ * @property {Object|null} errorToast - Current error toast
75
+ * @property {boolean} stashConfirmMode - Stash confirmation dialog active
76
+ * @property {number} stashConfirmSelectedIndex - Selected option in stash confirm dialog
77
+ * @property {string|null} pendingDirtyOperationLabel - Label for the pending dirty operation
78
+ * @property {ActivityLogEntry[]} activityLog - Activity log entries
79
+ * @property {SwitchHistoryEntry[]} switchHistory - Branch switch history
80
+ * @property {boolean} isPolling - Currently polling git
81
+ * @property {string} pollingStatus - Polling status message
82
+ * @property {boolean} isOffline - Network is offline
83
+ * @property {number} lastFetchDuration - Last fetch duration in ms
84
+ * @property {number} consecutiveNetworkFailures - Number of consecutive failures
85
+ * @property {number} adaptivePollInterval - Current adaptive poll interval in ms
86
+ * @property {boolean} serverRunning - Server process is running
87
+ * @property {boolean} serverCrashed - Server process crashed
88
+ * @property {ServerLogEntry[]} serverLogs - Server log buffer (legacy)
89
+ * @property {ServerLogEntry[]} serverLogBuffer - Server log buffer
90
+ * @property {number} logScrollOffset - Scroll position in log view
91
+ * @property {number} terminalWidth - Terminal width
92
+ * @property {number} terminalHeight - Terminal height
93
+ * @property {number} visibleBranchCount - Number of branches to show
94
+ * @property {boolean} soundEnabled - Sound notifications enabled
95
+ * @property {boolean} casinoModeEnabled - Casino mode enabled
96
+ * @property {Map<string, string>} sparklineCache - Branch sparkline cache
97
+ * @property {Map<string, Object>} branchPrStatusMap - Branch PR status cache
98
+ * @property {string} serverMode - Server mode ('static' | 'command' | 'none')
99
+ * @property {boolean} noServer - No server mode
100
+ * @property {number} port - Server port
101
+ * @property {number} maxLogEntries - Max activity log entries
102
+ * @property {string} projectName - Project name
103
+ * @property {number} clientCount - Connected SSE clients
104
+ */
105
+
106
+ /**
107
+ * Get initial state with sensible defaults
108
+ * @returns {State}
109
+ */
110
+ function getInitialState() {
111
+ return {
112
+ // Git state
113
+ branches: [],
114
+ currentBranch: null,
115
+ selectedIndex: 0,
116
+ selectedBranchName: null,
117
+ filteredBranches: null,
118
+ isDetachedHead: false,
119
+ hasMergeConflict: false,
120
+
121
+ // UI mode (legacy — used by setMode/getFilteredBranches)
122
+ mode: 'normal',
123
+
124
+ // UI mode flags
125
+ searchMode: false,
126
+ searchQuery: '',
127
+ previewMode: false,
128
+ previewData: null,
129
+ historyMode: false,
130
+ infoMode: false,
131
+ logViewMode: false,
132
+ logViewTab: 'server',
133
+ actionMode: false,
134
+ actionData: null,
135
+ actionLoading: false,
136
+
137
+ // Notifications
138
+ flashMessage: null,
139
+ errorToast: null,
140
+ stashConfirmMode: false,
141
+ stashConfirmSelectedIndex: 0,
142
+ pendingDirtyOperationLabel: null,
143
+
144
+ // Activity tracking
145
+ activityLog: [],
146
+ switchHistory: [],
147
+
148
+ // Polling state
149
+ isPolling: false,
150
+ pollingStatus: 'idle',
151
+ isOffline: false,
152
+ lastFetchDuration: 0,
153
+ consecutiveNetworkFailures: 0,
154
+ adaptivePollInterval: 5000,
155
+
156
+ // Server state
157
+ serverRunning: false,
158
+ serverCrashed: false,
159
+ serverLogs: [],
160
+ serverLogBuffer: [],
161
+ logScrollOffset: 0,
162
+
163
+ // Terminal state
164
+ terminalWidth: process.stdout.columns || 80,
165
+ terminalHeight: process.stdout.rows || 24,
166
+
167
+ // Settings (can be overridden by config)
168
+ visibleBranchCount: 7,
169
+ soundEnabled: true,
170
+ casinoModeEnabled: false,
171
+
172
+ // Caches (Maps — shallow-copied by getState())
173
+ sparklineCache: new Map(),
174
+ branchPrStatusMap: new Map(),
175
+
176
+ // Config (set once at startup, treated as read-only after)
177
+ serverMode: 'static',
178
+ noServer: false,
179
+ port: 3000,
180
+ maxLogEntries: 10,
181
+ projectName: '',
182
+ clientCount: 0,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Centralized state store with subscription support
188
+ */
189
+ class Store {
190
+ /**
191
+ * @param {Partial<State>} [initialState] - Optional initial state overrides
192
+ */
193
+ constructor(initialState = {}) {
194
+ this.state = { ...getInitialState(), ...initialState };
195
+ this.listeners = new Set();
196
+ this.middlewares = [];
197
+ }
198
+
199
+ /**
200
+ * Get a copy of the current state
201
+ * @returns {State}
202
+ */
203
+ getState() {
204
+ return { ...this.state };
205
+ }
206
+
207
+ /**
208
+ * Get a specific state value
209
+ * @template {keyof State} K
210
+ * @param {K} key - State key
211
+ * @returns {State[K]}
212
+ */
213
+ get(key) {
214
+ return this.state[key];
215
+ }
216
+
217
+ /**
218
+ * Update state with partial updates
219
+ * @param {Partial<State>} updates - State updates
220
+ */
221
+ setState(updates) {
222
+ const prevState = this.state;
223
+
224
+ // Run middlewares
225
+ let processedUpdates = updates;
226
+ for (const middleware of this.middlewares) {
227
+ processedUpdates = middleware(prevState, processedUpdates) || processedUpdates;
228
+ }
229
+
230
+ this.state = { ...this.state, ...processedUpdates };
231
+ this.notify(prevState, this.state, Object.keys(processedUpdates));
232
+ }
233
+
234
+ /**
235
+ * Subscribe to state changes
236
+ * @param {(prevState: State, newState: State, changedKeys: string[]) => void} listener
237
+ * @returns {() => void} Unsubscribe function
238
+ */
239
+ subscribe(listener) {
240
+ this.listeners.add(listener);
241
+ return () => this.listeners.delete(listener);
242
+ }
243
+
244
+ /**
245
+ * Subscribe to specific state keys
246
+ * @param {(keyof State)[]} keys - Keys to watch
247
+ * @param {(prevState: State, newState: State) => void} listener
248
+ * @returns {() => void} Unsubscribe function
249
+ */
250
+ subscribeToKeys(keys, listener) {
251
+ const keySet = new Set(keys);
252
+ return this.subscribe((prevState, newState, changedKeys) => {
253
+ // @ts-ignore - changedKeys are always valid State keys
254
+ if (changedKeys.some((key) => keySet.has(key))) {
255
+ listener(prevState, newState);
256
+ }
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Add middleware to process state updates
262
+ * @param {(prevState: State, updates: Partial<State>) => Partial<State>|void} middleware
263
+ */
264
+ use(middleware) {
265
+ this.middlewares.push(middleware);
266
+ }
267
+
268
+ /**
269
+ * Notify all listeners of state change
270
+ * @param {State} prevState
271
+ * @param {State} newState
272
+ * @param {string[]} changedKeys
273
+ */
274
+ notify(prevState, newState, changedKeys) {
275
+ this.listeners.forEach((listener) => {
276
+ try {
277
+ listener(prevState, newState, changedKeys);
278
+ } catch (error) {
279
+ console.error('Store listener error:', error);
280
+ }
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Reset state to initial values
286
+ * @param {Partial<State>} [overrides] - Optional overrides
287
+ */
288
+ reset(overrides = {}) {
289
+ const prevState = this.state;
290
+ this.state = { ...getInitialState(), ...overrides };
291
+ this.notify(prevState, this.state, Object.keys(this.state));
292
+ }
293
+
294
+ // ==========================================================================
295
+ // Convenience methods for common state operations
296
+ // ==========================================================================
297
+
298
+ /**
299
+ * Set the current UI mode
300
+ * @param {UIMode} mode
301
+ */
302
+ setMode(mode) {
303
+ const prevMode = this.state.mode;
304
+ const updates = { mode };
305
+
306
+ // Clear mode-specific state when leaving
307
+ if (prevMode === 'search' && mode !== 'search') {
308
+ updates.searchQuery = '';
309
+ }
310
+ if (prevMode === 'preview' && mode !== 'preview') {
311
+ updates.previewData = null;
312
+ }
313
+ if (prevMode === 'logs' && mode !== 'logs') {
314
+ updates.logScrollOffset = 0;
315
+ }
316
+
317
+ this.setState(updates);
318
+ }
319
+
320
+ /**
321
+ * Show a flash message
322
+ * @param {string} text - Message text
323
+ * @param {'info' | 'success' | 'warning' | 'error' | 'update'} [type='info'] - Message type
324
+ */
325
+ flash(text, type = 'info') {
326
+ this.setState({
327
+ flashMessage: { text, type },
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Clear the flash message
333
+ */
334
+ clearFlash() {
335
+ this.setState({ flashMessage: null });
336
+ }
337
+
338
+ /**
339
+ * Add an activity log entry
340
+ * @param {string} message - Log message
341
+ * @param {'info' | 'success' | 'warning' | 'error' | 'update'} [type='info'] - Entry type
342
+ * @param {number} [maxEntries=10] - Maximum entries to keep
343
+ */
344
+ addLog(message, type = 'info', maxEntries = 10) {
345
+ const entry = { message, type, timestamp: new Date() };
346
+ const activityLog = [...this.state.activityLog, entry].slice(-maxEntries);
347
+ this.setState({ activityLog });
348
+ }
349
+
350
+ /**
351
+ * Add a branch switch to history
352
+ * @param {string} from - Previous branch
353
+ * @param {string} to - New branch
354
+ * @param {number} [maxEntries=20] - Maximum entries to keep
355
+ */
356
+ addToHistory(from, to, maxEntries = 20) {
357
+ const entry = { from, to, timestamp: new Date() };
358
+ const switchHistory = [...this.state.switchHistory, entry].slice(-maxEntries);
359
+ this.setState({ switchHistory });
360
+ }
361
+
362
+ /**
363
+ * Get the last switch for undo
364
+ * @returns {SwitchHistoryEntry|null}
365
+ */
366
+ getLastSwitch() {
367
+ const history = this.state.switchHistory;
368
+ return history.length > 0 ? history[history.length - 1] : null;
369
+ }
370
+
371
+ /**
372
+ * Remove the last switch from history (after undo)
373
+ */
374
+ popHistory() {
375
+ const switchHistory = this.state.switchHistory.slice(0, -1);
376
+ this.setState({ switchHistory });
377
+ }
378
+
379
+ /**
380
+ * Add a server log entry
381
+ * @param {string} line - Log line
382
+ * @param {boolean} [isError=false] - Is error output
383
+ * @param {number} [maxLines=500] - Maximum lines to keep
384
+ */
385
+ addServerLog(line, isError = false, maxLines = 500) {
386
+ const entry = {
387
+ timestamp: new Date().toLocaleTimeString(),
388
+ line,
389
+ isError,
390
+ };
391
+ const serverLogs = [...this.state.serverLogs, entry].slice(-maxLines);
392
+ this.setState({ serverLogs });
393
+ }
394
+
395
+ /**
396
+ * Clear server logs
397
+ */
398
+ clearServerLogs() {
399
+ this.setState({ serverLogs: [], logScrollOffset: 0 });
400
+ }
401
+
402
+ /**
403
+ * Update branches and maintain selection
404
+ * @param {Branch[]} branches - New branch list
405
+ */
406
+ setBranches(branches) {
407
+ // Validate input
408
+ if (!Array.isArray(branches)) {
409
+ console.error('Store.setBranches: expected array, got', typeof branches);
410
+ return;
411
+ }
412
+
413
+ const { selectedBranchName, selectedIndex } = this.state;
414
+
415
+ // Try to maintain selection by name
416
+ let newSelectedIndex = selectedIndex;
417
+ if (selectedBranchName) {
418
+ const idx = branches.findIndex((b) => b.name === selectedBranchName);
419
+ if (idx !== -1) {
420
+ newSelectedIndex = idx;
421
+ }
422
+ }
423
+
424
+ // Clamp to valid range (handle empty array case)
425
+ if (branches.length === 0) {
426
+ newSelectedIndex = 0;
427
+ } else {
428
+ newSelectedIndex = Math.max(0, Math.min(newSelectedIndex, branches.length - 1));
429
+ }
430
+
431
+ this.setState({
432
+ branches,
433
+ selectedIndex: newSelectedIndex,
434
+ selectedBranchName: branches[newSelectedIndex]?.name || null,
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Update selection index
440
+ * @param {number} index - New index
441
+ */
442
+ setSelectedIndex(index) {
443
+ // Validate input - convert to number and check for NaN
444
+ const numIndex = Number(index);
445
+ if (Number.isNaN(numIndex)) {
446
+ console.error('Store.setSelectedIndex: expected number, got', typeof index);
447
+ return;
448
+ }
449
+
450
+ const { branches } = this.state;
451
+ // Handle empty branches array
452
+ if (branches.length === 0) {
453
+ this.setState({
454
+ selectedIndex: 0,
455
+ selectedBranchName: null,
456
+ });
457
+ return;
458
+ }
459
+
460
+ const clampedIndex = Math.max(0, Math.min(Math.floor(numIndex), branches.length - 1));
461
+ this.setState({
462
+ selectedIndex: clampedIndex,
463
+ selectedBranchName: branches[clampedIndex]?.name || null,
464
+ });
465
+ }
466
+
467
+ /**
468
+ * Move selection up or down
469
+ * @param {number} delta - Amount to move (-1 for up, 1 for down)
470
+ */
471
+ moveSelection(delta) {
472
+ const { selectedIndex, branches } = this.state;
473
+ const newIndex = selectedIndex + delta;
474
+ if (newIndex >= 0 && newIndex < branches.length) {
475
+ this.setSelectedIndex(newIndex);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Get currently selected branch
481
+ * @returns {Branch|null}
482
+ */
483
+ getSelectedBranch() {
484
+ const { branches, selectedIndex } = this.state;
485
+ return branches[selectedIndex] || null;
486
+ }
487
+
488
+ /**
489
+ * Get branches filtered by current search query
490
+ * @returns {Branch[]}
491
+ */
492
+ getFilteredBranches() {
493
+ const { branches, searchQuery, mode, searchMode } = this.state;
494
+ if ((!searchMode && mode !== 'search') || !searchQuery) {
495
+ return branches;
496
+ }
497
+ const query = searchQuery.toLowerCase();
498
+ return branches.filter((b) => b.name.toLowerCase().includes(query));
499
+ }
500
+
501
+ /**
502
+ * Update terminal dimensions
503
+ * @param {number} width
504
+ * @param {number} height
505
+ */
506
+ setTerminalSize(width, height) {
507
+ this.setState({
508
+ terminalWidth: width,
509
+ terminalHeight: height,
510
+ });
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Create a new store instance
516
+ * @param {Partial<State>} [initialState]
517
+ * @returns {Store}
518
+ */
519
+ function createStore(initialState) {
520
+ return new Store(initialState);
521
+ }
522
+
523
+ module.exports = {
524
+ Store,
525
+ createStore,
526
+ getInitialState,
527
+ };