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.
- package/bin/git-watchtower.js +49 -2
- 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
|
@@ -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
|
+
};
|