git-watchtower 1.10.1 → 1.10.2
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/package.json +1 -1
- package/src/server/web-ui/css.js +917 -0
- package/src/server/web-ui/html.js +140 -0
- package/src/server/web-ui/index.js +36 -0
- package/src/server/web-ui/js.js +1446 -0
- package/src/server/web-ui.js +8 -2472
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side JavaScript for the Git Watchtower web dashboard.
|
|
3
|
+
* Contains all interactive behavior: SSE connection, rendering, keyboard
|
|
4
|
+
* navigation, modals, notifications, and preferences.
|
|
5
|
+
* @module server/web-ui/js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the dashboard client-side JavaScript.
|
|
10
|
+
* @returns {string} JavaScript content (without script tags)
|
|
11
|
+
*/
|
|
12
|
+
function getDashboardJs() {
|
|
13
|
+
return `
|
|
14
|
+
(function() {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
// ── State ──────────────────────────────────────────────────────
|
|
18
|
+
var state = null;
|
|
19
|
+
var prevBranches = null; // for notification diffing
|
|
20
|
+
var selectedIndex = 0;
|
|
21
|
+
var searchMode = false;
|
|
22
|
+
var searchQuery = '';
|
|
23
|
+
var confirmMode = false;
|
|
24
|
+
var confirmCallback = null;
|
|
25
|
+
var connected = false;
|
|
26
|
+
var flashTimer = null;
|
|
27
|
+
var activeTabId = null;
|
|
28
|
+
var logViewerMode = false;
|
|
29
|
+
var logViewerTab = 'server';
|
|
30
|
+
var branchActionMode = false;
|
|
31
|
+
var infoMode = false;
|
|
32
|
+
var cleanupMode = false;
|
|
33
|
+
var updateMode = false;
|
|
34
|
+
var stashMode = false;
|
|
35
|
+
var pendingStashBranch = null;
|
|
36
|
+
var updateNotificationShown = false;
|
|
37
|
+
|
|
38
|
+
// ── Persistent Preferences (localStorage) ─────────────────────
|
|
39
|
+
var PREFS_KEY = 'git-watchtower-prefs';
|
|
40
|
+
function loadPrefs() {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
|
|
43
|
+
} catch (e) { return {}; }
|
|
44
|
+
}
|
|
45
|
+
function savePrefs(updates) {
|
|
46
|
+
var prefs = loadPrefs();
|
|
47
|
+
for (var k in updates) { if (updates.hasOwnProperty(k)) prefs[k] = updates[k]; }
|
|
48
|
+
try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch (e) { /* ignore */ }
|
|
49
|
+
return prefs;
|
|
50
|
+
}
|
|
51
|
+
var prefs = loadPrefs();
|
|
52
|
+
var sidebarCollapsed = prefs.sidebarCollapsed || false;
|
|
53
|
+
var sortOrder = prefs.sortOrder || 'default';
|
|
54
|
+
var pinnedBranches = prefs.pinnedBranches || [];
|
|
55
|
+
|
|
56
|
+
// Apply initial sidebar state
|
|
57
|
+
(function() {
|
|
58
|
+
var layout = document.querySelector('.layout');
|
|
59
|
+
if (sidebarCollapsed) layout.classList.add('sidebar-collapsed');
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
// ── Browser Notifications ─────────────────────────────────────
|
|
63
|
+
var notifPermission = typeof Notification !== 'undefined' ? Notification.permission : 'denied';
|
|
64
|
+
|
|
65
|
+
function updateNotifButton() {
|
|
66
|
+
var btn = document.getElementById('notif-btn');
|
|
67
|
+
if (notifPermission === 'granted') {
|
|
68
|
+
btn.className = 'notif-btn granted';
|
|
69
|
+
btn.textContent = 'notifs on';
|
|
70
|
+
} else if (notifPermission === 'denied') {
|
|
71
|
+
btn.className = 'notif-btn denied';
|
|
72
|
+
btn.textContent = 'notifs blocked';
|
|
73
|
+
} else {
|
|
74
|
+
btn.className = 'notif-btn';
|
|
75
|
+
btn.textContent = 'notifications';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
updateNotifButton();
|
|
79
|
+
|
|
80
|
+
document.getElementById('notif-btn').addEventListener('click', function() {
|
|
81
|
+
if (notifPermission === 'granted' || notifPermission === 'denied') return;
|
|
82
|
+
if (typeof Notification === 'undefined') {
|
|
83
|
+
showToast('Notifications not supported in this browser', 'warning');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
Notification.requestPermission().then(function(perm) {
|
|
87
|
+
notifPermission = perm;
|
|
88
|
+
updateNotifButton();
|
|
89
|
+
if (perm === 'granted') {
|
|
90
|
+
showToast('Desktop notifications enabled', 'success');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function sendNotification(title, body, tag) {
|
|
96
|
+
if (notifPermission !== 'granted') return;
|
|
97
|
+
try {
|
|
98
|
+
var n = new Notification(title, { body: body, tag: tag || 'git-watchtower', icon: '', silent: false });
|
|
99
|
+
setTimeout(function() { n.close(); }, 8000);
|
|
100
|
+
} catch (e) { /* ignore */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function diffBranchesForNotifications(oldBranches, newBranches) {
|
|
104
|
+
if (!oldBranches || !newBranches) return;
|
|
105
|
+
var oldMap = {};
|
|
106
|
+
for (var i = 0; i < oldBranches.length; i++) {
|
|
107
|
+
oldMap[oldBranches[i].name] = oldBranches[i];
|
|
108
|
+
}
|
|
109
|
+
for (var j = 0; j < newBranches.length; j++) {
|
|
110
|
+
var nb = newBranches[j];
|
|
111
|
+
var ob = oldMap[nb.name];
|
|
112
|
+
if (!ob && nb.isNew) {
|
|
113
|
+
sendNotification('New Branch', nb.name + ' was created', 'new-' + nb.name);
|
|
114
|
+
} else if (ob && !ob.justUpdated && nb.justUpdated) {
|
|
115
|
+
sendNotification('Branch Updated', nb.name + ' has new commits', 'update-' + nb.name);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Check PR state changes
|
|
119
|
+
if (state && state.branchPrStatusMap) {
|
|
120
|
+
for (var bn in state.branchPrStatusMap) {
|
|
121
|
+
if (!state.branchPrStatusMap.hasOwnProperty(bn)) continue;
|
|
122
|
+
var pr = state.branchPrStatusMap[bn];
|
|
123
|
+
if (pr && pr.state === 'MERGED') {
|
|
124
|
+
// Only notify once - check if it was not merged before
|
|
125
|
+
var oldBranch = oldMap[bn];
|
|
126
|
+
if (oldBranch) {
|
|
127
|
+
sendNotification('PR Merged', 'PR #' + pr.number + ' for ' + bn + ' was merged', 'merged-' + bn);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Clipboard Helper ──────────────────────────────────────────
|
|
135
|
+
function copyToClipboard(text, btnEl) {
|
|
136
|
+
navigator.clipboard.writeText(text).then(function() {
|
|
137
|
+
if (btnEl) {
|
|
138
|
+
btnEl.classList.add('copied');
|
|
139
|
+
btnEl.innerHTML = '✓';
|
|
140
|
+
setTimeout(function() {
|
|
141
|
+
btnEl.classList.remove('copied');
|
|
142
|
+
btnEl.innerHTML = '📋';
|
|
143
|
+
}, 1500);
|
|
144
|
+
}
|
|
145
|
+
showToast('Copied: ' + text, 'success');
|
|
146
|
+
}).catch(function() {
|
|
147
|
+
showToast('Failed to copy', 'error');
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── URL Building Helpers ──────────────────────────────────────
|
|
152
|
+
function getRepoUrl() {
|
|
153
|
+
return (state && state.repoWebUrl) ? state.repoWebUrl.replace(/\\/tree\\/.*$/, '') : null;
|
|
154
|
+
}
|
|
155
|
+
function getBranchUrl(branchName) {
|
|
156
|
+
var base = getRepoUrl();
|
|
157
|
+
if (!base) return null;
|
|
158
|
+
return base + '/tree/' + encodeURIComponent(branchName);
|
|
159
|
+
}
|
|
160
|
+
function getCommitUrl(hash) {
|
|
161
|
+
var base = getRepoUrl();
|
|
162
|
+
if (!base || !hash) return null;
|
|
163
|
+
return base + '/commit/' + hash;
|
|
164
|
+
}
|
|
165
|
+
function getPrUrl(prNumber) {
|
|
166
|
+
var base = getRepoUrl();
|
|
167
|
+
if (!base || !prNumber) return null;
|
|
168
|
+
// Detect GitLab by URL pattern
|
|
169
|
+
if (base.indexOf('gitlab') !== -1) {
|
|
170
|
+
return base + '/-/merge_requests/' + prNumber;
|
|
171
|
+
}
|
|
172
|
+
return base + '/pull/' + prNumber;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── SSE Connection ─────────────────────────────────────────────
|
|
176
|
+
var evtSource = null;
|
|
177
|
+
|
|
178
|
+
function connect() {
|
|
179
|
+
if (evtSource) { evtSource.close(); }
|
|
180
|
+
evtSource = new EventSource('/api/events');
|
|
181
|
+
|
|
182
|
+
evtSource.onopen = function() {
|
|
183
|
+
connected = true;
|
|
184
|
+
updateConnectionStatus();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
evtSource.addEventListener('state', function(e) {
|
|
188
|
+
try {
|
|
189
|
+
var newState = JSON.parse(e.data);
|
|
190
|
+
// Diff branches for desktop notifications
|
|
191
|
+
if (state && state.branches) {
|
|
192
|
+
diffBranchesForNotifications(state.branches, newState.branches || []);
|
|
193
|
+
}
|
|
194
|
+
prevBranches = state ? state.branches : null;
|
|
195
|
+
state = newState;
|
|
196
|
+
if (!activeTabId && state.activeProjectId) {
|
|
197
|
+
activeTabId = state.activeProjectId;
|
|
198
|
+
}
|
|
199
|
+
renderTabs();
|
|
200
|
+
render();
|
|
201
|
+
} catch (err) { /* ignore parse errors */ }
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
evtSource.addEventListener('flash', function(e) {
|
|
205
|
+
try {
|
|
206
|
+
var data = JSON.parse(e.data);
|
|
207
|
+
showFlash(data.text, data.type);
|
|
208
|
+
} catch (err) { /* ignore */ }
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
evtSource.addEventListener('actionResult', function(e) {
|
|
212
|
+
try {
|
|
213
|
+
var data = JSON.parse(e.data);
|
|
214
|
+
if (!data.success && data.message && data.message.indexOf('uncommitted') !== -1) {
|
|
215
|
+
pendingStashBranch = data.branch || null;
|
|
216
|
+
showErrorToastWithHint(data.message, 'Press S to stash');
|
|
217
|
+
} else {
|
|
218
|
+
showToast(data.message, data.success ? 'success' : 'error');
|
|
219
|
+
}
|
|
220
|
+
} catch (err) { /* ignore */ }
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
evtSource.onerror = function() {
|
|
224
|
+
connected = false;
|
|
225
|
+
updateConnectionStatus();
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function updateConnectionStatus() {
|
|
230
|
+
var dot = document.getElementById('connection-dot');
|
|
231
|
+
var badge = document.getElementById('status-badge');
|
|
232
|
+
if (connected) {
|
|
233
|
+
dot.className = 'connection-dot connected';
|
|
234
|
+
badge.className = 'badge badge-online';
|
|
235
|
+
badge.textContent = 'live';
|
|
236
|
+
} else {
|
|
237
|
+
dot.className = 'connection-dot disconnected';
|
|
238
|
+
badge.className = 'badge badge-offline';
|
|
239
|
+
badge.textContent = 'reconnecting';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Actions ────────────────────────────────────────────────────
|
|
244
|
+
function sendAction(action, payload) {
|
|
245
|
+
var xhr = new XMLHttpRequest();
|
|
246
|
+
xhr.open('POST', '/api/action');
|
|
247
|
+
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
248
|
+
var data = { action: action, payload: payload || {} };
|
|
249
|
+
if (activeTabId) data.projectId = activeTabId;
|
|
250
|
+
xhr.send(JSON.stringify(data));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Flash Messages ─────────────────────────────────────────────
|
|
254
|
+
function showFlash(text, type) {
|
|
255
|
+
var el = document.getElementById('flash');
|
|
256
|
+
el.textContent = text;
|
|
257
|
+
el.className = 'flash visible ' + (type || 'info');
|
|
258
|
+
clearTimeout(flashTimer);
|
|
259
|
+
flashTimer = setTimeout(function() {
|
|
260
|
+
el.className = 'flash';
|
|
261
|
+
}, 3000);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Toast Notifications ────────────────────────────────────────
|
|
265
|
+
function showToast(text, type) {
|
|
266
|
+
var container = document.getElementById('toast-container');
|
|
267
|
+
var toast = document.createElement('div');
|
|
268
|
+
var icons = { success: '\\u2713', error: '\\u2717', info: '\\u2139', warning: '\\u26a0' };
|
|
269
|
+
toast.className = 'toast ' + (type || 'info');
|
|
270
|
+
toast.innerHTML = '<span class="toast-icon">' + (icons[type] || icons.info) + '</span>' + escHtml(text);
|
|
271
|
+
container.appendChild(toast);
|
|
272
|
+
requestAnimationFrame(function() {
|
|
273
|
+
requestAnimationFrame(function() { toast.classList.add('visible'); });
|
|
274
|
+
});
|
|
275
|
+
setTimeout(function() {
|
|
276
|
+
toast.classList.remove('visible');
|
|
277
|
+
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
278
|
+
}, 4000);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Confirm Dialog ─────────────────────────────────────────────
|
|
282
|
+
function showConfirm(title, message, onConfirm, opts) {
|
|
283
|
+
opts = opts || {};
|
|
284
|
+
confirmMode = true;
|
|
285
|
+
confirmCallback = onConfirm;
|
|
286
|
+
var box = document.getElementById('confirm-box');
|
|
287
|
+
box.innerHTML =
|
|
288
|
+
'<div class="confirm-title">' + escHtml(title) + '</div>' +
|
|
289
|
+
'<div class="confirm-message">' + escHtml(message) + '</div>' +
|
|
290
|
+
'<div class="confirm-actions">' +
|
|
291
|
+
'<button class="confirm-btn" id="confirm-cancel">Cancel</button>' +
|
|
292
|
+
'<button class="confirm-btn ' + (opts.danger ? 'danger' : 'primary') + '" id="confirm-ok">' +
|
|
293
|
+
escHtml(opts.label || 'Confirm') +
|
|
294
|
+
'</button>' +
|
|
295
|
+
'</div>';
|
|
296
|
+
document.getElementById('confirm-overlay').className = 'confirm-overlay active';
|
|
297
|
+
document.getElementById('confirm-cancel').onclick = hideConfirm;
|
|
298
|
+
document.getElementById('confirm-ok').onclick = function() {
|
|
299
|
+
hideConfirm();
|
|
300
|
+
if (confirmCallback) confirmCallback();
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function hideConfirm() {
|
|
305
|
+
confirmMode = false;
|
|
306
|
+
confirmCallback = null;
|
|
307
|
+
document.getElementById('confirm-overlay').className = 'confirm-overlay';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Tabs ───────────────────────────────────────────────────────
|
|
311
|
+
function renderTabs() {
|
|
312
|
+
var tabBar = document.getElementById('tab-bar');
|
|
313
|
+
var projects = (state && state.projects) || [];
|
|
314
|
+
if (projects.length <= 1) {
|
|
315
|
+
tabBar.className = 'tab-bar';
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
tabBar.className = 'tab-bar visible';
|
|
319
|
+
// Adjust layout height for tab bar
|
|
320
|
+
document.querySelector('.layout').style.height = 'calc(100vh - 49px - 40px)';
|
|
321
|
+
var html = '';
|
|
322
|
+
for (var i = 0; i < projects.length; i++) {
|
|
323
|
+
var p = projects[i];
|
|
324
|
+
var isActive = p.id === activeTabId;
|
|
325
|
+
html += '<div class="tab' + (isActive ? ' active' : '') + '" data-project-id="' + escHtml(p.id) + '">';
|
|
326
|
+
html += '<span class="tab-dot"></span>';
|
|
327
|
+
html += escHtml(p.name);
|
|
328
|
+
if (i < 9) html += '<span class="tab-number">' + (i + 1) + '</span>';
|
|
329
|
+
html += '</div>';
|
|
330
|
+
}
|
|
331
|
+
tabBar.innerHTML = html;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function switchTab(projectId) {
|
|
335
|
+
if (projectId === activeTabId) return;
|
|
336
|
+
activeTabId = projectId;
|
|
337
|
+
selectedIndex = 0;
|
|
338
|
+
searchQuery = '';
|
|
339
|
+
searchMode = false;
|
|
340
|
+
document.getElementById('search-bar').className = 'search-bar';
|
|
341
|
+
document.getElementById('search-input').value = '';
|
|
342
|
+
renderTabs();
|
|
343
|
+
// Fetch the project's state
|
|
344
|
+
var xhr = new XMLHttpRequest();
|
|
345
|
+
xhr.open('GET', '/api/projects/' + projectId + '/state');
|
|
346
|
+
xhr.onload = function() {
|
|
347
|
+
if (xhr.status === 200) {
|
|
348
|
+
try {
|
|
349
|
+
var pState = JSON.parse(xhr.responseText);
|
|
350
|
+
// Merge into current state for rendering
|
|
351
|
+
state.branches = pState.branches || [];
|
|
352
|
+
state.currentBranch = pState.currentBranch;
|
|
353
|
+
state.activityLog = pState.activityLog || [];
|
|
354
|
+
state.switchHistory = pState.switchHistory || [];
|
|
355
|
+
state.sparklineCache = pState.sparklineCache || {};
|
|
356
|
+
state.branchPrStatusMap = pState.branchPrStatusMap || {};
|
|
357
|
+
state.aheadBehindCache = pState.aheadBehindCache || {};
|
|
358
|
+
state.projectName = pState.projectName || '';
|
|
359
|
+
state.pollingStatus = pState.pollingStatus || 'idle';
|
|
360
|
+
state.isOffline = pState.isOffline || false;
|
|
361
|
+
state.serverMode = pState.serverMode || 'none';
|
|
362
|
+
render();
|
|
363
|
+
} catch (err) { /* ignore */ }
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
xhr.send();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Time Formatting ────────────────────────────────────────────
|
|
370
|
+
function timeAgo(dateStr) {
|
|
371
|
+
if (!dateStr) return '';
|
|
372
|
+
var ts = new Date(dateStr).getTime();
|
|
373
|
+
if (isNaN(ts)) return '';
|
|
374
|
+
var diff = Date.now() - ts;
|
|
375
|
+
if (diff < 0) return 'now';
|
|
376
|
+
var s = Math.floor(diff / 1000);
|
|
377
|
+
if (s < 60) return s + 's ago';
|
|
378
|
+
var m = Math.floor(s / 60);
|
|
379
|
+
if (m < 60) return m + 'm ago';
|
|
380
|
+
var h = Math.floor(m / 60);
|
|
381
|
+
if (h < 24) return h + 'h ago';
|
|
382
|
+
var d = Math.floor(h / 24);
|
|
383
|
+
return d + 'd ago';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Sparkline Rendering ────────────────────────────────────────
|
|
387
|
+
function renderSparklineBars(sparkStr) {
|
|
388
|
+
if (!sparkStr) return '';
|
|
389
|
+
var chars = '\\u2581\\u2582\\u2583\\u2584\\u2585\\u2586\\u2587\\u2588';
|
|
390
|
+
var html = '<div class="sparkline-bar">';
|
|
391
|
+
for (var i = 0; i < sparkStr.length; i++) {
|
|
392
|
+
var ch = sparkStr[i];
|
|
393
|
+
var idx = chars.indexOf(ch);
|
|
394
|
+
if (idx < 0) {
|
|
395
|
+
html += '<div class="spark-bar" style="height:1px"></div>';
|
|
396
|
+
} else {
|
|
397
|
+
var pct = Math.round(((idx + 1) / 8) * 100);
|
|
398
|
+
html += '<div class="spark-bar" style="height:' + pct + '%"></div>';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
html += '</div>';
|
|
402
|
+
return html;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Compact number ─────────────────────────────────────────────
|
|
406
|
+
function fmtCompact(n) {
|
|
407
|
+
if (n < 1000) return String(n);
|
|
408
|
+
if (n < 10000) return (n / 1000).toFixed(1) + 'k';
|
|
409
|
+
if (n < 1000000) return Math.round(n / 1000) + 'k';
|
|
410
|
+
return (n / 1000000).toFixed(1) + 'm';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Get Display Branches ───────────────────────────────────────
|
|
414
|
+
function getDisplayBranches() {
|
|
415
|
+
if (!state || !state.branches) return [];
|
|
416
|
+
var branches = state.branches.slice();
|
|
417
|
+
if (searchQuery) {
|
|
418
|
+
var q = searchQuery.toLowerCase();
|
|
419
|
+
branches = branches.filter(function(b) {
|
|
420
|
+
return b.name.toLowerCase().indexOf(q) !== -1;
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// Pin branches to top
|
|
424
|
+
if (pinnedBranches.length > 0) {
|
|
425
|
+
var pinSet = {};
|
|
426
|
+
for (var i = 0; i < pinnedBranches.length; i++) pinSet[pinnedBranches[i]] = true;
|
|
427
|
+
branches.sort(function(a, b) {
|
|
428
|
+
var aPin = pinSet[a.name] ? 1 : 0;
|
|
429
|
+
var bPin = pinSet[b.name] ? 1 : 0;
|
|
430
|
+
return bPin - aPin; // pinned first
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// Sort
|
|
434
|
+
if (sortOrder === 'alpha') {
|
|
435
|
+
var pinSet2 = {};
|
|
436
|
+
for (var j = 0; j < pinnedBranches.length; j++) pinSet2[pinnedBranches[j]] = true;
|
|
437
|
+
branches.sort(function(a, b) {
|
|
438
|
+
// Pinned branches always first
|
|
439
|
+
var aPin = pinSet2[a.name] ? 1 : 0;
|
|
440
|
+
var bPin = pinSet2[b.name] ? 1 : 0;
|
|
441
|
+
if (aPin !== bPin) return bPin - aPin;
|
|
442
|
+
return a.name.localeCompare(b.name);
|
|
443
|
+
});
|
|
444
|
+
} else if (sortOrder === 'recent') {
|
|
445
|
+
var pinSet3 = {};
|
|
446
|
+
for (var k = 0; k < pinnedBranches.length; k++) pinSet3[pinnedBranches[k]] = true;
|
|
447
|
+
branches.sort(function(a, b) {
|
|
448
|
+
var aPin = pinSet3[a.name] ? 1 : 0;
|
|
449
|
+
var bPin = pinSet3[b.name] ? 1 : 0;
|
|
450
|
+
if (aPin !== bPin) return bPin - aPin;
|
|
451
|
+
var aDate = a.date ? new Date(a.date).getTime() : 0;
|
|
452
|
+
var bDate = b.date ? new Date(b.date).getTime() : 0;
|
|
453
|
+
return bDate - aDate;
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return branches;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Render ─────────────────────────────────────────────────────
|
|
460
|
+
function render() {
|
|
461
|
+
if (!state) return;
|
|
462
|
+
|
|
463
|
+
// Header — hide project name pill when tabs are showing it
|
|
464
|
+
var projectEl = document.getElementById('project-name');
|
|
465
|
+
var hasTabs = state.projects && state.projects.length > 1;
|
|
466
|
+
if (hasTabs) {
|
|
467
|
+
projectEl.style.display = 'none';
|
|
468
|
+
} else {
|
|
469
|
+
projectEl.style.display = '';
|
|
470
|
+
projectEl.textContent = state.projectName || '-';
|
|
471
|
+
}
|
|
472
|
+
var versionEl = document.getElementById('version');
|
|
473
|
+
if (state.version) versionEl.textContent = 'v' + state.version;
|
|
474
|
+
|
|
475
|
+
// Status badge
|
|
476
|
+
if (connected) {
|
|
477
|
+
var badge = document.getElementById('status-badge');
|
|
478
|
+
if (state.isOffline) {
|
|
479
|
+
badge.className = 'badge badge-offline';
|
|
480
|
+
badge.textContent = 'offline';
|
|
481
|
+
} else if (state.pollingStatus === 'fetching') {
|
|
482
|
+
badge.className = 'badge badge-fetching';
|
|
483
|
+
badge.textContent = 'fetching';
|
|
484
|
+
} else {
|
|
485
|
+
badge.className = 'badge badge-online';
|
|
486
|
+
badge.textContent = 'live';
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
renderBranches();
|
|
491
|
+
renderActivityLog();
|
|
492
|
+
renderSessionStats();
|
|
493
|
+
renderPrefsBar();
|
|
494
|
+
|
|
495
|
+
// Auto-show update notification (once per session)
|
|
496
|
+
if (state.updateAvailable && !updateNotificationShown && !anyModalOpen()) {
|
|
497
|
+
updateNotificationShown = true;
|
|
498
|
+
showUpdateModal();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Update log viewer if open
|
|
502
|
+
if (logViewerMode) renderLogViewer();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function renderBranches() {
|
|
506
|
+
var container = document.getElementById('branch-list');
|
|
507
|
+
var branches = getDisplayBranches();
|
|
508
|
+
var countEl = document.getElementById('branch-count');
|
|
509
|
+
countEl.textContent = branches.length;
|
|
510
|
+
|
|
511
|
+
if (selectedIndex >= branches.length) {
|
|
512
|
+
selectedIndex = Math.max(0, branches.length - 1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (branches.length === 0) {
|
|
516
|
+
container.innerHTML = '<div class="empty-state">' +
|
|
517
|
+
'<div class="empty-state-icon">🌿</div>' +
|
|
518
|
+
(searchQuery ? 'No branches matching "' + escHtml(searchQuery) + '"' : 'No branches found') +
|
|
519
|
+
'</div>';
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
var html = '';
|
|
524
|
+
for (var i = 0; i < branches.length; i++) {
|
|
525
|
+
var b = branches[i];
|
|
526
|
+
var isSelected = i === selectedIndex;
|
|
527
|
+
var isCurrent = b.name === state.currentBranch;
|
|
528
|
+
|
|
529
|
+
// Sparkline
|
|
530
|
+
var sparkStr = state.sparklineCache ? state.sparklineCache[b.name] : null;
|
|
531
|
+
|
|
532
|
+
// PR status
|
|
533
|
+
var prStatus = state.branchPrStatusMap ? state.branchPrStatusMap[b.name] : null;
|
|
534
|
+
var isMerged = prStatus && prStatus.state === 'MERGED';
|
|
535
|
+
|
|
536
|
+
// Ahead/behind
|
|
537
|
+
var ab = state.aheadBehindCache ? state.aheadBehindCache[b.name] : null;
|
|
538
|
+
|
|
539
|
+
var itemClasses = 'branch-item';
|
|
540
|
+
if (isSelected) itemClasses += ' selected';
|
|
541
|
+
if (isCurrent) itemClasses += ' current';
|
|
542
|
+
if (isMerged) itemClasses += ' merged';
|
|
543
|
+
|
|
544
|
+
html += '<div class="' + itemClasses + '" data-index="' + i + '">';
|
|
545
|
+
if (isCurrent) {
|
|
546
|
+
html += '<span class="branch-current-icon">●</span>';
|
|
547
|
+
}
|
|
548
|
+
html += '<span class="branch-cursor">▶</span>';
|
|
549
|
+
html += '<div class="branch-info">';
|
|
550
|
+
html += '<div class="branch-name-row">';
|
|
551
|
+
// Branch name - clickable link to GitHub/GitLab
|
|
552
|
+
var branchUrl = getBranchUrl(b.name);
|
|
553
|
+
var isPinned = pinnedBranches.indexOf(b.name) !== -1;
|
|
554
|
+
html += '<span class="branch-name">';
|
|
555
|
+
if (branchUrl) {
|
|
556
|
+
html += '<a href="' + escHtml(branchUrl) + '" target="_blank" rel="noopener" title="Open on web" onclick="event.stopPropagation()">' + escHtml(b.name) + '</a>';
|
|
557
|
+
} else {
|
|
558
|
+
html += escHtml(b.name);
|
|
559
|
+
}
|
|
560
|
+
html += '</span>';
|
|
561
|
+
// Copy branch name button
|
|
562
|
+
html += '<button class="copy-btn" data-copy="' + escHtml(b.name) + '" title="Copy branch name" onclick="event.stopPropagation()">📋</button>';
|
|
563
|
+
html += '</div>'; // branch-name-row
|
|
564
|
+
|
|
565
|
+
html += '<div class="branch-meta">';
|
|
566
|
+
// Commit hash - clickable link
|
|
567
|
+
var commitUrl = getCommitUrl(b.commit);
|
|
568
|
+
html += '<span class="branch-commit">';
|
|
569
|
+
if (commitUrl) {
|
|
570
|
+
html += '<a href="' + escHtml(commitUrl) + '" target="_blank" rel="noopener" title="View commit" onclick="event.stopPropagation()">' + escHtml(b.commit || '') + '</a>';
|
|
571
|
+
} else {
|
|
572
|
+
html += escHtml(b.commit || '');
|
|
573
|
+
}
|
|
574
|
+
html += '</span>';
|
|
575
|
+
// Copy commit hash
|
|
576
|
+
if (b.commit) {
|
|
577
|
+
html += '<button class="copy-btn" data-copy="' + escHtml(b.commit) + '" title="Copy commit hash" onclick="event.stopPropagation()">📋</button>';
|
|
578
|
+
}
|
|
579
|
+
html += '<span class="branch-subject">' + escHtml(b.subject || '') + '</span>';
|
|
580
|
+
html += '</div>'; // branch-meta
|
|
581
|
+
html += '</div>'; // branch-info
|
|
582
|
+
|
|
583
|
+
html += '<div class="branch-right">';
|
|
584
|
+
// Badges
|
|
585
|
+
var badges = '';
|
|
586
|
+
if (isCurrent) badges += '<span class="branch-current-badge">HEAD</span>';
|
|
587
|
+
if (isPinned) badges += '<span class="branch-new-badge" style="color:var(--orange);background:rgba(219,109,40,0.15)">pinned</span>';
|
|
588
|
+
if (b.isNew) badges += '<span class="branch-new-badge">new</span>';
|
|
589
|
+
if (b.isDeleted) badges += '<span class="branch-deleted-badge">deleted</span>';
|
|
590
|
+
if (b.justUpdated) badges += '<span class="branch-updated-badge">updated</span>';
|
|
591
|
+
if (prStatus) {
|
|
592
|
+
var prClass = prStatus.state === 'OPEN' ? 'pr-open' : prStatus.state === 'MERGED' ? 'pr-merged' : 'pr-closed';
|
|
593
|
+
var prUrl = getPrUrl(prStatus.number);
|
|
594
|
+
badges += '<span class="pr-badge ' + prClass + '">';
|
|
595
|
+
if (prUrl) badges += '<a href="' + escHtml(prUrl) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()">';
|
|
596
|
+
badges += (prStatus.state === 'MERGED' ? 'merged' : 'PR #' + prStatus.number);
|
|
597
|
+
if (prUrl) badges += '</a>';
|
|
598
|
+
badges += '</span>';
|
|
599
|
+
if (prUrl) badges += '<button class="copy-btn" data-copy="' + escHtml(prUrl) + '" title="Copy PR URL" onclick="event.stopPropagation()">📋</button>';
|
|
600
|
+
}
|
|
601
|
+
html += '<div class="branch-time-row">';
|
|
602
|
+
html += '<span class="branch-time">' + timeAgo(b.date) + '</span>';
|
|
603
|
+
if (badges) html += '<div class="branch-badges">' + badges + '</div>';
|
|
604
|
+
html += '</div>';
|
|
605
|
+
if (ab && (ab.ahead || ab.behind)) {
|
|
606
|
+
html += '<div class="branch-diff">';
|
|
607
|
+
html += '<span class="diff-added">+' + fmtCompact(ab.ahead || 0) + '</span>';
|
|
608
|
+
html += '<span class="diff-deleted">-' + fmtCompact(ab.behind || 0) + '</span>';
|
|
609
|
+
html += '<span class="diff-label">commits</span>';
|
|
610
|
+
if (ab.linesAdded || ab.linesDeleted) {
|
|
611
|
+
html += ' <span class="diff-added">+' + fmtCompact(ab.linesAdded || 0) + '</span>';
|
|
612
|
+
html += '<span class="diff-deleted">-' + fmtCompact(ab.linesDeleted || 0) + '</span>';
|
|
613
|
+
html += '<span class="diff-label">lines</span>';
|
|
614
|
+
}
|
|
615
|
+
html += '</div>';
|
|
616
|
+
}
|
|
617
|
+
html += renderSparklineBars(sparkStr);
|
|
618
|
+
html += '</div>';
|
|
619
|
+
html += '</div>'; // branch-item
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
container.innerHTML = html;
|
|
623
|
+
|
|
624
|
+
// Scroll selected into view
|
|
625
|
+
var selected = container.querySelector('.branch-item.selected');
|
|
626
|
+
if (selected) {
|
|
627
|
+
selected.scrollIntoView({ block: 'nearest' });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function renderActivityLog() {
|
|
632
|
+
var container = document.getElementById('activity-log');
|
|
633
|
+
var log = (state && state.activityLog) || [];
|
|
634
|
+
if (log.length === 0) {
|
|
635
|
+
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📋</div>No activity yet</div>';
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
var html = '';
|
|
639
|
+
for (var i = 0; i < log.length; i++) {
|
|
640
|
+
var entry = log[i];
|
|
641
|
+
var t = '';
|
|
642
|
+
if (entry.timestamp) {
|
|
643
|
+
var d = new Date(entry.timestamp);
|
|
644
|
+
t = isNaN(d.getTime()) ? '' : d.toLocaleTimeString();
|
|
645
|
+
}
|
|
646
|
+
html += '<div class="log-entry">';
|
|
647
|
+
html += '<span class="log-dot ' + (entry.type || 'info') + '"></span>';
|
|
648
|
+
html += '<span class="log-text">' + escHtml(entry.message) + '</span>';
|
|
649
|
+
html += '<span class="log-time">' + t + '</span>';
|
|
650
|
+
html += '</div>';
|
|
651
|
+
}
|
|
652
|
+
container.innerHTML = html;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── Log Viewer ─────────────────────────────────────────────────
|
|
656
|
+
function showLogViewer() {
|
|
657
|
+
logViewerMode = true;
|
|
658
|
+
logViewerTab = 'server';
|
|
659
|
+
renderLogViewer();
|
|
660
|
+
document.getElementById('log-viewer-overlay').className = 'modal-overlay active';
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function hideLogViewer() {
|
|
664
|
+
logViewerMode = false;
|
|
665
|
+
document.getElementById('log-viewer-overlay').className = 'modal-overlay';
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function renderLogViewer() {
|
|
669
|
+
if (!state) return;
|
|
670
|
+
var container = document.getElementById('log-viewer-content');
|
|
671
|
+
// Update tab active state
|
|
672
|
+
var tabs = document.querySelectorAll('.log-viewer-tab');
|
|
673
|
+
for (var t = 0; t < tabs.length; t++) {
|
|
674
|
+
tabs[t].className = 'log-viewer-tab' + (tabs[t].getAttribute('data-tab') === logViewerTab ? ' active' : '');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
var html = '';
|
|
678
|
+
if (logViewerTab === 'server') {
|
|
679
|
+
var logs = state.serverLogBuffer || [];
|
|
680
|
+
if (logs.length === 0) {
|
|
681
|
+
html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No server logs</div>';
|
|
682
|
+
} else {
|
|
683
|
+
for (var i = 0; i < logs.length; i++) {
|
|
684
|
+
var log = logs[i];
|
|
685
|
+
html += '<div class="log-line' + (log.isError ? ' error' : '') + '">';
|
|
686
|
+
html += '<span class="log-ts">' + escHtml(log.timestamp || '') + '</span>';
|
|
687
|
+
html += escHtml(log.line || '');
|
|
688
|
+
html += '</div>';
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
var alog = (state.activityLog || []);
|
|
693
|
+
if (alog.length === 0) {
|
|
694
|
+
html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No activity</div>';
|
|
695
|
+
} else {
|
|
696
|
+
for (var j = 0; j < alog.length; j++) {
|
|
697
|
+
var entry = alog[j];
|
|
698
|
+
var ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
699
|
+
html += '<div class="log-line">';
|
|
700
|
+
html += '<span class="log-ts">' + ts + '</span>';
|
|
701
|
+
html += escHtml(entry.message || '');
|
|
702
|
+
html += '</div>';
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
container.innerHTML = html;
|
|
707
|
+
container.scrollTop = container.scrollHeight;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
document.getElementById('log-viewer-tabs').addEventListener('click', function(e) {
|
|
711
|
+
var tab = e.target.closest('.log-viewer-tab');
|
|
712
|
+
if (!tab) return;
|
|
713
|
+
logViewerTab = tab.getAttribute('data-tab');
|
|
714
|
+
renderLogViewer();
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
document.getElementById('log-viewer-close').addEventListener('click', hideLogViewer);
|
|
718
|
+
document.getElementById('log-viewer-overlay').addEventListener('click', function(e) {
|
|
719
|
+
if (e.target === this) hideLogViewer();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
// ── Branch Action Modal ────────────────────────────────────────
|
|
723
|
+
function showBranchActions() {
|
|
724
|
+
var branches = getDisplayBranches();
|
|
725
|
+
if (!branches.length || selectedIndex >= branches.length) return;
|
|
726
|
+
var branch = branches[selectedIndex];
|
|
727
|
+
branchActionMode = true;
|
|
728
|
+
document.getElementById('branch-action-title').textContent = 'Actions: ' + branch.name;
|
|
729
|
+
|
|
730
|
+
var prStatus = (state.branchPrStatusMap || {})[branch.name];
|
|
731
|
+
var isCurrent = branch.name === state.currentBranch;
|
|
732
|
+
|
|
733
|
+
var actions = [];
|
|
734
|
+
|
|
735
|
+
// Open on web (GitHub/GitLab) — direct link if we have repo URL
|
|
736
|
+
var brUrl = getBranchUrl(branch.name);
|
|
737
|
+
if (brUrl) {
|
|
738
|
+
actions.push({ icon: '\\u{1f310}', label: 'Open branch on web', key: 'openLink', data: { url: brUrl } });
|
|
739
|
+
} else {
|
|
740
|
+
actions.push({ icon: '\\u{1f310}', label: 'Open branch on web', key: 'openBranchWeb', data: { branch: branch.name } });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// PR actions
|
|
744
|
+
var prUrl = prStatus ? getPrUrl(prStatus.number) : null;
|
|
745
|
+
if (prStatus && prUrl) {
|
|
746
|
+
actions.push({ icon: '\\u{1f517}', label: 'View PR #' + prStatus.number, key: 'openLink', data: { url: prUrl } });
|
|
747
|
+
} else if (prStatus && prStatus.url) {
|
|
748
|
+
actions.push({ icon: '\\u{1f517}', label: 'View PR #' + prStatus.number, key: 'openPrUrl', data: { url: prStatus.url } });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Copy actions
|
|
752
|
+
actions.push({ icon: '\\u{1f4cb}', label: 'Copy branch name', key: 'copy', data: { text: branch.name } });
|
|
753
|
+
if (branch.commit) {
|
|
754
|
+
actions.push({ icon: '\\u{1f4cb}', label: 'Copy commit hash (' + branch.commit + ')', key: 'copy', data: { text: branch.commit } });
|
|
755
|
+
}
|
|
756
|
+
if (prUrl) {
|
|
757
|
+
actions.push({ icon: '\\u{1f4cb}', label: 'Copy PR URL', key: 'copy', data: { text: prUrl } });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Pin/Unpin
|
|
761
|
+
var isPinnedBranch = pinnedBranches.indexOf(branch.name) !== -1;
|
|
762
|
+
actions.push({ icon: isPinnedBranch ? '\\u{1f4cc}' : '\\u{1f4cc}', label: isPinnedBranch ? 'Unpin branch' : 'Pin branch to top', key: 'pin', data: { branch: branch.name } });
|
|
763
|
+
|
|
764
|
+
// Switch to branch
|
|
765
|
+
if (!isCurrent) {
|
|
766
|
+
actions.push({ icon: '\\u{27a1}', label: 'Switch to this branch', key: 'switchBranch', data: { branch: branch.name } });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Pull
|
|
770
|
+
if (isCurrent) {
|
|
771
|
+
actions.push({ icon: '\\u{2b07}', label: 'Pull latest changes', key: 'pull', data: {} });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Fetch
|
|
775
|
+
actions.push({ icon: '\\u{1f504}', label: 'Fetch all remotes', key: 'fetch', data: {} });
|
|
776
|
+
|
|
777
|
+
var html = '';
|
|
778
|
+
for (var i = 0; i < actions.length; i++) {
|
|
779
|
+
var a = actions[i];
|
|
780
|
+
html += '<button class="action-item" data-action-key="' + escHtml(a.key) + '" data-action-data=\\'' + escHtml(JSON.stringify(a.data)) + '\\'>';
|
|
781
|
+
html += '<span class="action-icon">' + a.icon + '</span>';
|
|
782
|
+
html += '<span class="action-label">' + escHtml(a.label) + '</span>';
|
|
783
|
+
html += '</button>';
|
|
784
|
+
}
|
|
785
|
+
document.getElementById('branch-action-list').innerHTML = html;
|
|
786
|
+
document.getElementById('branch-action-overlay').className = 'modal-overlay active';
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function hideBranchActions() {
|
|
790
|
+
branchActionMode = false;
|
|
791
|
+
document.getElementById('branch-action-overlay').className = 'modal-overlay';
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
document.getElementById('branch-action-close').addEventListener('click', hideBranchActions);
|
|
795
|
+
document.getElementById('branch-action-overlay').addEventListener('click', function(e) {
|
|
796
|
+
if (e.target === this) hideBranchActions();
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
document.getElementById('branch-action-list').addEventListener('click', function(e) {
|
|
800
|
+
var btn = e.target.closest('.action-item');
|
|
801
|
+
if (!btn) return;
|
|
802
|
+
var key = btn.getAttribute('data-action-key');
|
|
803
|
+
var data = {};
|
|
804
|
+
try { data = JSON.parse(btn.getAttribute('data-action-data') || '{}'); } catch (err) { /* ignore */ }
|
|
805
|
+
|
|
806
|
+
hideBranchActions();
|
|
807
|
+
|
|
808
|
+
if (key === 'openLink') {
|
|
809
|
+
// Direct client-side link opening
|
|
810
|
+
window.open(data.url, '_blank', 'noopener');
|
|
811
|
+
showToast('Opening in browser...', 'info');
|
|
812
|
+
} else if (key === 'copy') {
|
|
813
|
+
copyToClipboard(data.text, null);
|
|
814
|
+
} else if (key === 'pin') {
|
|
815
|
+
var pIdx = pinnedBranches.indexOf(data.branch);
|
|
816
|
+
if (pIdx === -1) {
|
|
817
|
+
pinnedBranches.push(data.branch);
|
|
818
|
+
showToast('Pinned: ' + data.branch, 'success');
|
|
819
|
+
} else {
|
|
820
|
+
pinnedBranches.splice(pIdx, 1);
|
|
821
|
+
showToast('Unpinned: ' + data.branch, 'info');
|
|
822
|
+
}
|
|
823
|
+
savePrefs({ pinnedBranches: pinnedBranches });
|
|
824
|
+
renderBranches();
|
|
825
|
+
} else if (key === 'openBranchWeb' || key === 'openPrUrl') {
|
|
826
|
+
// Fallback: handled by the server sending back a URL
|
|
827
|
+
sendAction('openBrowser', data);
|
|
828
|
+
showToast('Opening in browser...', 'info');
|
|
829
|
+
} else if (key === 'switchBranch') {
|
|
830
|
+
sendAction('switchBranch', data);
|
|
831
|
+
showToast('Switching to ' + data.branch + '...', 'info');
|
|
832
|
+
} else {
|
|
833
|
+
sendAction(key, data);
|
|
834
|
+
showToast(key + '...', 'info');
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// ── Info Panel ─────────────────────────────────────────────────
|
|
839
|
+
function showInfo() {
|
|
840
|
+
if (!state) return;
|
|
841
|
+
infoMode = true;
|
|
842
|
+
var grid = document.getElementById('info-grid');
|
|
843
|
+
var rows = [
|
|
844
|
+
['Project', state.projectName || '-'],
|
|
845
|
+
['Version', 'v' + (state.version || '-')],
|
|
846
|
+
['Server Mode', state.serverMode || 'none'],
|
|
847
|
+
['Server Port', state.noServer ? 'N/A' : String(state.port || '-')],
|
|
848
|
+
['Server Running', state.serverRunning ? 'Yes' : 'No'],
|
|
849
|
+
['SSE Clients', String(state.clientCount || 0)],
|
|
850
|
+
['Current Branch', state.currentBranch || '-'],
|
|
851
|
+
['Polling Status', state.pollingStatus || 'idle'],
|
|
852
|
+
['Network', state.isOffline ? 'Offline' : 'Online'],
|
|
853
|
+
['Branches', String((state.branches || []).length)],
|
|
854
|
+
];
|
|
855
|
+
var html = '';
|
|
856
|
+
for (var i = 0; i < rows.length; i++) {
|
|
857
|
+
html += '<span class="info-label">' + escHtml(rows[i][0]) + '</span>';
|
|
858
|
+
html += '<span class="info-value">' + escHtml(rows[i][1]) + '</span>';
|
|
859
|
+
}
|
|
860
|
+
grid.innerHTML = html;
|
|
861
|
+
document.getElementById('info-overlay').className = 'modal-overlay active';
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function hideInfo() {
|
|
865
|
+
infoMode = false;
|
|
866
|
+
document.getElementById('info-overlay').className = 'modal-overlay';
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
document.getElementById('info-close').addEventListener('click', hideInfo);
|
|
870
|
+
document.getElementById('info-overlay').addEventListener('click', function(e) {
|
|
871
|
+
if (e.target === this) hideInfo();
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// ── Stash Management ───────────────────────────────────────────
|
|
875
|
+
function showStashDialog(pendingBranch) {
|
|
876
|
+
stashMode = true;
|
|
877
|
+
pendingStashBranch = pendingBranch || null;
|
|
878
|
+
var msg = pendingBranch
|
|
879
|
+
? 'You have uncommitted changes. Stash them before switching to <strong>' + escHtml(pendingBranch) + '</strong>?'
|
|
880
|
+
: 'Stash all uncommitted changes in the working directory?';
|
|
881
|
+
var html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:16px;">' + msg + '</div>';
|
|
882
|
+
html += '<div class="confirm-actions">';
|
|
883
|
+
html += '<button class="confirm-btn" id="stash-cancel">Cancel</button>';
|
|
884
|
+
html += '<button class="confirm-btn primary" id="stash-confirm">Stash & Continue</button>';
|
|
885
|
+
html += '</div>';
|
|
886
|
+
document.getElementById('stash-content').innerHTML = html;
|
|
887
|
+
document.getElementById('stash-overlay').className = 'modal-overlay active';
|
|
888
|
+
document.getElementById('stash-cancel').onclick = hideStash;
|
|
889
|
+
document.getElementById('stash-confirm').onclick = function() {
|
|
890
|
+
sendAction('stash', { pendingBranch: pendingStashBranch });
|
|
891
|
+
showToast('Stashing changes...', 'info');
|
|
892
|
+
hideStash();
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function hideStash() {
|
|
897
|
+
stashMode = false;
|
|
898
|
+
pendingStashBranch = null;
|
|
899
|
+
document.getElementById('stash-overlay').className = 'modal-overlay';
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
document.getElementById('stash-close').addEventListener('click', hideStash);
|
|
903
|
+
document.getElementById('stash-overlay').addEventListener('click', function(e) {
|
|
904
|
+
if (e.target === this) hideStash();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// ── Branch Cleanup ─────────────────────────────────────────────
|
|
908
|
+
function showCleanup() {
|
|
909
|
+
cleanupMode = true;
|
|
910
|
+
var html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:12px;">Scanning for branches with deleted remotes...</div>';
|
|
911
|
+
document.getElementById('cleanup-content').innerHTML = html;
|
|
912
|
+
document.getElementById('cleanup-overlay').className = 'modal-overlay active';
|
|
913
|
+
|
|
914
|
+
// Ask the server to find gone branches (we inspect state.branches for gone tracking hints)
|
|
915
|
+
// For now, look at branches that have no remote
|
|
916
|
+
var goneBranches = [];
|
|
917
|
+
if (state && state.branches) {
|
|
918
|
+
for (var i = 0; i < state.branches.length; i++) {
|
|
919
|
+
var b = state.branches[i];
|
|
920
|
+
if (b.isLocal && !b.hasRemote && b.name !== state.currentBranch) {
|
|
921
|
+
goneBranches.push(b.name);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (goneBranches.length === 0) {
|
|
927
|
+
html = '<div style="color:var(--text-dim);font-size:13px;padding:12px 0;">No stale branches found. All branches have active remotes.</div>';
|
|
928
|
+
html += '<div class="confirm-actions"><button class="confirm-btn" id="cleanup-done">OK</button></div>';
|
|
929
|
+
document.getElementById('cleanup-content').innerHTML = html;
|
|
930
|
+
document.getElementById('cleanup-done').onclick = hideCleanup;
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:8px;">Found ' + goneBranches.length + ' branch(es) with no remote tracking:</div>';
|
|
935
|
+
html += '<div class="cleanup-branch-list">';
|
|
936
|
+
for (var j = 0; j < goneBranches.length; j++) {
|
|
937
|
+
html += '<div class="cleanup-branch-item"><span class="cleanup-branch-icon">✖</span>' + escHtml(goneBranches[j]) + '</div>';
|
|
938
|
+
}
|
|
939
|
+
html += '</div>';
|
|
940
|
+
html += '<div class="confirm-actions">';
|
|
941
|
+
html += '<button class="confirm-btn" id="cleanup-cancel">Cancel</button>';
|
|
942
|
+
html += '<button class="confirm-btn danger" id="cleanup-safe">Safe Delete (-d)</button>';
|
|
943
|
+
html += '<button class="confirm-btn danger" id="cleanup-force">Force Delete (-D)</button>';
|
|
944
|
+
html += '</div>';
|
|
945
|
+
|
|
946
|
+
document.getElementById('cleanup-content').innerHTML = html;
|
|
947
|
+
document.getElementById('cleanup-cancel').onclick = hideCleanup;
|
|
948
|
+
document.getElementById('cleanup-safe').onclick = function() {
|
|
949
|
+
sendAction('deleteBranches', { branches: goneBranches, force: false });
|
|
950
|
+
showToast('Deleting ' + goneBranches.length + ' branches (safe)...', 'info');
|
|
951
|
+
hideCleanup();
|
|
952
|
+
};
|
|
953
|
+
document.getElementById('cleanup-force').onclick = function() {
|
|
954
|
+
showConfirm(
|
|
955
|
+
'Force Delete',
|
|
956
|
+
'Force delete ' + goneBranches.length + ' branch(es)? This may delete unmerged work.',
|
|
957
|
+
function() {
|
|
958
|
+
sendAction('deleteBranches', { branches: goneBranches, force: true });
|
|
959
|
+
showToast('Force deleting ' + goneBranches.length + ' branches...', 'warning');
|
|
960
|
+
hideCleanup();
|
|
961
|
+
},
|
|
962
|
+
{ danger: true, label: 'Force Delete' }
|
|
963
|
+
);
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function hideCleanup() {
|
|
968
|
+
cleanupMode = false;
|
|
969
|
+
document.getElementById('cleanup-overlay').className = 'modal-overlay';
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
document.getElementById('cleanup-close').addEventListener('click', hideCleanup);
|
|
973
|
+
document.getElementById('cleanup-overlay').addEventListener('click', function(e) {
|
|
974
|
+
if (e.target === this) hideCleanup();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// ── Update Notification ────────────────────────────────────────
|
|
978
|
+
function showUpdateModal() {
|
|
979
|
+
if (!state || !state.updateAvailable) return;
|
|
980
|
+
updateMode = true;
|
|
981
|
+
var html = '<div class="update-versions">';
|
|
982
|
+
html += '<span class="old-version">v' + escHtml(state.version || '?') + '</span>';
|
|
983
|
+
html += '<span class="arrow">→</span>';
|
|
984
|
+
html += '<span class="new-version">v' + escHtml(state.updateAvailable) + '</span>';
|
|
985
|
+
html += '</div>';
|
|
986
|
+
html += '<div class="update-info">A new version of git-watchtower is available.</div>';
|
|
987
|
+
if (state.updateInProgress) {
|
|
988
|
+
html += '<div class="update-progress">Update in progress...</div>';
|
|
989
|
+
} else {
|
|
990
|
+
html += '<div class="confirm-actions">';
|
|
991
|
+
html += '<button class="confirm-btn" id="update-dismiss">Dismiss</button>';
|
|
992
|
+
html += '<button class="confirm-btn primary" id="update-install">Update Now</button>';
|
|
993
|
+
html += '</div>';
|
|
994
|
+
}
|
|
995
|
+
document.getElementById('update-content').innerHTML = html;
|
|
996
|
+
document.getElementById('update-overlay').className = 'modal-overlay active';
|
|
997
|
+
if (!state.updateInProgress) {
|
|
998
|
+
document.getElementById('update-dismiss').onclick = hideUpdate;
|
|
999
|
+
document.getElementById('update-install').onclick = function() {
|
|
1000
|
+
sendAction('checkUpdate', { install: true });
|
|
1001
|
+
showToast('Installing update...', 'info');
|
|
1002
|
+
hideUpdate();
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function hideUpdate() {
|
|
1008
|
+
updateMode = false;
|
|
1009
|
+
document.getElementById('update-overlay').className = 'modal-overlay';
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
document.getElementById('update-close').addEventListener('click', hideUpdate);
|
|
1013
|
+
document.getElementById('update-overlay').addEventListener('click', function(e) {
|
|
1014
|
+
if (e.target === this) hideUpdate();
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// ── Session Stats ──────────────────────────────────────────────
|
|
1018
|
+
function renderSessionStats() {
|
|
1019
|
+
if (!state || !state.sessionStats) return;
|
|
1020
|
+
var s = state.sessionStats;
|
|
1021
|
+
var bar = document.getElementById('stats-bar');
|
|
1022
|
+
var activeBranches = 0;
|
|
1023
|
+
var staleBranches = 0;
|
|
1024
|
+
if (state.branches) {
|
|
1025
|
+
for (var i = 0; i < state.branches.length; i++) {
|
|
1026
|
+
var b = state.branches[i];
|
|
1027
|
+
// Consider stale if no updates and not current
|
|
1028
|
+
if (b.justUpdated || b.name === state.currentBranch) {
|
|
1029
|
+
activeBranches++;
|
|
1030
|
+
} else {
|
|
1031
|
+
staleBranches++;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
var html = '';
|
|
1036
|
+
html += '<span class="stat-item"><span class="stat-label">Session:</span> <span class="stat-value">' + escHtml(s.sessionDuration || '0m') + '</span></span>';
|
|
1037
|
+
html += '<span class="stat-item"><span class="stat-label">Lines:</span> <span class="stat-value">+' + (s.linesAdded || 0) + '/-' + (s.linesDeleted || 0) + '</span></span>';
|
|
1038
|
+
html += '<span class="stat-item"><span class="stat-label">Polls:</span> <span class="stat-value">' + (s.totalPolls || 0) + '</span> <span class="stat-label">(' + (s.hitRate || 0) + '% hit)</span></span>';
|
|
1039
|
+
if (s.lastUpdate) {
|
|
1040
|
+
html += '<span class="stat-item"><span class="stat-label">Last update:</span> <span class="stat-value">' + escHtml(s.lastUpdate) + '</span></span>';
|
|
1041
|
+
}
|
|
1042
|
+
html += '<span class="stat-item"><span class="stat-label">Active:</span> <span class="stat-value">' + activeBranches + '</span> <span class="stat-label">Stale:</span> <span class="stat-value">' + staleBranches + '</span></span>';
|
|
1043
|
+
bar.innerHTML = html;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ── Error Toast with Stash Hint ────────────────────────────────
|
|
1047
|
+
function showErrorToastWithHint(message, hint) {
|
|
1048
|
+
var container = document.getElementById('toast-container');
|
|
1049
|
+
var toast = document.createElement('div');
|
|
1050
|
+
toast.className = 'toast error';
|
|
1051
|
+
var html = '<span class="toast-icon">\\u2717</span>' + escHtml(message);
|
|
1052
|
+
if (hint) {
|
|
1053
|
+
html += '<span class="toast-action" data-hint="' + escHtml(hint) + '">' + escHtml(hint) + '</span>';
|
|
1054
|
+
}
|
|
1055
|
+
toast.innerHTML = html;
|
|
1056
|
+
container.appendChild(toast);
|
|
1057
|
+
requestAnimationFrame(function() {
|
|
1058
|
+
requestAnimationFrame(function() { toast.classList.add('visible'); });
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
// Handle hint click
|
|
1062
|
+
var hintEl = toast.querySelector('.toast-action');
|
|
1063
|
+
if (hintEl) {
|
|
1064
|
+
hintEl.addEventListener('click', function() {
|
|
1065
|
+
var h = this.getAttribute('data-hint');
|
|
1066
|
+
if (h === 'Press S to stash') {
|
|
1067
|
+
showStashDialog(pendingStashBranch);
|
|
1068
|
+
}
|
|
1069
|
+
toast.classList.remove('visible');
|
|
1070
|
+
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
setTimeout(function() {
|
|
1075
|
+
toast.classList.remove('visible');
|
|
1076
|
+
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
1077
|
+
}, 6000);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// ── Any modal open check ───────────────────────────────────────
|
|
1081
|
+
function anyModalOpen() {
|
|
1082
|
+
return logViewerMode || branchActionMode || infoMode || cleanupMode || updateMode || stashMode || confirmMode;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ── Keyboard ───────────────────────────────────────────────────
|
|
1086
|
+
document.addEventListener('keydown', function(e) {
|
|
1087
|
+
// Ignore when typing in input fields (other than search)
|
|
1088
|
+
if (e.target.tagName === 'INPUT' && e.target.id !== 'search-input') return;
|
|
1089
|
+
if (e.target.tagName === 'BUTTON') return;
|
|
1090
|
+
|
|
1091
|
+
// Any modal — Escape to close
|
|
1092
|
+
if (logViewerMode && e.key === 'Escape') { e.preventDefault(); hideLogViewer(); return; }
|
|
1093
|
+
if (branchActionMode && e.key === 'Escape') { e.preventDefault(); hideBranchActions(); return; }
|
|
1094
|
+
if (infoMode && e.key === 'Escape') { e.preventDefault(); hideInfo(); return; }
|
|
1095
|
+
if (cleanupMode && e.key === 'Escape') { e.preventDefault(); hideCleanup(); return; }
|
|
1096
|
+
if (updateMode && e.key === 'Escape') { e.preventDefault(); hideUpdate(); return; }
|
|
1097
|
+
if (stashMode && e.key === 'Escape') { e.preventDefault(); hideStash(); return; }
|
|
1098
|
+
|
|
1099
|
+
// Log viewer tab switching
|
|
1100
|
+
if (logViewerMode) {
|
|
1101
|
+
if (e.key === 'Tab' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
1102
|
+
e.preventDefault();
|
|
1103
|
+
logViewerTab = logViewerTab === 'server' ? 'activity' : 'server';
|
|
1104
|
+
renderLogViewer();
|
|
1105
|
+
}
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Block other keys while modals are open
|
|
1110
|
+
if (branchActionMode || infoMode || cleanupMode || updateMode || stashMode) return;
|
|
1111
|
+
|
|
1112
|
+
// Confirm dialog mode — Escape to cancel, Enter to confirm
|
|
1113
|
+
if (confirmMode) {
|
|
1114
|
+
if (e.key === 'Escape') { e.preventDefault(); hideConfirm(); }
|
|
1115
|
+
if (e.key === 'Enter') {
|
|
1116
|
+
e.preventDefault();
|
|
1117
|
+
var cb = confirmCallback;
|
|
1118
|
+
hideConfirm();
|
|
1119
|
+
if (cb) cb();
|
|
1120
|
+
}
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Search mode
|
|
1125
|
+
if (searchMode) {
|
|
1126
|
+
if (e.key === 'Escape') {
|
|
1127
|
+
e.preventDefault();
|
|
1128
|
+
searchMode = false;
|
|
1129
|
+
searchQuery = '';
|
|
1130
|
+
document.getElementById('search-bar').className = 'search-bar';
|
|
1131
|
+
document.getElementById('search-input').value = '';
|
|
1132
|
+
selectedIndex = 0;
|
|
1133
|
+
renderBranches();
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (e.key === 'Enter') {
|
|
1137
|
+
e.preventDefault();
|
|
1138
|
+
searchMode = false;
|
|
1139
|
+
document.getElementById('search-bar').className = 'search-bar';
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (e.key === 'ArrowDown' || (e.key === 'j' && e.ctrlKey)) {
|
|
1143
|
+
e.preventDefault();
|
|
1144
|
+
moveSelection(1);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (e.key === 'ArrowUp' || (e.key === 'k' && e.ctrlKey)) {
|
|
1148
|
+
e.preventDefault();
|
|
1149
|
+
moveSelection(-1);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Tab switching with number keys (1-9)
|
|
1156
|
+
var projects = (state && state.projects) || [];
|
|
1157
|
+
if (projects.length > 1 && e.key >= '1' && e.key <= '9') {
|
|
1158
|
+
var tabIdx = parseInt(e.key, 10) - 1;
|
|
1159
|
+
if (tabIdx < projects.length) {
|
|
1160
|
+
e.preventDefault();
|
|
1161
|
+
switchTab(projects[tabIdx].id);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Tab cycling with Tab key
|
|
1167
|
+
if (e.key === 'Tab' && projects.length > 1) {
|
|
1168
|
+
e.preventDefault();
|
|
1169
|
+
var curIdx = projects.findIndex(function(p) { return p.id === activeTabId; });
|
|
1170
|
+
var nextIdx = e.shiftKey
|
|
1171
|
+
? (curIdx - 1 + projects.length) % projects.length
|
|
1172
|
+
: (curIdx + 1) % projects.length;
|
|
1173
|
+
switchTab(projects[nextIdx].id);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Normal mode
|
|
1178
|
+
switch (e.key) {
|
|
1179
|
+
case 'j':
|
|
1180
|
+
case 'ArrowDown':
|
|
1181
|
+
e.preventDefault();
|
|
1182
|
+
moveSelection(1);
|
|
1183
|
+
break;
|
|
1184
|
+
case 'k':
|
|
1185
|
+
case 'ArrowUp':
|
|
1186
|
+
e.preventDefault();
|
|
1187
|
+
moveSelection(-1);
|
|
1188
|
+
break;
|
|
1189
|
+
case 'Enter':
|
|
1190
|
+
e.preventDefault();
|
|
1191
|
+
var branches = getDisplayBranches();
|
|
1192
|
+
if (branches.length > 0 && selectedIndex < branches.length) {
|
|
1193
|
+
var b = branches[selectedIndex];
|
|
1194
|
+
if (b.isDeleted) {
|
|
1195
|
+
showToast('Cannot switch to a deleted branch', 'error');
|
|
1196
|
+
} else if (b.name === state.currentBranch) {
|
|
1197
|
+
showToast('Already on ' + b.name, 'info');
|
|
1198
|
+
} else {
|
|
1199
|
+
sendAction('switchBranch', { branch: b.name });
|
|
1200
|
+
showToast('Switching to ' + b.name + '...', 'info');
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
break;
|
|
1204
|
+
case '/':
|
|
1205
|
+
e.preventDefault();
|
|
1206
|
+
searchMode = true;
|
|
1207
|
+
searchQuery = '';
|
|
1208
|
+
selectedIndex = 0;
|
|
1209
|
+
document.getElementById('search-bar').className = 'search-bar active';
|
|
1210
|
+
var input = document.getElementById('search-input');
|
|
1211
|
+
input.value = '';
|
|
1212
|
+
input.focus();
|
|
1213
|
+
break;
|
|
1214
|
+
case 'p':
|
|
1215
|
+
e.preventDefault();
|
|
1216
|
+
sendAction('pull');
|
|
1217
|
+
showToast('Pulling current branch...', 'info');
|
|
1218
|
+
break;
|
|
1219
|
+
case 'f':
|
|
1220
|
+
e.preventDefault();
|
|
1221
|
+
sendAction('fetch');
|
|
1222
|
+
showToast('Fetching all branches...', 'info');
|
|
1223
|
+
break;
|
|
1224
|
+
case 'r':
|
|
1225
|
+
e.preventDefault();
|
|
1226
|
+
if (state && state.serverMode === 'static') {
|
|
1227
|
+
sendAction('reloadBrowsers');
|
|
1228
|
+
showToast('Reloading browsers...', 'info');
|
|
1229
|
+
}
|
|
1230
|
+
break;
|
|
1231
|
+
case 'R':
|
|
1232
|
+
e.preventDefault();
|
|
1233
|
+
if (state && state.serverMode === 'command') {
|
|
1234
|
+
showConfirm(
|
|
1235
|
+
'Restart Server',
|
|
1236
|
+
'Restart the dev server process?',
|
|
1237
|
+
function() {
|
|
1238
|
+
sendAction('restartServer');
|
|
1239
|
+
showToast('Restarting server...', 'info');
|
|
1240
|
+
},
|
|
1241
|
+
{ label: 'Restart' }
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
break;
|
|
1245
|
+
case 'c':
|
|
1246
|
+
e.preventDefault();
|
|
1247
|
+
sendAction('toggleCasino');
|
|
1248
|
+
break;
|
|
1249
|
+
case 'o':
|
|
1250
|
+
e.preventDefault();
|
|
1251
|
+
sendAction('openBrowser');
|
|
1252
|
+
showToast('Opening in browser...', 'info');
|
|
1253
|
+
break;
|
|
1254
|
+
case 'h':
|
|
1255
|
+
e.preventDefault();
|
|
1256
|
+
if (state && state.switchHistory && state.switchHistory.length > 0) {
|
|
1257
|
+
var last = state.switchHistory[0];
|
|
1258
|
+
var histMsg = 'Last: ' + last.from + ' \\u2192 ' + last.to;
|
|
1259
|
+
if (state.switchHistory.length > 1) histMsg += ' (+' + (state.switchHistory.length - 1) + ' more)';
|
|
1260
|
+
showToast(histMsg, 'info');
|
|
1261
|
+
} else {
|
|
1262
|
+
showToast('No switch history yet', 'info');
|
|
1263
|
+
}
|
|
1264
|
+
break;
|
|
1265
|
+
case 'u':
|
|
1266
|
+
e.preventDefault();
|
|
1267
|
+
sendAction('undo');
|
|
1268
|
+
showToast('Undoing last switch...', 'info');
|
|
1269
|
+
break;
|
|
1270
|
+
case 's':
|
|
1271
|
+
e.preventDefault();
|
|
1272
|
+
sendAction('toggleSound');
|
|
1273
|
+
showToast(state && state.soundEnabled ? 'Sound off' : 'Sound on', 'info');
|
|
1274
|
+
break;
|
|
1275
|
+
case 'b':
|
|
1276
|
+
e.preventDefault();
|
|
1277
|
+
showBranchActions();
|
|
1278
|
+
break;
|
|
1279
|
+
case 'i':
|
|
1280
|
+
e.preventDefault();
|
|
1281
|
+
showInfo();
|
|
1282
|
+
break;
|
|
1283
|
+
case 'l':
|
|
1284
|
+
e.preventDefault();
|
|
1285
|
+
showLogViewer();
|
|
1286
|
+
break;
|
|
1287
|
+
case 'S':
|
|
1288
|
+
e.preventDefault();
|
|
1289
|
+
showStashDialog(null);
|
|
1290
|
+
break;
|
|
1291
|
+
case 'd':
|
|
1292
|
+
e.preventDefault();
|
|
1293
|
+
showCleanup();
|
|
1294
|
+
break;
|
|
1295
|
+
case 'Escape':
|
|
1296
|
+
e.preventDefault();
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
// Search input handler
|
|
1302
|
+
document.getElementById('search-input').addEventListener('input', function(e) {
|
|
1303
|
+
searchQuery = e.target.value;
|
|
1304
|
+
selectedIndex = 0;
|
|
1305
|
+
renderBranches();
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
function moveSelection(delta) {
|
|
1309
|
+
var branches = getDisplayBranches();
|
|
1310
|
+
var newIndex = selectedIndex + delta;
|
|
1311
|
+
if (newIndex >= 0 && newIndex < branches.length) {
|
|
1312
|
+
selectedIndex = newIndex;
|
|
1313
|
+
renderBranches();
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ── Click Handlers ─────────────────────────────────────────────
|
|
1318
|
+
document.getElementById('branch-list').addEventListener('click', function(e) {
|
|
1319
|
+
var item = e.target.closest('.branch-item');
|
|
1320
|
+
if (!item) return;
|
|
1321
|
+
var idx = parseInt(item.getAttribute('data-index'), 10);
|
|
1322
|
+
if (isNaN(idx)) return;
|
|
1323
|
+
selectedIndex = idx;
|
|
1324
|
+
renderBranches();
|
|
1325
|
+
|
|
1326
|
+
// Double-click to switch with confirmation
|
|
1327
|
+
if (e.detail === 2) {
|
|
1328
|
+
var branches = getDisplayBranches();
|
|
1329
|
+
var br = branches[idx];
|
|
1330
|
+
if (br && !br.isDeleted && br.name !== state.currentBranch) {
|
|
1331
|
+
sendAction('switchBranch', { branch: br.name });
|
|
1332
|
+
showToast('Switching to ' + br.name + '...', 'info');
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
document.getElementById('confirm-overlay').addEventListener('click', function(e) {
|
|
1338
|
+
if (e.target === this) hideConfirm();
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
// Tab clicks
|
|
1342
|
+
document.getElementById('tab-bar').addEventListener('click', function(e) {
|
|
1343
|
+
var tab = e.target.closest('.tab');
|
|
1344
|
+
if (!tab) return;
|
|
1345
|
+
var projectId = tab.getAttribute('data-project-id');
|
|
1346
|
+
if (projectId) switchTab(projectId);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
// ── Preferences Bar ─────────────────────────────────────────────
|
|
1350
|
+
function renderPrefsBar() {
|
|
1351
|
+
// Insert prefs controls into footer if not already there
|
|
1352
|
+
var footer = document.getElementById('footer');
|
|
1353
|
+
var existing = document.getElementById('prefs-bar');
|
|
1354
|
+
if (!existing) {
|
|
1355
|
+
var div = document.createElement('span');
|
|
1356
|
+
div.id = 'prefs-bar';
|
|
1357
|
+
div.style.cssText = 'display:flex;gap:6px;align-items:center;margin-left:auto;';
|
|
1358
|
+
div.innerHTML =
|
|
1359
|
+
'<button class="pref-btn' + (sortOrder === 'default' ? ' active' : '') + '" data-sort="default" title="Default sort">Default</button>' +
|
|
1360
|
+
'<button class="pref-btn' + (sortOrder === 'alpha' ? ' active' : '') + '" data-sort="alpha" title="Sort alphabetically">A-Z</button>' +
|
|
1361
|
+
'<button class="pref-btn' + (sortOrder === 'recent' ? ' active' : '') + '" data-sort="recent" title="Sort by most recent">Recent</button>' +
|
|
1362
|
+
'<button class="pref-btn" id="pin-selected-btn" title="Pin/unpin selected branch">Pin</button>' +
|
|
1363
|
+
'<button class="pref-btn' + (sidebarCollapsed ? ' active' : '') + '" id="toggle-sidebar-btn" title="Toggle sidebar">Sidebar</button>';
|
|
1364
|
+
footer.appendChild(div);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Prefs bar click handler
|
|
1369
|
+
document.getElementById('footer').addEventListener('click', function(e) {
|
|
1370
|
+
var sortBtn = e.target.closest('[data-sort]');
|
|
1371
|
+
if (sortBtn) {
|
|
1372
|
+
sortOrder = sortBtn.getAttribute('data-sort');
|
|
1373
|
+
savePrefs({ sortOrder: sortOrder });
|
|
1374
|
+
var sortBtns = document.querySelectorAll('[data-sort]');
|
|
1375
|
+
for (var i = 0; i < sortBtns.length; i++) {
|
|
1376
|
+
sortBtns[i].className = 'pref-btn' + (sortBtns[i].getAttribute('data-sort') === sortOrder ? ' active' : '');
|
|
1377
|
+
}
|
|
1378
|
+
renderBranches();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (e.target.id === 'pin-selected-btn') {
|
|
1382
|
+
var branches = getDisplayBranches();
|
|
1383
|
+
if (branches.length > 0 && selectedIndex < branches.length) {
|
|
1384
|
+
var bn = branches[selectedIndex].name;
|
|
1385
|
+
var idx = pinnedBranches.indexOf(bn);
|
|
1386
|
+
if (idx === -1) {
|
|
1387
|
+
pinnedBranches.push(bn);
|
|
1388
|
+
showToast('Pinned: ' + bn, 'success');
|
|
1389
|
+
} else {
|
|
1390
|
+
pinnedBranches.splice(idx, 1);
|
|
1391
|
+
showToast('Unpinned: ' + bn, 'info');
|
|
1392
|
+
}
|
|
1393
|
+
savePrefs({ pinnedBranches: pinnedBranches });
|
|
1394
|
+
renderBranches();
|
|
1395
|
+
}
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (e.target.id === 'toggle-sidebar-btn') {
|
|
1399
|
+
sidebarCollapsed = !sidebarCollapsed;
|
|
1400
|
+
savePrefs({ sidebarCollapsed: sidebarCollapsed });
|
|
1401
|
+
var layout = document.querySelector('.layout');
|
|
1402
|
+
if (sidebarCollapsed) {
|
|
1403
|
+
layout.classList.add('sidebar-collapsed');
|
|
1404
|
+
} else {
|
|
1405
|
+
layout.classList.remove('sidebar-collapsed');
|
|
1406
|
+
}
|
|
1407
|
+
e.target.className = 'pref-btn' + (sidebarCollapsed ? ' active' : '');
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
// ── Sidebar Toggle (header) ───────────────────────────────────
|
|
1413
|
+
document.getElementById('sidebar-toggle').addEventListener('click', function() {
|
|
1414
|
+
sidebarCollapsed = !sidebarCollapsed;
|
|
1415
|
+
savePrefs({ sidebarCollapsed: sidebarCollapsed });
|
|
1416
|
+
var layout = document.querySelector('.layout');
|
|
1417
|
+
layout.classList.toggle('sidebar-collapsed', sidebarCollapsed);
|
|
1418
|
+
var btn = document.getElementById('toggle-sidebar-btn');
|
|
1419
|
+
if (btn) btn.className = 'pref-btn' + (sidebarCollapsed ? ' active' : '');
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
// ── Copy button delegation ────────────────────────────────────
|
|
1423
|
+
document.addEventListener('click', function(e) {
|
|
1424
|
+
var copyBtn = e.target.closest('.copy-btn');
|
|
1425
|
+
if (!copyBtn) return;
|
|
1426
|
+
var text = copyBtn.getAttribute('data-copy');
|
|
1427
|
+
if (text) {
|
|
1428
|
+
e.preventDefault();
|
|
1429
|
+
e.stopPropagation();
|
|
1430
|
+
copyToClipboard(text, copyBtn);
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
// ── Utility ────────────────────────────────────────────────────
|
|
1435
|
+
function escHtml(s) {
|
|
1436
|
+
if (!s) return '';
|
|
1437
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ── Init ───────────────────────────────────────────────────────
|
|
1441
|
+
connect();
|
|
1442
|
+
})();
|
|
1443
|
+
`;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
module.exports = { getDashboardJs };
|