git-watchtower 1.10.4 → 1.10.5
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/js.js +293 -315
package/package.json
CHANGED
package/src/server/web-ui/js.js
CHANGED
|
@@ -33,56 +33,56 @@ function getDashboardJs() {
|
|
|
33
33
|
'use strict';
|
|
34
34
|
|
|
35
35
|
// ── State ──────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
36
|
+
let state = null;
|
|
37
|
+
let prevBranches = null; // for notification diffing
|
|
38
|
+
let selectedIndex = 0;
|
|
39
|
+
let searchMode = false;
|
|
40
|
+
let searchQuery = '';
|
|
41
|
+
let confirmMode = false;
|
|
42
|
+
let confirmCallback = null;
|
|
43
|
+
let connected = false;
|
|
44
|
+
let flashTimer = null;
|
|
45
|
+
let activeTabId = null;
|
|
46
|
+
let logViewerMode = false;
|
|
47
|
+
let logViewerTab = 'server';
|
|
48
|
+
let branchActionMode = false;
|
|
49
|
+
let infoMode = false;
|
|
50
|
+
let cleanupMode = false;
|
|
51
|
+
let updateMode = false;
|
|
52
|
+
let stashMode = false;
|
|
53
|
+
let pendingStashBranch = null;
|
|
54
|
+
let updateNotificationShown = false;
|
|
55
|
+
let remoteTabPollTimer = null;
|
|
56
56
|
|
|
57
57
|
// ── Persistent Preferences (localStorage) ─────────────────────
|
|
58
|
-
|
|
58
|
+
const PREFS_KEY = 'git-watchtower-prefs';
|
|
59
59
|
function loadPrefs() {
|
|
60
60
|
try {
|
|
61
61
|
return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
|
|
62
62
|
} catch (e) { return {}; }
|
|
63
63
|
}
|
|
64
64
|
function savePrefs(updates) {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const prefs = loadPrefs();
|
|
66
|
+
Object.keys(updates).forEach((k) => { prefs[k] = updates[k]; });
|
|
67
67
|
try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch (e) { /* ignore */ }
|
|
68
68
|
return prefs;
|
|
69
69
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
const prefs = loadPrefs();
|
|
71
|
+
let sidebarCollapsed = prefs.sidebarCollapsed || false;
|
|
72
|
+
let sortOrder = prefs.sortOrder || 'default';
|
|
73
|
+
let pinnedBranches = prefs.pinnedBranches || [];
|
|
74
74
|
|
|
75
75
|
// Apply initial sidebar state
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
{
|
|
77
|
+
const layout = document.querySelector('.layout');
|
|
78
78
|
if (sidebarCollapsed) layout.classList.add('sidebar-collapsed');
|
|
79
|
-
}
|
|
79
|
+
}
|
|
80
80
|
|
|
81
81
|
// ── Browser Notifications ─────────────────────────────────────
|
|
82
|
-
|
|
82
|
+
let notifPermission = typeof Notification !== 'undefined' ? Notification.permission : 'denied';
|
|
83
83
|
|
|
84
84
|
function updateNotifButton() {
|
|
85
|
-
|
|
85
|
+
const btn = document.getElementById('notif-btn');
|
|
86
86
|
if (notifPermission === 'granted') {
|
|
87
87
|
btn.className = 'notif-btn granted';
|
|
88
88
|
btn.textContent = 'notifs on';
|
|
@@ -96,13 +96,13 @@ function getDashboardJs() {
|
|
|
96
96
|
}
|
|
97
97
|
updateNotifButton();
|
|
98
98
|
|
|
99
|
-
document.getElementById('notif-btn').addEventListener('click',
|
|
99
|
+
document.getElementById('notif-btn').addEventListener('click', () => {
|
|
100
100
|
if (notifPermission === 'granted' || notifPermission === 'denied') return;
|
|
101
101
|
if (typeof Notification === 'undefined') {
|
|
102
102
|
showToast('Notifications not supported in this browser', 'warning');
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
-
Notification.requestPermission().then(
|
|
105
|
+
Notification.requestPermission().then((perm) => {
|
|
106
106
|
notifPermission = perm;
|
|
107
107
|
updateNotifButton();
|
|
108
108
|
if (perm === 'granted') {
|
|
@@ -114,20 +114,19 @@ function getDashboardJs() {
|
|
|
114
114
|
function sendNotification(title, body, tag) {
|
|
115
115
|
if (notifPermission !== 'granted') return;
|
|
116
116
|
try {
|
|
117
|
-
|
|
118
|
-
setTimeout(
|
|
117
|
+
const n = new Notification(title, { body, tag: tag || 'git-watchtower', icon: '', silent: false });
|
|
118
|
+
setTimeout(() => n.close(), 8000);
|
|
119
119
|
} catch (e) { /* ignore */ }
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function diffBranchesForNotifications(oldBranches, newBranches) {
|
|
123
123
|
if (!oldBranches || !newBranches) return;
|
|
124
|
-
|
|
125
|
-
for (
|
|
126
|
-
oldMap[
|
|
124
|
+
const oldMap = {};
|
|
125
|
+
for (const ob of oldBranches) {
|
|
126
|
+
oldMap[ob.name] = ob;
|
|
127
127
|
}
|
|
128
|
-
for (
|
|
129
|
-
|
|
130
|
-
var ob = oldMap[nb.name];
|
|
128
|
+
for (const nb of newBranches) {
|
|
129
|
+
const ob = oldMap[nb.name];
|
|
131
130
|
if (!ob && nb.isNew) {
|
|
132
131
|
sendNotification('New Branch', nb.name + ' was created', 'new-' + nb.name);
|
|
133
132
|
} else if (ob && !ob.justUpdated && nb.justUpdated) {
|
|
@@ -136,15 +135,9 @@ function getDashboardJs() {
|
|
|
136
135
|
}
|
|
137
136
|
// Check PR state changes
|
|
138
137
|
if (state && state.branchPrStatusMap) {
|
|
139
|
-
for (
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
if (pr && pr.state === 'MERGED') {
|
|
143
|
-
// Only notify once - check if it was not merged before
|
|
144
|
-
var oldBranch = oldMap[bn];
|
|
145
|
-
if (oldBranch) {
|
|
146
|
-
sendNotification('PR Merged', 'PR #' + pr.number + ' for ' + bn + ' was merged', 'merged-' + bn);
|
|
147
|
-
}
|
|
138
|
+
for (const [bn, pr] of Object.entries(state.branchPrStatusMap)) {
|
|
139
|
+
if (pr && pr.state === 'MERGED' && oldMap[bn]) {
|
|
140
|
+
sendNotification('PR Merged', 'PR #' + pr.number + ' for ' + bn + ' was merged', 'merged-' + bn);
|
|
148
141
|
}
|
|
149
142
|
}
|
|
150
143
|
}
|
|
@@ -152,17 +145,17 @@ function getDashboardJs() {
|
|
|
152
145
|
|
|
153
146
|
// ── Clipboard Helper ──────────────────────────────────────────
|
|
154
147
|
function copyToClipboard(text, btnEl) {
|
|
155
|
-
navigator.clipboard.writeText(text).then(
|
|
148
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
156
149
|
if (btnEl) {
|
|
157
150
|
btnEl.classList.add('copied');
|
|
158
151
|
btnEl.innerHTML = '✓';
|
|
159
|
-
setTimeout(
|
|
152
|
+
setTimeout(() => {
|
|
160
153
|
btnEl.classList.remove('copied');
|
|
161
154
|
btnEl.innerHTML = '📋';
|
|
162
155
|
}, 1500);
|
|
163
156
|
}
|
|
164
157
|
showToast('Copied: ' + text, 'success');
|
|
165
|
-
}).catch(
|
|
158
|
+
}).catch(() => {
|
|
166
159
|
showToast('Failed to copy', 'error');
|
|
167
160
|
});
|
|
168
161
|
}
|
|
@@ -172,19 +165,18 @@ function getDashboardJs() {
|
|
|
172
165
|
return (state && state.repoWebUrl) ? state.repoWebUrl.replace(/\\/tree\\/.*$/, '') : null;
|
|
173
166
|
}
|
|
174
167
|
function getBranchUrl(branchName) {
|
|
175
|
-
|
|
168
|
+
const base = getRepoUrl();
|
|
176
169
|
if (!base) return null;
|
|
177
170
|
return base + '/tree/' + encodeURIComponent(branchName);
|
|
178
171
|
}
|
|
179
172
|
function getCommitUrl(hash) {
|
|
180
|
-
|
|
173
|
+
const base = getRepoUrl();
|
|
181
174
|
if (!base || !hash) return null;
|
|
182
175
|
return base + '/commit/' + hash;
|
|
183
176
|
}
|
|
184
177
|
function getPrUrl(prNumber) {
|
|
185
|
-
|
|
178
|
+
const base = getRepoUrl();
|
|
186
179
|
if (!base || !prNumber) return null;
|
|
187
|
-
// Detect GitLab by URL pattern
|
|
188
180
|
if (base.indexOf('gitlab') !== -1) {
|
|
189
181
|
return base + '/-/merge_requests/' + prNumber;
|
|
190
182
|
}
|
|
@@ -192,20 +184,20 @@ function getDashboardJs() {
|
|
|
192
184
|
}
|
|
193
185
|
|
|
194
186
|
// ── SSE Connection ─────────────────────────────────────────────
|
|
195
|
-
|
|
187
|
+
let evtSource = null;
|
|
196
188
|
|
|
197
189
|
function connect() {
|
|
198
190
|
if (evtSource) { evtSource.close(); }
|
|
199
191
|
evtSource = new EventSource('/api/events');
|
|
200
192
|
|
|
201
|
-
evtSource.onopen =
|
|
193
|
+
evtSource.onopen = () => {
|
|
202
194
|
connected = true;
|
|
203
195
|
updateConnectionStatus();
|
|
204
196
|
};
|
|
205
197
|
|
|
206
|
-
evtSource.addEventListener('state',
|
|
198
|
+
evtSource.addEventListener('state', (e) => {
|
|
207
199
|
try {
|
|
208
|
-
|
|
200
|
+
const newState = JSON.parse(e.data);
|
|
209
201
|
if (!activeTabId && newState.activeProjectId) {
|
|
210
202
|
activeTabId = newState.activeProjectId;
|
|
211
203
|
}
|
|
@@ -214,16 +206,14 @@ function getDashboardJs() {
|
|
|
214
206
|
// data (branches, PRs, activity, etc.) — only update global
|
|
215
207
|
// metadata so the tab bar, connection status, and version info
|
|
216
208
|
// stay current.
|
|
217
|
-
|
|
209
|
+
const viewingLocalProject = !activeTabId || activeTabId === newState.activeProjectId;
|
|
218
210
|
if (viewingLocalProject) {
|
|
219
|
-
// Diff branches for desktop notifications
|
|
220
211
|
if (state && state.branches) {
|
|
221
212
|
diffBranchesForNotifications(state.branches, newState.branches || []);
|
|
222
213
|
}
|
|
223
214
|
prevBranches = state ? state.branches : null;
|
|
224
215
|
state = newState;
|
|
225
216
|
} else {
|
|
226
|
-
// Viewing a remote tab — preserve per-project fields, update globals only
|
|
227
217
|
if (state) {
|
|
228
218
|
state.projects = newState.projects;
|
|
229
219
|
state.version = newState.version;
|
|
@@ -239,16 +229,16 @@ function getDashboardJs() {
|
|
|
239
229
|
} catch (err) { /* ignore parse errors */ }
|
|
240
230
|
});
|
|
241
231
|
|
|
242
|
-
evtSource.addEventListener('flash',
|
|
232
|
+
evtSource.addEventListener('flash', (e) => {
|
|
243
233
|
try {
|
|
244
|
-
|
|
234
|
+
const data = JSON.parse(e.data);
|
|
245
235
|
showFlash(data.text, data.type);
|
|
246
236
|
} catch (err) { /* ignore */ }
|
|
247
237
|
});
|
|
248
238
|
|
|
249
|
-
evtSource.addEventListener('actionResult',
|
|
239
|
+
evtSource.addEventListener('actionResult', (e) => {
|
|
250
240
|
try {
|
|
251
|
-
|
|
241
|
+
const data = JSON.parse(e.data);
|
|
252
242
|
if (!data.success && data.message && data.message.indexOf('uncommitted') !== -1) {
|
|
253
243
|
pendingStashBranch = data.branch || null;
|
|
254
244
|
showErrorToastWithHint(data.message, 'Press S to stash');
|
|
@@ -258,15 +248,15 @@ function getDashboardJs() {
|
|
|
258
248
|
} catch (err) { /* ignore */ }
|
|
259
249
|
});
|
|
260
250
|
|
|
261
|
-
evtSource.onerror =
|
|
251
|
+
evtSource.onerror = () => {
|
|
262
252
|
connected = false;
|
|
263
253
|
updateConnectionStatus();
|
|
264
254
|
};
|
|
265
255
|
}
|
|
266
256
|
|
|
267
257
|
function updateConnectionStatus() {
|
|
268
|
-
|
|
269
|
-
|
|
258
|
+
const dot = document.getElementById('connection-dot');
|
|
259
|
+
const badge = document.getElementById('status-badge');
|
|
270
260
|
if (connected) {
|
|
271
261
|
dot.className = 'connection-dot connected';
|
|
272
262
|
badge.className = 'badge badge-online';
|
|
@@ -280,48 +270,98 @@ function getDashboardJs() {
|
|
|
280
270
|
|
|
281
271
|
// ── Actions ────────────────────────────────────────────────────
|
|
282
272
|
function sendAction(action, payload) {
|
|
283
|
-
|
|
273
|
+
const xhr = new XMLHttpRequest();
|
|
284
274
|
xhr.open('POST', '/api/action');
|
|
285
275
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
286
|
-
|
|
276
|
+
const data = { action, payload: payload || {} };
|
|
287
277
|
if (activeTabId) data.projectId = activeTabId;
|
|
288
278
|
xhr.send(JSON.stringify(data));
|
|
289
279
|
}
|
|
290
280
|
|
|
291
281
|
// ── Flash Messages ─────────────────────────────────────────────
|
|
292
282
|
function showFlash(text, type) {
|
|
293
|
-
|
|
283
|
+
const el = document.getElementById('flash');
|
|
294
284
|
el.textContent = text;
|
|
295
285
|
el.className = 'flash visible ' + (type || 'info');
|
|
296
286
|
clearTimeout(flashTimer);
|
|
297
|
-
flashTimer = setTimeout(
|
|
298
|
-
el.className = 'flash';
|
|
299
|
-
}, 3000);
|
|
287
|
+
flashTimer = setTimeout(() => { el.className = 'flash'; }, 3000);
|
|
300
288
|
}
|
|
301
289
|
|
|
302
290
|
// ── Toast Notifications ────────────────────────────────────────
|
|
303
291
|
function showToast(text, type) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
292
|
+
const container = document.getElementById('toast-container');
|
|
293
|
+
const toast = document.createElement('div');
|
|
294
|
+
const icons = { success: '\\u2713', error: '\\u2717', info: '\\u2139', warning: '\\u26a0' };
|
|
307
295
|
toast.className = 'toast ' + (type || 'info');
|
|
308
296
|
toast.innerHTML = '<span class="toast-icon">' + (icons[type] || icons.info) + '</span>' + escHtml(text);
|
|
309
297
|
container.appendChild(toast);
|
|
310
|
-
requestAnimationFrame(
|
|
311
|
-
requestAnimationFrame(
|
|
298
|
+
requestAnimationFrame(() => {
|
|
299
|
+
requestAnimationFrame(() => toast.classList.add('visible'));
|
|
312
300
|
});
|
|
313
|
-
setTimeout(
|
|
301
|
+
setTimeout(() => {
|
|
314
302
|
toast.classList.remove('visible');
|
|
315
|
-
setTimeout(
|
|
303
|
+
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
316
304
|
}, 4000);
|
|
317
305
|
}
|
|
318
306
|
|
|
307
|
+
// ── Modal Helper ───────────────────────────────────────────────
|
|
308
|
+
// Reusable helper that manages show/hide, overlay-click-to-close,
|
|
309
|
+
// close-button click, and Escape key for standard modal overlays.
|
|
310
|
+
const _openModals = [];
|
|
311
|
+
|
|
312
|
+
function Modal(overlayId, closeId) {
|
|
313
|
+
this.overlay = document.getElementById(overlayId);
|
|
314
|
+
this.isOpen = false;
|
|
315
|
+
this.onHide = null;
|
|
316
|
+
if (closeId) {
|
|
317
|
+
const closeBtn = document.getElementById(closeId);
|
|
318
|
+
if (closeBtn) closeBtn.addEventListener('click', () => this.hide());
|
|
319
|
+
}
|
|
320
|
+
this.overlay.addEventListener('click', (e) => {
|
|
321
|
+
if (e.target === this.overlay) this.hide();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
Modal.prototype.show = function() {
|
|
326
|
+
this.isOpen = true;
|
|
327
|
+
this.overlay.className = 'modal-overlay active';
|
|
328
|
+
if (_openModals.indexOf(this) === -1) _openModals.push(this);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
Modal.prototype.hide = function() {
|
|
332
|
+
this.isOpen = false;
|
|
333
|
+
this.overlay.className = 'modal-overlay';
|
|
334
|
+
const idx = _openModals.indexOf(this);
|
|
335
|
+
if (idx !== -1) _openModals.splice(idx, 1);
|
|
336
|
+
if (this.onHide) this.onHide();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
function anyModalOpen() {
|
|
340
|
+
return _openModals.length > 0 || confirmMode;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Create modal instances
|
|
344
|
+
const logViewerModal = new Modal('log-viewer-overlay', 'log-viewer-close');
|
|
345
|
+
const branchActionModal = new Modal('branch-action-overlay', 'branch-action-close');
|
|
346
|
+
const infoModal = new Modal('info-overlay', 'info-close');
|
|
347
|
+
const stashModal = new Modal('stash-overlay', 'stash-close');
|
|
348
|
+
const cleanupModal = new Modal('cleanup-overlay', 'cleanup-close');
|
|
349
|
+
const updateModal = new Modal('update-overlay', 'update-close');
|
|
350
|
+
|
|
351
|
+
// Per-modal hide callbacks for state cleanup
|
|
352
|
+
logViewerModal.onHide = () => { logViewerMode = false; };
|
|
353
|
+
branchActionModal.onHide = () => { branchActionMode = false; };
|
|
354
|
+
infoModal.onHide = () => { infoMode = false; };
|
|
355
|
+
stashModal.onHide = () => { stashMode = false; pendingStashBranch = null; };
|
|
356
|
+
cleanupModal.onHide = () => { cleanupMode = false; };
|
|
357
|
+
updateModal.onHide = () => { updateMode = false; };
|
|
358
|
+
|
|
319
359
|
// ── Confirm Dialog ─────────────────────────────────────────────
|
|
320
360
|
function showConfirm(title, message, onConfirm, opts) {
|
|
321
361
|
opts = opts || {};
|
|
322
362
|
confirmMode = true;
|
|
323
363
|
confirmCallback = onConfirm;
|
|
324
|
-
|
|
364
|
+
const box = document.getElementById('confirm-box');
|
|
325
365
|
box.innerHTML =
|
|
326
366
|
'<div class="confirm-title">' + escHtml(title) + '</div>' +
|
|
327
367
|
'<div class="confirm-message">' + escHtml(message) + '</div>' +
|
|
@@ -333,7 +373,7 @@ function getDashboardJs() {
|
|
|
333
373
|
'</div>';
|
|
334
374
|
document.getElementById('confirm-overlay').className = 'confirm-overlay active';
|
|
335
375
|
document.getElementById('confirm-cancel').onclick = hideConfirm;
|
|
336
|
-
document.getElementById('confirm-ok').onclick =
|
|
376
|
+
document.getElementById('confirm-ok').onclick = () => {
|
|
337
377
|
hideConfirm();
|
|
338
378
|
if (confirmCallback) confirmCallback();
|
|
339
379
|
};
|
|
@@ -347,19 +387,18 @@ function getDashboardJs() {
|
|
|
347
387
|
|
|
348
388
|
// ── Tabs ───────────────────────────────────────────────────────
|
|
349
389
|
function renderTabs() {
|
|
350
|
-
|
|
351
|
-
|
|
390
|
+
const tabBar = document.getElementById('tab-bar');
|
|
391
|
+
const projects = (state && state.projects) || [];
|
|
352
392
|
if (projects.length <= 1) {
|
|
353
393
|
tabBar.className = 'tab-bar';
|
|
354
394
|
return;
|
|
355
395
|
}
|
|
356
396
|
tabBar.className = 'tab-bar visible';
|
|
357
|
-
// Adjust layout height for tab bar
|
|
358
397
|
document.querySelector('.layout').style.height = 'calc(100vh - 49px - 40px)';
|
|
359
|
-
|
|
360
|
-
for (
|
|
361
|
-
|
|
362
|
-
|
|
398
|
+
let html = '';
|
|
399
|
+
for (let i = 0; i < projects.length; i++) {
|
|
400
|
+
const p = projects[i];
|
|
401
|
+
const isActive = p.id === activeTabId;
|
|
363
402
|
html += '<div class="tab' + (isActive ? ' active' : '') + '" data-project-id="' + escHtml(p.id) + '">';
|
|
364
403
|
html += '<span class="tab-dot"></span>';
|
|
365
404
|
html += escHtml(p.name);
|
|
@@ -369,17 +408,13 @@ function getDashboardJs() {
|
|
|
369
408
|
tabBar.innerHTML = html;
|
|
370
409
|
}
|
|
371
410
|
|
|
372
|
-
/**
|
|
373
|
-
* Fetch a project's state from the server and merge it into the
|
|
374
|
-
* current client-side state for rendering.
|
|
375
|
-
*/
|
|
376
411
|
function fetchAndApplyProjectState(projectId) {
|
|
377
|
-
|
|
412
|
+
const xhr = new XMLHttpRequest();
|
|
378
413
|
xhr.open('GET', '/api/projects/' + projectId + '/state');
|
|
379
|
-
xhr.onload =
|
|
414
|
+
xhr.onload = () => {
|
|
380
415
|
if (xhr.status === 200 && activeTabId === projectId) {
|
|
381
416
|
try {
|
|
382
|
-
|
|
417
|
+
const pState = JSON.parse(xhr.responseText);
|
|
383
418
|
state.branches = pState.branches || [];
|
|
384
419
|
state.currentBranch = pState.currentBranch;
|
|
385
420
|
state.activityLog = pState.activityLog || [];
|
|
@@ -410,12 +445,10 @@ function getDashboardJs() {
|
|
|
410
445
|
renderTabs();
|
|
411
446
|
fetchAndApplyProjectState(projectId);
|
|
412
447
|
|
|
413
|
-
// For non-local tabs the SSE stream won't push per-project updates,
|
|
414
|
-
// so poll the server periodically to keep the view fresh.
|
|
415
448
|
clearInterval(remoteTabPollTimer);
|
|
416
449
|
remoteTabPollTimer = null;
|
|
417
450
|
if (state && projectId !== state.activeProjectId) {
|
|
418
|
-
remoteTabPollTimer = setInterval(
|
|
451
|
+
remoteTabPollTimer = setInterval(() => {
|
|
419
452
|
fetchAndApplyProjectState(projectId);
|
|
420
453
|
}, 2000);
|
|
421
454
|
}
|
|
@@ -427,7 +460,7 @@ ${pureFnBlock}
|
|
|
427
460
|
// ── Get Display Branches (wrapper) ─────────────────────────────
|
|
428
461
|
// The pure getDisplayBranches is inlined above as a var assignment.
|
|
429
462
|
// Wrap it to pass closure state as args, keeping the same call-site API.
|
|
430
|
-
|
|
463
|
+
const _pureGetDisplayBranches = getDisplayBranches;
|
|
431
464
|
getDisplayBranches = function() {
|
|
432
465
|
if (!state || !state.branches) return [];
|
|
433
466
|
return _pureGetDisplayBranches(state.branches, {
|
|
@@ -442,20 +475,20 @@ ${pureFnBlock}
|
|
|
442
475
|
if (!state) return;
|
|
443
476
|
|
|
444
477
|
// Header — hide project name pill when tabs are showing it
|
|
445
|
-
|
|
446
|
-
|
|
478
|
+
const projectEl = document.getElementById('project-name');
|
|
479
|
+
const hasTabs = state.projects && state.projects.length > 1;
|
|
447
480
|
if (hasTabs) {
|
|
448
481
|
projectEl.style.display = 'none';
|
|
449
482
|
} else {
|
|
450
483
|
projectEl.style.display = '';
|
|
451
484
|
projectEl.textContent = state.projectName || '-';
|
|
452
485
|
}
|
|
453
|
-
|
|
486
|
+
const versionEl = document.getElementById('version');
|
|
454
487
|
if (state.version) versionEl.textContent = 'v' + state.version;
|
|
455
488
|
|
|
456
489
|
// Status badge
|
|
457
490
|
if (connected) {
|
|
458
|
-
|
|
491
|
+
const badge = document.getElementById('status-badge');
|
|
459
492
|
if (state.isOffline) {
|
|
460
493
|
badge.className = 'badge badge-offline';
|
|
461
494
|
badge.textContent = 'offline';
|
|
@@ -484,9 +517,9 @@ ${pureFnBlock}
|
|
|
484
517
|
}
|
|
485
518
|
|
|
486
519
|
function renderBranches() {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
520
|
+
const container = document.getElementById('branch-list');
|
|
521
|
+
const branches = getDisplayBranches();
|
|
522
|
+
const countEl = document.getElementById('branch-count');
|
|
490
523
|
countEl.textContent = branches.length;
|
|
491
524
|
|
|
492
525
|
if (selectedIndex >= branches.length) {
|
|
@@ -501,23 +534,23 @@ ${pureFnBlock}
|
|
|
501
534
|
return;
|
|
502
535
|
}
|
|
503
536
|
|
|
504
|
-
|
|
505
|
-
for (
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
537
|
+
let html = '';
|
|
538
|
+
for (let i = 0; i < branches.length; i++) {
|
|
539
|
+
const b = branches[i];
|
|
540
|
+
const isSelected = i === selectedIndex;
|
|
541
|
+
const isCurrent = b.name === state.currentBranch;
|
|
509
542
|
|
|
510
543
|
// Sparkline
|
|
511
|
-
|
|
544
|
+
const sparkStr = state.sparklineCache ? state.sparklineCache[b.name] : null;
|
|
512
545
|
|
|
513
546
|
// PR status
|
|
514
|
-
|
|
515
|
-
|
|
547
|
+
const prStatus = state.branchPrStatusMap ? state.branchPrStatusMap[b.name] : null;
|
|
548
|
+
const isMerged = prStatus && prStatus.state === 'MERGED';
|
|
516
549
|
|
|
517
550
|
// Ahead/behind
|
|
518
|
-
|
|
551
|
+
const ab = state.aheadBehindCache ? state.aheadBehindCache[b.name] : null;
|
|
519
552
|
|
|
520
|
-
|
|
553
|
+
let itemClasses = 'branch-item';
|
|
521
554
|
if (isSelected) itemClasses += ' selected';
|
|
522
555
|
if (isCurrent) itemClasses += ' current';
|
|
523
556
|
if (isMerged) itemClasses += ' merged';
|
|
@@ -530,8 +563,8 @@ ${pureFnBlock}
|
|
|
530
563
|
html += '<div class="branch-info">';
|
|
531
564
|
html += '<div class="branch-name-row">';
|
|
532
565
|
// Branch name - clickable link to GitHub/GitLab
|
|
533
|
-
|
|
534
|
-
|
|
566
|
+
const branchUrl = getBranchUrl(b.name);
|
|
567
|
+
const isPinned = pinnedBranches.indexOf(b.name) !== -1;
|
|
535
568
|
html += '<span class="branch-name">';
|
|
536
569
|
if (branchUrl) {
|
|
537
570
|
html += '<a href="' + escHtml(branchUrl) + '" target="_blank" rel="noopener" title="Open on web" onclick="event.stopPropagation()">' + escHtml(b.name) + '</a>';
|
|
@@ -545,7 +578,7 @@ ${pureFnBlock}
|
|
|
545
578
|
|
|
546
579
|
html += '<div class="branch-meta">';
|
|
547
580
|
// Commit hash - clickable link
|
|
548
|
-
|
|
581
|
+
const commitUrl = getCommitUrl(b.commit);
|
|
549
582
|
html += '<span class="branch-commit">';
|
|
550
583
|
if (commitUrl) {
|
|
551
584
|
html += '<a href="' + escHtml(commitUrl) + '" target="_blank" rel="noopener" title="View commit" onclick="event.stopPropagation()">' + escHtml(b.commit || '') + '</a>';
|
|
@@ -563,15 +596,15 @@ ${pureFnBlock}
|
|
|
563
596
|
|
|
564
597
|
html += '<div class="branch-right">';
|
|
565
598
|
// Badges
|
|
566
|
-
|
|
599
|
+
let badges = '';
|
|
567
600
|
if (isCurrent) badges += '<span class="branch-current-badge">HEAD</span>';
|
|
568
601
|
if (isPinned) badges += '<span class="branch-new-badge" style="color:var(--orange);background:rgba(219,109,40,0.15)">pinned</span>';
|
|
569
602
|
if (b.isNew) badges += '<span class="branch-new-badge">new</span>';
|
|
570
603
|
if (b.isDeleted) badges += '<span class="branch-deleted-badge">deleted</span>';
|
|
571
604
|
if (b.justUpdated) badges += '<span class="branch-updated-badge">updated</span>';
|
|
572
605
|
if (prStatus) {
|
|
573
|
-
|
|
574
|
-
|
|
606
|
+
const prClass = prStatus.state === 'OPEN' ? 'pr-open' : prStatus.state === 'MERGED' ? 'pr-merged' : 'pr-closed';
|
|
607
|
+
const prUrl = getPrUrl(prStatus.number);
|
|
575
608
|
badges += '<span class="pr-badge ' + prClass + '">';
|
|
576
609
|
if (prUrl) badges += '<a href="' + escHtml(prUrl) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()">';
|
|
577
610
|
badges += (prStatus.state === 'MERGED' ? 'merged' : 'PR #' + prStatus.number);
|
|
@@ -603,25 +636,25 @@ ${pureFnBlock}
|
|
|
603
636
|
container.innerHTML = html;
|
|
604
637
|
|
|
605
638
|
// Scroll selected into view
|
|
606
|
-
|
|
639
|
+
const selected = container.querySelector('.branch-item.selected');
|
|
607
640
|
if (selected) {
|
|
608
641
|
selected.scrollIntoView({ block: 'nearest' });
|
|
609
642
|
}
|
|
610
643
|
}
|
|
611
644
|
|
|
612
645
|
function renderActivityLog() {
|
|
613
|
-
|
|
614
|
-
|
|
646
|
+
const container = document.getElementById('activity-log');
|
|
647
|
+
const log = (state && state.activityLog) || [];
|
|
615
648
|
if (log.length === 0) {
|
|
616
649
|
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📋</div>No activity yet</div>';
|
|
617
650
|
return;
|
|
618
651
|
}
|
|
619
|
-
|
|
620
|
-
for (
|
|
621
|
-
|
|
622
|
-
|
|
652
|
+
let html = '';
|
|
653
|
+
for (let i = 0; i < log.length; i++) {
|
|
654
|
+
const entry = log[i];
|
|
655
|
+
const t = '';
|
|
623
656
|
if (entry.timestamp) {
|
|
624
|
-
|
|
657
|
+
const d = new Date(entry.timestamp);
|
|
625
658
|
t = isNaN(d.getTime()) ? '' : d.toLocaleTimeString();
|
|
626
659
|
}
|
|
627
660
|
html += '<div class="log-entry">';
|
|
@@ -638,31 +671,28 @@ ${pureFnBlock}
|
|
|
638
671
|
logViewerMode = true;
|
|
639
672
|
logViewerTab = 'server';
|
|
640
673
|
renderLogViewer();
|
|
641
|
-
|
|
674
|
+
logViewerModal.show();
|
|
642
675
|
}
|
|
643
676
|
|
|
644
|
-
function hideLogViewer() {
|
|
645
|
-
logViewerMode = false;
|
|
646
|
-
document.getElementById('log-viewer-overlay').className = 'modal-overlay';
|
|
647
|
-
}
|
|
677
|
+
function hideLogViewer() { logViewerModal.hide(); }
|
|
648
678
|
|
|
649
679
|
function renderLogViewer() {
|
|
650
680
|
if (!state) return;
|
|
651
|
-
|
|
681
|
+
const container = document.getElementById('log-viewer-content');
|
|
652
682
|
// Update tab active state
|
|
653
|
-
|
|
654
|
-
for (
|
|
683
|
+
const tabs = document.querySelectorAll('.log-viewer-tab');
|
|
684
|
+
for (let t = 0; t < tabs.length; t++) {
|
|
655
685
|
tabs[t].className = 'log-viewer-tab' + (tabs[t].getAttribute('data-tab') === logViewerTab ? ' active' : '');
|
|
656
686
|
}
|
|
657
687
|
|
|
658
|
-
|
|
688
|
+
let html = '';
|
|
659
689
|
if (logViewerTab === 'server') {
|
|
660
|
-
|
|
690
|
+
const logs = state.serverLogBuffer || [];
|
|
661
691
|
if (logs.length === 0) {
|
|
662
692
|
html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No server logs</div>';
|
|
663
693
|
} else {
|
|
664
|
-
for (
|
|
665
|
-
|
|
694
|
+
for (let i = 0; i < logs.length; i++) {
|
|
695
|
+
const log = logs[i];
|
|
666
696
|
html += '<div class="log-line' + (log.isError ? ' error' : '') + '">';
|
|
667
697
|
html += '<span class="log-ts">' + escHtml(log.timestamp || '') + '</span>';
|
|
668
698
|
html += escHtml(log.line || '');
|
|
@@ -670,13 +700,13 @@ ${pureFnBlock}
|
|
|
670
700
|
}
|
|
671
701
|
}
|
|
672
702
|
} else {
|
|
673
|
-
|
|
703
|
+
const alog = (state.activityLog || []);
|
|
674
704
|
if (alog.length === 0) {
|
|
675
705
|
html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No activity</div>';
|
|
676
706
|
} else {
|
|
677
|
-
for (
|
|
678
|
-
|
|
679
|
-
|
|
707
|
+
for (let j = 0; j < alog.length; j++) {
|
|
708
|
+
const entry = alog[j];
|
|
709
|
+
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
680
710
|
html += '<div class="log-line">';
|
|
681
711
|
html += '<span class="log-ts">' + ts + '</span>';
|
|
682
712
|
html += escHtml(entry.message || '');
|
|
@@ -688,33 +718,29 @@ ${pureFnBlock}
|
|
|
688
718
|
container.scrollTop = container.scrollHeight;
|
|
689
719
|
}
|
|
690
720
|
|
|
691
|
-
document.getElementById('log-viewer-tabs').addEventListener('click',
|
|
692
|
-
|
|
721
|
+
document.getElementById('log-viewer-tabs').addEventListener('click', (e) => {
|
|
722
|
+
const tab = e.target.closest('.log-viewer-tab');
|
|
693
723
|
if (!tab) return;
|
|
694
724
|
logViewerTab = tab.getAttribute('data-tab');
|
|
695
725
|
renderLogViewer();
|
|
696
726
|
});
|
|
697
727
|
|
|
698
|
-
document.getElementById('log-viewer-close').addEventListener('click', hideLogViewer);
|
|
699
|
-
document.getElementById('log-viewer-overlay').addEventListener('click', function(e) {
|
|
700
|
-
if (e.target === this) hideLogViewer();
|
|
701
|
-
});
|
|
702
|
-
|
|
703
728
|
// ── Branch Action Modal ────────────────────────────────────────
|
|
704
729
|
function showBranchActions() {
|
|
705
|
-
|
|
730
|
+
const branches = getDisplayBranches();
|
|
706
731
|
if (!branches.length || selectedIndex >= branches.length) return;
|
|
707
|
-
|
|
732
|
+
const branch = branches[selectedIndex];
|
|
708
733
|
branchActionMode = true;
|
|
734
|
+
branchActionModal.show();
|
|
709
735
|
document.getElementById('branch-action-title').textContent = 'Actions: ' + branch.name;
|
|
710
736
|
|
|
711
|
-
|
|
712
|
-
|
|
737
|
+
const prStatus = (state.branchPrStatusMap || {})[branch.name];
|
|
738
|
+
const isCurrent = branch.name === state.currentBranch;
|
|
713
739
|
|
|
714
|
-
|
|
740
|
+
const actions = [];
|
|
715
741
|
|
|
716
742
|
// Open on web (GitHub/GitLab) — direct link if we have repo URL
|
|
717
|
-
|
|
743
|
+
const brUrl = getBranchUrl(branch.name);
|
|
718
744
|
if (brUrl) {
|
|
719
745
|
actions.push({ icon: '\\u{1f310}', label: 'Open branch on web', key: 'openLink', data: { url: brUrl } });
|
|
720
746
|
} else {
|
|
@@ -722,7 +748,7 @@ ${pureFnBlock}
|
|
|
722
748
|
}
|
|
723
749
|
|
|
724
750
|
// PR actions
|
|
725
|
-
|
|
751
|
+
const prUrl = prStatus ? getPrUrl(prStatus.number) : null;
|
|
726
752
|
if (prStatus && prUrl) {
|
|
727
753
|
actions.push({ icon: '\\u{1f517}', label: 'View PR #' + prStatus.number, key: 'openLink', data: { url: prUrl } });
|
|
728
754
|
} else if (prStatus && prStatus.url) {
|
|
@@ -739,7 +765,7 @@ ${pureFnBlock}
|
|
|
739
765
|
}
|
|
740
766
|
|
|
741
767
|
// Pin/Unpin
|
|
742
|
-
|
|
768
|
+
const isPinnedBranch = pinnedBranches.indexOf(branch.name) !== -1;
|
|
743
769
|
actions.push({ icon: isPinnedBranch ? '\\u{1f4cc}' : '\\u{1f4cc}', label: isPinnedBranch ? 'Unpin branch' : 'Pin branch to top', key: 'pin', data: { branch: branch.name } });
|
|
744
770
|
|
|
745
771
|
// Switch to branch
|
|
@@ -755,33 +781,24 @@ ${pureFnBlock}
|
|
|
755
781
|
// Fetch
|
|
756
782
|
actions.push({ icon: '\\u{1f504}', label: 'Fetch all remotes', key: 'fetch', data: {} });
|
|
757
783
|
|
|
758
|
-
|
|
759
|
-
for (
|
|
760
|
-
|
|
784
|
+
let html = '';
|
|
785
|
+
for (let i = 0; i < actions.length; i++) {
|
|
786
|
+
const a = actions[i];
|
|
761
787
|
html += '<button class="action-item" data-action-key="' + escHtml(a.key) + '" data-action-data=\\'' + escHtml(JSON.stringify(a.data)) + '\\'>';
|
|
762
788
|
html += '<span class="action-icon">' + a.icon + '</span>';
|
|
763
789
|
html += '<span class="action-label">' + escHtml(a.label) + '</span>';
|
|
764
790
|
html += '</button>';
|
|
765
791
|
}
|
|
766
792
|
document.getElementById('branch-action-list').innerHTML = html;
|
|
767
|
-
document.getElementById('branch-action-overlay').className = 'modal-overlay active';
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function hideBranchActions() {
|
|
771
|
-
branchActionMode = false;
|
|
772
|
-
document.getElementById('branch-action-overlay').className = 'modal-overlay';
|
|
773
793
|
}
|
|
774
794
|
|
|
775
|
-
|
|
776
|
-
document.getElementById('branch-action-overlay').addEventListener('click', function(e) {
|
|
777
|
-
if (e.target === this) hideBranchActions();
|
|
778
|
-
});
|
|
795
|
+
function hideBranchActions() { branchActionModal.hide(); }
|
|
779
796
|
|
|
780
|
-
document.getElementById('branch-action-list').addEventListener('click',
|
|
781
|
-
|
|
797
|
+
document.getElementById('branch-action-list').addEventListener('click', (e) => {
|
|
798
|
+
const btn = e.target.closest('.action-item');
|
|
782
799
|
if (!btn) return;
|
|
783
|
-
|
|
784
|
-
|
|
800
|
+
const key = btn.getAttribute('data-action-key');
|
|
801
|
+
const data = {};
|
|
785
802
|
try { data = JSON.parse(btn.getAttribute('data-action-data') || '{}'); } catch (err) { /* ignore */ }
|
|
786
803
|
|
|
787
804
|
hideBranchActions();
|
|
@@ -793,7 +810,7 @@ ${pureFnBlock}
|
|
|
793
810
|
} else if (key === 'copy') {
|
|
794
811
|
copyToClipboard(data.text, null);
|
|
795
812
|
} else if (key === 'pin') {
|
|
796
|
-
|
|
813
|
+
const pIdx = pinnedBranches.indexOf(data.branch);
|
|
797
814
|
if (pIdx === -1) {
|
|
798
815
|
pinnedBranches.push(data.branch);
|
|
799
816
|
showToast('Pinned: ' + data.branch, 'success');
|
|
@@ -820,8 +837,8 @@ ${pureFnBlock}
|
|
|
820
837
|
function showInfo() {
|
|
821
838
|
if (!state) return;
|
|
822
839
|
infoMode = true;
|
|
823
|
-
|
|
824
|
-
|
|
840
|
+
const grid = document.getElementById('info-grid');
|
|
841
|
+
const rows = [
|
|
825
842
|
['Project', state.projectName || '-'],
|
|
826
843
|
['Version', 'v' + (state.version || '-')],
|
|
827
844
|
['Server Mode', state.serverMode || 'none'],
|
|
@@ -833,71 +850,54 @@ ${pureFnBlock}
|
|
|
833
850
|
['Network', state.isOffline ? 'Offline' : 'Online'],
|
|
834
851
|
['Branches', String((state.branches || []).length)],
|
|
835
852
|
];
|
|
836
|
-
|
|
837
|
-
for (
|
|
853
|
+
let html = '';
|
|
854
|
+
for (let i = 0; i < rows.length; i++) {
|
|
838
855
|
html += '<span class="info-label">' + escHtml(rows[i][0]) + '</span>';
|
|
839
856
|
html += '<span class="info-value">' + escHtml(rows[i][1]) + '</span>';
|
|
840
857
|
}
|
|
841
858
|
grid.innerHTML = html;
|
|
842
|
-
|
|
859
|
+
infoModal.show();
|
|
843
860
|
}
|
|
844
861
|
|
|
845
|
-
function hideInfo() {
|
|
846
|
-
infoMode = false;
|
|
847
|
-
document.getElementById('info-overlay').className = 'modal-overlay';
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
document.getElementById('info-close').addEventListener('click', hideInfo);
|
|
851
|
-
document.getElementById('info-overlay').addEventListener('click', function(e) {
|
|
852
|
-
if (e.target === this) hideInfo();
|
|
853
|
-
});
|
|
862
|
+
function hideInfo() { infoModal.hide(); }
|
|
854
863
|
|
|
855
864
|
// ── Stash Management ───────────────────────────────────────────
|
|
856
865
|
function showStashDialog(pendingBranch) {
|
|
857
866
|
stashMode = true;
|
|
858
867
|
pendingStashBranch = pendingBranch || null;
|
|
859
|
-
|
|
868
|
+
const msg = pendingBranch
|
|
860
869
|
? 'You have uncommitted changes. Stash them before switching to <strong>' + escHtml(pendingBranch) + '</strong>?'
|
|
861
870
|
: 'Stash all uncommitted changes in the working directory?';
|
|
862
|
-
|
|
871
|
+
const html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:16px;">' + msg + '</div>';
|
|
863
872
|
html += '<div class="confirm-actions">';
|
|
864
873
|
html += '<button class="confirm-btn" id="stash-cancel">Cancel</button>';
|
|
865
874
|
html += '<button class="confirm-btn primary" id="stash-confirm">Stash & Continue</button>';
|
|
866
875
|
html += '</div>';
|
|
867
876
|
document.getElementById('stash-content').innerHTML = html;
|
|
868
|
-
|
|
877
|
+
stashModal.show();
|
|
869
878
|
document.getElementById('stash-cancel').onclick = hideStash;
|
|
870
|
-
document.getElementById('stash-confirm').onclick =
|
|
879
|
+
document.getElementById('stash-confirm').onclick = () => {
|
|
871
880
|
sendAction('stash', { pendingBranch: pendingStashBranch });
|
|
872
881
|
showToast('Stashing changes...', 'info');
|
|
873
882
|
hideStash();
|
|
874
883
|
};
|
|
875
884
|
}
|
|
876
885
|
|
|
877
|
-
function hideStash() {
|
|
878
|
-
stashMode = false;
|
|
879
|
-
pendingStashBranch = null;
|
|
880
|
-
document.getElementById('stash-overlay').className = 'modal-overlay';
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
document.getElementById('stash-close').addEventListener('click', hideStash);
|
|
884
|
-
document.getElementById('stash-overlay').addEventListener('click', function(e) {
|
|
885
|
-
if (e.target === this) hideStash();
|
|
886
|
-
});
|
|
886
|
+
function hideStash() { stashModal.hide(); }
|
|
887
887
|
|
|
888
888
|
// ── Branch Cleanup ─────────────────────────────────────────────
|
|
889
889
|
function showCleanup() {
|
|
890
890
|
cleanupMode = true;
|
|
891
|
-
|
|
891
|
+
const html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:12px;">Scanning for branches with deleted remotes...</div>';
|
|
892
892
|
document.getElementById('cleanup-content').innerHTML = html;
|
|
893
|
-
|
|
893
|
+
cleanupModal.show();
|
|
894
894
|
|
|
895
895
|
// Ask the server to find gone branches (we inspect state.branches for gone tracking hints)
|
|
896
896
|
// For now, look at branches that have no remote
|
|
897
|
-
|
|
897
|
+
const goneBranches = [];
|
|
898
898
|
if (state && state.branches) {
|
|
899
|
-
for (
|
|
900
|
-
|
|
899
|
+
for (let i = 0; i < state.branches.length; i++) {
|
|
900
|
+
const b = state.branches[i];
|
|
901
901
|
if (b.isLocal && !b.hasRemote && b.name !== state.currentBranch) {
|
|
902
902
|
goneBranches.push(b.name);
|
|
903
903
|
}
|
|
@@ -914,7 +914,7 @@ ${pureFnBlock}
|
|
|
914
914
|
|
|
915
915
|
html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:8px;">Found ' + goneBranches.length + ' branch(es) with no remote tracking:</div>';
|
|
916
916
|
html += '<div class="cleanup-branch-list">';
|
|
917
|
-
for (
|
|
917
|
+
for (let j = 0; j < goneBranches.length; j++) {
|
|
918
918
|
html += '<div class="cleanup-branch-item"><span class="cleanup-branch-icon">✖</span>' + escHtml(goneBranches[j]) + '</div>';
|
|
919
919
|
}
|
|
920
920
|
html += '</div>';
|
|
@@ -926,16 +926,16 @@ ${pureFnBlock}
|
|
|
926
926
|
|
|
927
927
|
document.getElementById('cleanup-content').innerHTML = html;
|
|
928
928
|
document.getElementById('cleanup-cancel').onclick = hideCleanup;
|
|
929
|
-
document.getElementById('cleanup-safe').onclick =
|
|
929
|
+
document.getElementById('cleanup-safe').onclick = () => {
|
|
930
930
|
sendAction('deleteBranches', { branches: goneBranches, force: false });
|
|
931
931
|
showToast('Deleting ' + goneBranches.length + ' branches (safe)...', 'info');
|
|
932
932
|
hideCleanup();
|
|
933
933
|
};
|
|
934
|
-
document.getElementById('cleanup-force').onclick =
|
|
934
|
+
document.getElementById('cleanup-force').onclick = () => {
|
|
935
935
|
showConfirm(
|
|
936
936
|
'Force Delete',
|
|
937
937
|
'Force delete ' + goneBranches.length + ' branch(es)? This may delete unmerged work.',
|
|
938
|
-
|
|
938
|
+
() => {
|
|
939
939
|
sendAction('deleteBranches', { branches: goneBranches, force: true });
|
|
940
940
|
showToast('Force deleting ' + goneBranches.length + ' branches...', 'warning');
|
|
941
941
|
hideCleanup();
|
|
@@ -945,21 +945,13 @@ ${pureFnBlock}
|
|
|
945
945
|
};
|
|
946
946
|
}
|
|
947
947
|
|
|
948
|
-
function hideCleanup() {
|
|
949
|
-
cleanupMode = false;
|
|
950
|
-
document.getElementById('cleanup-overlay').className = 'modal-overlay';
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
document.getElementById('cleanup-close').addEventListener('click', hideCleanup);
|
|
954
|
-
document.getElementById('cleanup-overlay').addEventListener('click', function(e) {
|
|
955
|
-
if (e.target === this) hideCleanup();
|
|
956
|
-
});
|
|
948
|
+
function hideCleanup() { cleanupModal.hide(); }
|
|
957
949
|
|
|
958
950
|
// ── Update Notification ────────────────────────────────────────
|
|
959
951
|
function showUpdateModal() {
|
|
960
952
|
if (!state || !state.updateAvailable) return;
|
|
961
953
|
updateMode = true;
|
|
962
|
-
|
|
954
|
+
const html = '<div class="update-versions">';
|
|
963
955
|
html += '<span class="old-version">v' + escHtml(state.version || '?') + '</span>';
|
|
964
956
|
html += '<span class="arrow">→</span>';
|
|
965
957
|
html += '<span class="new-version">v' + escHtml(state.updateAvailable) + '</span>';
|
|
@@ -974,10 +966,10 @@ ${pureFnBlock}
|
|
|
974
966
|
html += '</div>';
|
|
975
967
|
}
|
|
976
968
|
document.getElementById('update-content').innerHTML = html;
|
|
977
|
-
|
|
969
|
+
updateModal.show();
|
|
978
970
|
if (!state.updateInProgress) {
|
|
979
971
|
document.getElementById('update-dismiss').onclick = hideUpdate;
|
|
980
|
-
document.getElementById('update-install').onclick =
|
|
972
|
+
document.getElementById('update-install').onclick = () => {
|
|
981
973
|
sendAction('checkUpdate', { install: true });
|
|
982
974
|
showToast('Installing update...', 'info');
|
|
983
975
|
hideUpdate();
|
|
@@ -985,26 +977,18 @@ ${pureFnBlock}
|
|
|
985
977
|
}
|
|
986
978
|
}
|
|
987
979
|
|
|
988
|
-
function hideUpdate() {
|
|
989
|
-
updateMode = false;
|
|
990
|
-
document.getElementById('update-overlay').className = 'modal-overlay';
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
document.getElementById('update-close').addEventListener('click', hideUpdate);
|
|
994
|
-
document.getElementById('update-overlay').addEventListener('click', function(e) {
|
|
995
|
-
if (e.target === this) hideUpdate();
|
|
996
|
-
});
|
|
980
|
+
function hideUpdate() { updateModal.hide(); }
|
|
997
981
|
|
|
998
982
|
// ── Session Stats ──────────────────────────────────────────────
|
|
999
983
|
function renderSessionStats() {
|
|
1000
984
|
if (!state || !state.sessionStats) return;
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
985
|
+
const s = state.sessionStats;
|
|
986
|
+
const bar = document.getElementById('stats-bar');
|
|
987
|
+
const activeBranches = 0;
|
|
988
|
+
const staleBranches = 0;
|
|
1005
989
|
if (state.branches) {
|
|
1006
|
-
for (
|
|
1007
|
-
|
|
990
|
+
for (let i = 0; i < state.branches.length; i++) {
|
|
991
|
+
const b = state.branches[i];
|
|
1008
992
|
// Consider stale if no updates and not current
|
|
1009
993
|
if (b.justUpdated || b.name === state.currentBranch) {
|
|
1010
994
|
activeBranches++;
|
|
@@ -1013,7 +997,7 @@ ${pureFnBlock}
|
|
|
1013
997
|
}
|
|
1014
998
|
}
|
|
1015
999
|
}
|
|
1016
|
-
|
|
1000
|
+
let html = '';
|
|
1017
1001
|
html += '<span class="stat-item"><span class="stat-label">Session:</span> <span class="stat-value">' + escHtml(s.sessionDuration || '0m') + '</span></span>';
|
|
1018
1002
|
html += '<span class="stat-item"><span class="stat-label">Lines:</span> <span class="stat-value">+' + (s.linesAdded || 0) + '/-' + (s.linesDeleted || 0) + '</span></span>';
|
|
1019
1003
|
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>';
|
|
@@ -1026,56 +1010,50 @@ ${pureFnBlock}
|
|
|
1026
1010
|
|
|
1027
1011
|
// ── Error Toast with Stash Hint ────────────────────────────────
|
|
1028
1012
|
function showErrorToastWithHint(message, hint) {
|
|
1029
|
-
|
|
1030
|
-
|
|
1013
|
+
const container = document.getElementById('toast-container');
|
|
1014
|
+
const toast = document.createElement('div');
|
|
1031
1015
|
toast.className = 'toast error';
|
|
1032
|
-
|
|
1016
|
+
const html = '<span class="toast-icon">\\u2717</span>' + escHtml(message);
|
|
1033
1017
|
if (hint) {
|
|
1034
1018
|
html += '<span class="toast-action" data-hint="' + escHtml(hint) + '">' + escHtml(hint) + '</span>';
|
|
1035
1019
|
}
|
|
1036
1020
|
toast.innerHTML = html;
|
|
1037
1021
|
container.appendChild(toast);
|
|
1038
|
-
requestAnimationFrame(
|
|
1039
|
-
requestAnimationFrame(
|
|
1022
|
+
requestAnimationFrame(() => {
|
|
1023
|
+
requestAnimationFrame(() => toast.classList.add('visible'));
|
|
1040
1024
|
});
|
|
1041
1025
|
|
|
1042
1026
|
// Handle hint click
|
|
1043
|
-
|
|
1027
|
+
const hintEl = toast.querySelector('.toast-action');
|
|
1044
1028
|
if (hintEl) {
|
|
1045
|
-
hintEl.addEventListener('click',
|
|
1046
|
-
|
|
1029
|
+
hintEl.addEventListener('click', (e) => {
|
|
1030
|
+
const h = e.currentTarget.getAttribute('data-hint');
|
|
1047
1031
|
if (h === 'Press S to stash') {
|
|
1048
1032
|
showStashDialog(pendingStashBranch);
|
|
1049
1033
|
}
|
|
1050
1034
|
toast.classList.remove('visible');
|
|
1051
|
-
setTimeout(
|
|
1035
|
+
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
1052
1036
|
});
|
|
1053
1037
|
}
|
|
1054
1038
|
|
|
1055
|
-
setTimeout(
|
|
1039
|
+
setTimeout(() => {
|
|
1056
1040
|
toast.classList.remove('visible');
|
|
1057
|
-
setTimeout(
|
|
1041
|
+
setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
1058
1042
|
}, 6000);
|
|
1059
1043
|
}
|
|
1060
1044
|
|
|
1061
|
-
// ── Any modal open check ───────────────────────────────────────
|
|
1062
|
-
function anyModalOpen() {
|
|
1063
|
-
return logViewerMode || branchActionMode || infoMode || cleanupMode || updateMode || stashMode || confirmMode;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
1045
|
// ── Keyboard ───────────────────────────────────────────────────
|
|
1067
|
-
document.addEventListener('keydown',
|
|
1046
|
+
document.addEventListener('keydown', (e) => {
|
|
1068
1047
|
// Ignore when typing in input fields (other than search)
|
|
1069
1048
|
if (e.target.tagName === 'INPUT' && e.target.id !== 'search-input') return;
|
|
1070
1049
|
if (e.target.tagName === 'BUTTON') return;
|
|
1071
1050
|
|
|
1072
|
-
// Any modal — Escape to close
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
if (stashMode && e.key === 'Escape') { e.preventDefault(); hideStash(); return; }
|
|
1051
|
+
// Any modal — Escape to close the topmost one
|
|
1052
|
+
if (_openModals.length > 0 && e.key === 'Escape') {
|
|
1053
|
+
e.preventDefault();
|
|
1054
|
+
_openModals[_openModals.length - 1].hide();
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1079
1057
|
|
|
1080
1058
|
// Log viewer tab switching
|
|
1081
1059
|
if (logViewerMode) {
|
|
@@ -1088,14 +1066,14 @@ ${pureFnBlock}
|
|
|
1088
1066
|
}
|
|
1089
1067
|
|
|
1090
1068
|
// Block other keys while modals are open
|
|
1091
|
-
if (
|
|
1069
|
+
if (_openModals.length > 0) return;
|
|
1092
1070
|
|
|
1093
1071
|
// Confirm dialog mode — Escape to cancel, Enter to confirm
|
|
1094
1072
|
if (confirmMode) {
|
|
1095
1073
|
if (e.key === 'Escape') { e.preventDefault(); hideConfirm(); }
|
|
1096
1074
|
if (e.key === 'Enter') {
|
|
1097
1075
|
e.preventDefault();
|
|
1098
|
-
|
|
1076
|
+
const cb = confirmCallback;
|
|
1099
1077
|
hideConfirm();
|
|
1100
1078
|
if (cb) cb();
|
|
1101
1079
|
}
|
|
@@ -1134,9 +1112,9 @@ ${pureFnBlock}
|
|
|
1134
1112
|
}
|
|
1135
1113
|
|
|
1136
1114
|
// Tab switching with number keys (1-9)
|
|
1137
|
-
|
|
1115
|
+
const projects = (state && state.projects) || [];
|
|
1138
1116
|
if (projects.length > 1 && e.key >= '1' && e.key <= '9') {
|
|
1139
|
-
|
|
1117
|
+
const tabIdx = parseInt(e.key, 10) - 1;
|
|
1140
1118
|
if (tabIdx < projects.length) {
|
|
1141
1119
|
e.preventDefault();
|
|
1142
1120
|
switchTab(projects[tabIdx].id);
|
|
@@ -1147,8 +1125,8 @@ ${pureFnBlock}
|
|
|
1147
1125
|
// Tab cycling with Tab key
|
|
1148
1126
|
if (e.key === 'Tab' && projects.length > 1) {
|
|
1149
1127
|
e.preventDefault();
|
|
1150
|
-
|
|
1151
|
-
|
|
1128
|
+
const curIdx = projects.findIndex((p) => p.id === activeTabId);
|
|
1129
|
+
const nextIdx = e.shiftKey
|
|
1152
1130
|
? (curIdx - 1 + projects.length) % projects.length
|
|
1153
1131
|
: (curIdx + 1) % projects.length;
|
|
1154
1132
|
switchTab(projects[nextIdx].id);
|
|
@@ -1169,9 +1147,9 @@ ${pureFnBlock}
|
|
|
1169
1147
|
break;
|
|
1170
1148
|
case 'Enter':
|
|
1171
1149
|
e.preventDefault();
|
|
1172
|
-
|
|
1150
|
+
const branches = getDisplayBranches();
|
|
1173
1151
|
if (branches.length > 0 && selectedIndex < branches.length) {
|
|
1174
|
-
|
|
1152
|
+
const b = branches[selectedIndex];
|
|
1175
1153
|
if (b.isDeleted) {
|
|
1176
1154
|
showToast('Cannot switch to a deleted branch', 'error');
|
|
1177
1155
|
} else if (b.name === state.currentBranch) {
|
|
@@ -1188,7 +1166,7 @@ ${pureFnBlock}
|
|
|
1188
1166
|
searchQuery = '';
|
|
1189
1167
|
selectedIndex = 0;
|
|
1190
1168
|
document.getElementById('search-bar').className = 'search-bar active';
|
|
1191
|
-
|
|
1169
|
+
const input = document.getElementById('search-input');
|
|
1192
1170
|
input.value = '';
|
|
1193
1171
|
input.focus();
|
|
1194
1172
|
break;
|
|
@@ -1215,7 +1193,7 @@ ${pureFnBlock}
|
|
|
1215
1193
|
showConfirm(
|
|
1216
1194
|
'Restart Server',
|
|
1217
1195
|
'Restart the dev server process?',
|
|
1218
|
-
|
|
1196
|
+
() => {
|
|
1219
1197
|
sendAction('restartServer');
|
|
1220
1198
|
showToast('Restarting server...', 'info');
|
|
1221
1199
|
},
|
|
@@ -1235,8 +1213,8 @@ ${pureFnBlock}
|
|
|
1235
1213
|
case 'h':
|
|
1236
1214
|
e.preventDefault();
|
|
1237
1215
|
if (state && state.switchHistory && state.switchHistory.length > 0) {
|
|
1238
|
-
|
|
1239
|
-
|
|
1216
|
+
const last = state.switchHistory[0];
|
|
1217
|
+
const histMsg = 'Last: ' + last.from + ' \\u2192 ' + last.to;
|
|
1240
1218
|
if (state.switchHistory.length > 1) histMsg += ' (+' + (state.switchHistory.length - 1) + ' more)';
|
|
1241
1219
|
showToast(histMsg, 'info');
|
|
1242
1220
|
} else {
|
|
@@ -1280,15 +1258,15 @@ ${pureFnBlock}
|
|
|
1280
1258
|
});
|
|
1281
1259
|
|
|
1282
1260
|
// Search input handler
|
|
1283
|
-
document.getElementById('search-input').addEventListener('input',
|
|
1261
|
+
document.getElementById('search-input').addEventListener('input', (e) => {
|
|
1284
1262
|
searchQuery = e.target.value;
|
|
1285
1263
|
selectedIndex = 0;
|
|
1286
1264
|
renderBranches();
|
|
1287
1265
|
});
|
|
1288
1266
|
|
|
1289
1267
|
function moveSelection(delta) {
|
|
1290
|
-
|
|
1291
|
-
|
|
1268
|
+
const branches = getDisplayBranches();
|
|
1269
|
+
const newIndex = selectedIndex + delta;
|
|
1292
1270
|
if (newIndex >= 0 && newIndex < branches.length) {
|
|
1293
1271
|
selectedIndex = newIndex;
|
|
1294
1272
|
renderBranches();
|
|
@@ -1296,18 +1274,18 @@ ${pureFnBlock}
|
|
|
1296
1274
|
}
|
|
1297
1275
|
|
|
1298
1276
|
// ── Click Handlers ─────────────────────────────────────────────
|
|
1299
|
-
document.getElementById('branch-list').addEventListener('click',
|
|
1300
|
-
|
|
1277
|
+
document.getElementById('branch-list').addEventListener('click', (e) => {
|
|
1278
|
+
const item = e.target.closest('.branch-item');
|
|
1301
1279
|
if (!item) return;
|
|
1302
|
-
|
|
1280
|
+
const idx = parseInt(item.getAttribute('data-index'), 10);
|
|
1303
1281
|
if (isNaN(idx)) return;
|
|
1304
1282
|
selectedIndex = idx;
|
|
1305
1283
|
renderBranches();
|
|
1306
1284
|
|
|
1307
1285
|
// Double-click to switch with confirmation
|
|
1308
1286
|
if (e.detail === 2) {
|
|
1309
|
-
|
|
1310
|
-
|
|
1287
|
+
const branches = getDisplayBranches();
|
|
1288
|
+
const br = branches[idx];
|
|
1311
1289
|
if (br && !br.isDeleted && br.name !== state.currentBranch) {
|
|
1312
1290
|
sendAction('switchBranch', { branch: br.name });
|
|
1313
1291
|
showToast('Switching to ' + br.name + '...', 'info');
|
|
@@ -1315,25 +1293,25 @@ ${pureFnBlock}
|
|
|
1315
1293
|
}
|
|
1316
1294
|
});
|
|
1317
1295
|
|
|
1318
|
-
document.getElementById('confirm-overlay').addEventListener('click',
|
|
1296
|
+
document.getElementById('confirm-overlay').addEventListener('click', (e) => {
|
|
1319
1297
|
if (e.target === this) hideConfirm();
|
|
1320
1298
|
});
|
|
1321
1299
|
|
|
1322
1300
|
// Tab clicks
|
|
1323
|
-
document.getElementById('tab-bar').addEventListener('click',
|
|
1324
|
-
|
|
1301
|
+
document.getElementById('tab-bar').addEventListener('click', (e) => {
|
|
1302
|
+
const tab = e.target.closest('.tab');
|
|
1325
1303
|
if (!tab) return;
|
|
1326
|
-
|
|
1304
|
+
const projectId = tab.getAttribute('data-project-id');
|
|
1327
1305
|
if (projectId) switchTab(projectId);
|
|
1328
1306
|
});
|
|
1329
1307
|
|
|
1330
1308
|
// ── Preferences Bar ─────────────────────────────────────────────
|
|
1331
1309
|
function renderPrefsBar() {
|
|
1332
1310
|
// Insert prefs controls into footer if not already there
|
|
1333
|
-
|
|
1334
|
-
|
|
1311
|
+
const footer = document.getElementById('footer');
|
|
1312
|
+
const existing = document.getElementById('prefs-bar');
|
|
1335
1313
|
if (!existing) {
|
|
1336
|
-
|
|
1314
|
+
const div = document.createElement('span');
|
|
1337
1315
|
div.id = 'prefs-bar';
|
|
1338
1316
|
div.style.cssText = 'display:flex;gap:6px;align-items:center;margin-left:auto;';
|
|
1339
1317
|
div.innerHTML =
|
|
@@ -1347,23 +1325,23 @@ ${pureFnBlock}
|
|
|
1347
1325
|
}
|
|
1348
1326
|
|
|
1349
1327
|
// Prefs bar click handler
|
|
1350
|
-
document.getElementById('footer').addEventListener('click',
|
|
1351
|
-
|
|
1328
|
+
document.getElementById('footer').addEventListener('click', (e) => {
|
|
1329
|
+
const sortBtn = e.target.closest('[data-sort]');
|
|
1352
1330
|
if (sortBtn) {
|
|
1353
1331
|
sortOrder = sortBtn.getAttribute('data-sort');
|
|
1354
1332
|
savePrefs({ sortOrder: sortOrder });
|
|
1355
|
-
|
|
1356
|
-
for (
|
|
1333
|
+
const sortBtns = document.querySelectorAll('[data-sort]');
|
|
1334
|
+
for (let i = 0; i < sortBtns.length; i++) {
|
|
1357
1335
|
sortBtns[i].className = 'pref-btn' + (sortBtns[i].getAttribute('data-sort') === sortOrder ? ' active' : '');
|
|
1358
1336
|
}
|
|
1359
1337
|
renderBranches();
|
|
1360
1338
|
return;
|
|
1361
1339
|
}
|
|
1362
1340
|
if (e.target.id === 'pin-selected-btn') {
|
|
1363
|
-
|
|
1341
|
+
const branches = getDisplayBranches();
|
|
1364
1342
|
if (branches.length > 0 && selectedIndex < branches.length) {
|
|
1365
|
-
|
|
1366
|
-
|
|
1343
|
+
const bn = branches[selectedIndex].name;
|
|
1344
|
+
const idx = pinnedBranches.indexOf(bn);
|
|
1367
1345
|
if (idx === -1) {
|
|
1368
1346
|
pinnedBranches.push(bn);
|
|
1369
1347
|
showToast('Pinned: ' + bn, 'success');
|
|
@@ -1379,7 +1357,7 @@ ${pureFnBlock}
|
|
|
1379
1357
|
if (e.target.id === 'toggle-sidebar-btn') {
|
|
1380
1358
|
sidebarCollapsed = !sidebarCollapsed;
|
|
1381
1359
|
savePrefs({ sidebarCollapsed: sidebarCollapsed });
|
|
1382
|
-
|
|
1360
|
+
const layout = document.querySelector('.layout');
|
|
1383
1361
|
if (sidebarCollapsed) {
|
|
1384
1362
|
layout.classList.add('sidebar-collapsed');
|
|
1385
1363
|
} else {
|
|
@@ -1391,20 +1369,20 @@ ${pureFnBlock}
|
|
|
1391
1369
|
});
|
|
1392
1370
|
|
|
1393
1371
|
// ── Sidebar Toggle (header) ───────────────────────────────────
|
|
1394
|
-
document.getElementById('sidebar-toggle').addEventListener('click',
|
|
1372
|
+
document.getElementById('sidebar-toggle').addEventListener('click', () => {
|
|
1395
1373
|
sidebarCollapsed = !sidebarCollapsed;
|
|
1396
1374
|
savePrefs({ sidebarCollapsed: sidebarCollapsed });
|
|
1397
|
-
|
|
1375
|
+
const layout = document.querySelector('.layout');
|
|
1398
1376
|
layout.classList.toggle('sidebar-collapsed', sidebarCollapsed);
|
|
1399
|
-
|
|
1377
|
+
const btn = document.getElementById('toggle-sidebar-btn');
|
|
1400
1378
|
if (btn) btn.className = 'pref-btn' + (sidebarCollapsed ? ' active' : '');
|
|
1401
1379
|
});
|
|
1402
1380
|
|
|
1403
1381
|
// ── Copy button delegation ────────────────────────────────────
|
|
1404
|
-
document.addEventListener('click',
|
|
1405
|
-
|
|
1382
|
+
document.addEventListener('click', (e) => {
|
|
1383
|
+
const copyBtn = e.target.closest('.copy-btn');
|
|
1406
1384
|
if (!copyBtn) return;
|
|
1407
|
-
|
|
1385
|
+
const text = copyBtn.getAttribute('data-copy');
|
|
1408
1386
|
if (text) {
|
|
1409
1387
|
e.preventDefault();
|
|
1410
1388
|
e.stopPropagation();
|