git-watchtower 1.10.1 → 1.10.3
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 +8 -7
- 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 +1483 -0
- package/src/server/web-ui.js +8 -2472
package/src/server/web-ui.js
CHANGED
|
@@ -1,2478 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Embedded HTML/CSS/JS for the Git Watchtower web dashboard.
|
|
3
3
|
* Returns a complete HTML page as a string — no external dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Implementation split into sub-modules for maintainability:
|
|
6
|
+
* - ./web-ui/css.js — dashboard styles
|
|
7
|
+
* - ./web-ui/html.js — body markup & modal templates
|
|
8
|
+
* - ./web-ui/js.js — client-side behaviour
|
|
9
|
+
* - ./web-ui/index.js — assembles the above into a full HTML page
|
|
10
|
+
*
|
|
4
11
|
* @module server/web-ui
|
|
5
12
|
*/
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
* Generate the web dashboard HTML.
|
|
9
|
-
* @param {number} port - The web server port (for SSE connection)
|
|
10
|
-
* @returns {string} Complete HTML document
|
|
11
|
-
*/
|
|
12
|
-
function getWebDashboardHtml(port) {
|
|
13
|
-
return `<!DOCTYPE html>
|
|
14
|
-
<html lang="en">
|
|
15
|
-
<head>
|
|
16
|
-
<meta charset="utf-8">
|
|
17
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
-
<title>Git Watchtower</title>
|
|
19
|
-
<style>
|
|
20
|
-
:root {
|
|
21
|
-
--bg: #0d1117;
|
|
22
|
-
--bg-surface: #161b22;
|
|
23
|
-
--bg-surface-hover: #1c2129;
|
|
24
|
-
--bg-surface-active: #252c35;
|
|
25
|
-
--border: #30363d;
|
|
26
|
-
--border-subtle: #21262d;
|
|
27
|
-
--text: #e6edf3;
|
|
28
|
-
--text-dim: #8b949e;
|
|
29
|
-
--text-muted: #484f58;
|
|
30
|
-
--accent: #58a6ff;
|
|
31
|
-
--accent-dim: #1f6feb;
|
|
32
|
-
--green: #3fb950;
|
|
33
|
-
--green-dim: #238636;
|
|
34
|
-
--red: #f85149;
|
|
35
|
-
--red-dim: #da3633;
|
|
36
|
-
--yellow: #d29922;
|
|
37
|
-
--cyan: #39d2c0;
|
|
38
|
-
--magenta: #bc8cff;
|
|
39
|
-
--orange: #db6d28;
|
|
40
|
-
--sparkline: #58a6ff;
|
|
41
|
-
--header-bg: linear-gradient(135deg, #0550ae 0%, #0969da 100%);
|
|
42
|
-
--radius: 8px;
|
|
43
|
-
--radius-sm: 4px;
|
|
44
|
-
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
|
|
45
|
-
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Consolas, monospace;
|
|
46
|
-
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
|
|
47
|
-
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
|
|
48
|
-
--shadow-lg: 0 8px 24px rgba(0,0,0,0.5);
|
|
49
|
-
--merged-text: #484f58;
|
|
50
|
-
--merged-bg: rgba(72,79,88,0.06);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
54
|
-
|
|
55
|
-
body {
|
|
56
|
-
font-family: var(--font);
|
|
57
|
-
background: var(--bg);
|
|
58
|
-
color: var(--text);
|
|
59
|
-
line-height: 1.5;
|
|
60
|
-
overflow: hidden;
|
|
61
|
-
height: 100vh;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/* ── Header ────────────────────────────────────────────────────── */
|
|
65
|
-
.header {
|
|
66
|
-
background: var(--header-bg);
|
|
67
|
-
padding: 14px 24px;
|
|
68
|
-
display: flex;
|
|
69
|
-
align-items: center;
|
|
70
|
-
justify-content: space-between;
|
|
71
|
-
border-bottom: 1px solid rgba(255,255,255,0.08);
|
|
72
|
-
user-select: none;
|
|
73
|
-
box-shadow: 0 1px 8px rgba(0,0,0,0.3);
|
|
74
|
-
position: relative;
|
|
75
|
-
z-index: 10;
|
|
76
|
-
}
|
|
77
|
-
.header-left {
|
|
78
|
-
display: flex;
|
|
79
|
-
align-items: center;
|
|
80
|
-
gap: 14px;
|
|
81
|
-
}
|
|
82
|
-
.header-title {
|
|
83
|
-
font-size: 15px;
|
|
84
|
-
font-weight: 700;
|
|
85
|
-
color: #fff;
|
|
86
|
-
letter-spacing: -0.2px;
|
|
87
|
-
}
|
|
88
|
-
.header-version {
|
|
89
|
-
font-size: 11px;
|
|
90
|
-
color: rgba(255,255,255,0.4);
|
|
91
|
-
font-weight: 500;
|
|
92
|
-
}
|
|
93
|
-
.header-project {
|
|
94
|
-
font-size: 13px;
|
|
95
|
-
font-weight: 600;
|
|
96
|
-
color: rgba(255,255,255,0.95);
|
|
97
|
-
background: rgba(255,255,255,0.12);
|
|
98
|
-
padding: 3px 12px;
|
|
99
|
-
border-radius: var(--radius-sm);
|
|
100
|
-
backdrop-filter: blur(4px);
|
|
101
|
-
border: 1px solid rgba(255,255,255,0.08);
|
|
102
|
-
}
|
|
103
|
-
.header-right {
|
|
104
|
-
display: flex;
|
|
105
|
-
align-items: center;
|
|
106
|
-
gap: 12px;
|
|
107
|
-
}
|
|
108
|
-
.badge {
|
|
109
|
-
font-size: 10px;
|
|
110
|
-
font-weight: 700;
|
|
111
|
-
padding: 3px 10px;
|
|
112
|
-
border-radius: 10px;
|
|
113
|
-
text-transform: uppercase;
|
|
114
|
-
letter-spacing: 0.8px;
|
|
115
|
-
}
|
|
116
|
-
.badge-online { background: var(--green-dim); color: #fff; box-shadow: 0 0 8px rgba(63,185,80,0.3); }
|
|
117
|
-
.badge-offline { background: var(--red-dim); color: #fff; box-shadow: 0 0 8px rgba(248,81,73,0.3); }
|
|
118
|
-
.badge-fetching { background: var(--yellow); color: #000; }
|
|
119
|
-
|
|
120
|
-
/* ── Layout ────────────────────────────────────────────────────── */
|
|
121
|
-
.layout {
|
|
122
|
-
display: grid;
|
|
123
|
-
grid-template-columns: 1fr 320px;
|
|
124
|
-
grid-template-rows: 1fr auto;
|
|
125
|
-
height: calc(100vh - 49px);
|
|
126
|
-
min-height: 0;
|
|
127
|
-
gap: 0;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/* ── Branch List ───────────────────────────────────────────────── */
|
|
131
|
-
.branch-panel {
|
|
132
|
-
display: flex;
|
|
133
|
-
flex-direction: column;
|
|
134
|
-
overflow: hidden;
|
|
135
|
-
}
|
|
136
|
-
.panel-header {
|
|
137
|
-
padding: 12px 20px;
|
|
138
|
-
font-size: 10px;
|
|
139
|
-
font-weight: 700;
|
|
140
|
-
text-transform: uppercase;
|
|
141
|
-
letter-spacing: 1.2px;
|
|
142
|
-
color: var(--text-muted);
|
|
143
|
-
border-bottom: 1px solid var(--border);
|
|
144
|
-
background: var(--bg-surface);
|
|
145
|
-
display: flex;
|
|
146
|
-
align-items: center;
|
|
147
|
-
justify-content: space-between;
|
|
148
|
-
}
|
|
149
|
-
.branch-count {
|
|
150
|
-
color: var(--text-muted);
|
|
151
|
-
font-weight: 400;
|
|
152
|
-
}
|
|
153
|
-
.branch-list {
|
|
154
|
-
flex: 1;
|
|
155
|
-
overflow-y: auto;
|
|
156
|
-
scrollbar-width: thin;
|
|
157
|
-
scrollbar-color: var(--border) transparent;
|
|
158
|
-
}
|
|
159
|
-
.branch-list::-webkit-scrollbar { width: 6px; }
|
|
160
|
-
.branch-list::-webkit-scrollbar-track { background: transparent; }
|
|
161
|
-
.branch-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
162
|
-
|
|
163
|
-
.branch-item {
|
|
164
|
-
display: grid;
|
|
165
|
-
grid-template-columns: 24px 1fr auto;
|
|
166
|
-
align-items: center;
|
|
167
|
-
padding: 10px 20px;
|
|
168
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
169
|
-
border-left: 2px solid transparent;
|
|
170
|
-
cursor: pointer;
|
|
171
|
-
transition: background 0.15s, opacity 0.15s, border-color 0.15s;
|
|
172
|
-
}
|
|
173
|
-
.branch-item:hover { background: var(--bg-surface-hover); }
|
|
174
|
-
.branch-item.selected { background: var(--bg-surface-active); border-left-color: var(--accent); }
|
|
175
|
-
.branch-item.selected .branch-name { color: var(--accent); }
|
|
176
|
-
.branch-item.current {
|
|
177
|
-
background: rgba(63,185,80,0.06);
|
|
178
|
-
border-left-color: var(--green);
|
|
179
|
-
}
|
|
180
|
-
.branch-item.current:hover { background: rgba(63,185,80,0.10); }
|
|
181
|
-
.branch-item.current.selected { border-left-color: var(--green); background: rgba(63,185,80,0.10); }
|
|
182
|
-
.branch-item.current .branch-name { color: var(--green); font-weight: 600; }
|
|
183
|
-
.branch-item.merged { opacity: 0.45; }
|
|
184
|
-
.branch-item.merged:hover { opacity: 0.7; }
|
|
185
|
-
.branch-item.merged .branch-name { color: var(--text-muted); }
|
|
186
|
-
|
|
187
|
-
.branch-cursor {
|
|
188
|
-
font-size: 9px;
|
|
189
|
-
color: var(--accent);
|
|
190
|
-
opacity: 0;
|
|
191
|
-
transition: opacity 0.15s;
|
|
192
|
-
filter: drop-shadow(0 0 3px var(--accent));
|
|
193
|
-
}
|
|
194
|
-
.branch-item.selected .branch-cursor { opacity: 1; }
|
|
195
|
-
.branch-current-icon {
|
|
196
|
-
font-size: 10px;
|
|
197
|
-
color: var(--green);
|
|
198
|
-
filter: drop-shadow(0 0 4px rgba(63,185,80,0.6));
|
|
199
|
-
}
|
|
200
|
-
.branch-item.current .branch-cursor { display: none; }
|
|
201
|
-
.branch-item.current.selected .branch-current-icon { display: none; }
|
|
202
|
-
.branch-item.current.selected .branch-cursor { display: inline; opacity: 1; }
|
|
203
|
-
|
|
204
|
-
.branch-info {
|
|
205
|
-
min-width: 0;
|
|
206
|
-
display: flex;
|
|
207
|
-
flex-direction: column;
|
|
208
|
-
gap: 2px;
|
|
209
|
-
}
|
|
210
|
-
.branch-name-row {
|
|
211
|
-
display: flex;
|
|
212
|
-
align-items: center;
|
|
213
|
-
gap: 8px;
|
|
214
|
-
}
|
|
215
|
-
.branch-name {
|
|
216
|
-
font-family: var(--font-mono);
|
|
217
|
-
font-size: 13px;
|
|
218
|
-
font-weight: 500;
|
|
219
|
-
white-space: nowrap;
|
|
220
|
-
overflow: hidden;
|
|
221
|
-
text-overflow: ellipsis;
|
|
222
|
-
}
|
|
223
|
-
.branch-current-badge {
|
|
224
|
-
font-size: 10px;
|
|
225
|
-
color: var(--green);
|
|
226
|
-
background: rgba(63,185,80,0.15);
|
|
227
|
-
padding: 0 6px;
|
|
228
|
-
border-radius: var(--radius-sm);
|
|
229
|
-
font-weight: 600;
|
|
230
|
-
flex-shrink: 0;
|
|
231
|
-
}
|
|
232
|
-
.branch-new-badge {
|
|
233
|
-
font-size: 10px;
|
|
234
|
-
color: var(--yellow);
|
|
235
|
-
background: rgba(210,153,34,0.15);
|
|
236
|
-
padding: 0 6px;
|
|
237
|
-
border-radius: var(--radius-sm);
|
|
238
|
-
font-weight: 600;
|
|
239
|
-
flex-shrink: 0;
|
|
240
|
-
}
|
|
241
|
-
.branch-deleted-badge {
|
|
242
|
-
font-size: 10px;
|
|
243
|
-
color: var(--red);
|
|
244
|
-
background: rgba(248,81,73,0.15);
|
|
245
|
-
padding: 0 6px;
|
|
246
|
-
border-radius: var(--radius-sm);
|
|
247
|
-
font-weight: 600;
|
|
248
|
-
flex-shrink: 0;
|
|
249
|
-
}
|
|
250
|
-
.branch-updated-badge {
|
|
251
|
-
font-size: 10px;
|
|
252
|
-
color: var(--cyan);
|
|
253
|
-
background: rgba(57,210,192,0.15);
|
|
254
|
-
padding: 0 6px;
|
|
255
|
-
border-radius: var(--radius-sm);
|
|
256
|
-
font-weight: 600;
|
|
257
|
-
flex-shrink: 0;
|
|
258
|
-
}
|
|
259
|
-
.branch-meta {
|
|
260
|
-
font-size: 11px;
|
|
261
|
-
color: var(--text-dim);
|
|
262
|
-
display: flex;
|
|
263
|
-
align-items: center;
|
|
264
|
-
gap: 10px;
|
|
265
|
-
}
|
|
266
|
-
.branch-commit {
|
|
267
|
-
font-family: var(--font-mono);
|
|
268
|
-
color: var(--text-muted);
|
|
269
|
-
font-size: 11px;
|
|
270
|
-
}
|
|
271
|
-
.branch-subject {
|
|
272
|
-
white-space: nowrap;
|
|
273
|
-
overflow: hidden;
|
|
274
|
-
text-overflow: ellipsis;
|
|
275
|
-
max-width: 300px;
|
|
276
|
-
}
|
|
277
|
-
.branch-right {
|
|
278
|
-
display: flex;
|
|
279
|
-
flex-direction: column;
|
|
280
|
-
align-items: flex-end;
|
|
281
|
-
gap: 4px;
|
|
282
|
-
padding-left: 16px;
|
|
283
|
-
flex-shrink: 0;
|
|
284
|
-
min-width: 60px;
|
|
285
|
-
}
|
|
286
|
-
.branch-badges {
|
|
287
|
-
display: flex;
|
|
288
|
-
gap: 4px;
|
|
289
|
-
justify-content: flex-end;
|
|
290
|
-
flex-wrap: wrap;
|
|
291
|
-
}
|
|
292
|
-
.branch-time {
|
|
293
|
-
font-size: 12px;
|
|
294
|
-
font-family: var(--font-mono);
|
|
295
|
-
color: var(--text-dim);
|
|
296
|
-
white-space: nowrap;
|
|
297
|
-
font-weight: 500;
|
|
298
|
-
letter-spacing: -0.3px;
|
|
299
|
-
}
|
|
300
|
-
.sparkline-bar {
|
|
301
|
-
display: flex;
|
|
302
|
-
align-items: flex-end;
|
|
303
|
-
gap: 1px;
|
|
304
|
-
height: 16px;
|
|
305
|
-
}
|
|
306
|
-
.spark-bar {
|
|
307
|
-
width: 4px;
|
|
308
|
-
border-radius: 1px;
|
|
309
|
-
background: var(--sparkline);
|
|
310
|
-
transition: height 0.3s;
|
|
311
|
-
min-height: 1px;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
.branch-diff {
|
|
315
|
-
display: flex;
|
|
316
|
-
gap: 6px;
|
|
317
|
-
font-size: 11px;
|
|
318
|
-
font-family: var(--font-mono);
|
|
319
|
-
justify-content: flex-end;
|
|
320
|
-
text-align: right;
|
|
321
|
-
white-space: nowrap;
|
|
322
|
-
}
|
|
323
|
-
.diff-added { color: var(--green); }
|
|
324
|
-
.diff-deleted { color: var(--red); }
|
|
325
|
-
.diff-label { color: var(--text-muted); font-size: 10px; }
|
|
326
|
-
|
|
327
|
-
.pr-badge {
|
|
328
|
-
font-size: 10px;
|
|
329
|
-
padding: 1px 6px;
|
|
330
|
-
border-radius: var(--radius-sm);
|
|
331
|
-
font-weight: 600;
|
|
332
|
-
flex-shrink: 0;
|
|
333
|
-
}
|
|
334
|
-
.pr-open { color: var(--green); background: rgba(63,185,80,0.15); }
|
|
335
|
-
.pr-merged { color: var(--magenta); background: rgba(188,140,255,0.15); }
|
|
336
|
-
.pr-closed { color: var(--red); background: rgba(248,81,73,0.15); }
|
|
337
|
-
|
|
338
|
-
/* ── Side Panel ────────────────────────────────────────────────── */
|
|
339
|
-
.side-panel {
|
|
340
|
-
display: flex;
|
|
341
|
-
flex-direction: column;
|
|
342
|
-
overflow: hidden;
|
|
343
|
-
background: var(--bg-surface);
|
|
344
|
-
border-left: 1px solid var(--border);
|
|
345
|
-
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/* ── Activity Log ──────────────────────────────────────────────── */
|
|
349
|
-
.activity-log {
|
|
350
|
-
flex: 1;
|
|
351
|
-
overflow-y: auto;
|
|
352
|
-
scrollbar-width: thin;
|
|
353
|
-
scrollbar-color: var(--border) transparent;
|
|
354
|
-
}
|
|
355
|
-
.log-entry {
|
|
356
|
-
padding: 6px 16px;
|
|
357
|
-
font-size: 12px;
|
|
358
|
-
border-bottom: 1px solid var(--border-subtle);
|
|
359
|
-
display: flex;
|
|
360
|
-
gap: 8px;
|
|
361
|
-
align-items: flex-start;
|
|
362
|
-
}
|
|
363
|
-
.log-dot {
|
|
364
|
-
width: 6px;
|
|
365
|
-
height: 6px;
|
|
366
|
-
border-radius: 50%;
|
|
367
|
-
margin-top: 6px;
|
|
368
|
-
flex-shrink: 0;
|
|
369
|
-
}
|
|
370
|
-
.log-dot.info { background: var(--accent); }
|
|
371
|
-
.log-dot.success { background: var(--green); }
|
|
372
|
-
.log-dot.warning { background: var(--yellow); }
|
|
373
|
-
.log-dot.error { background: var(--red); }
|
|
374
|
-
.log-dot.update { background: var(--cyan); }
|
|
375
|
-
.log-text {
|
|
376
|
-
color: var(--text-dim);
|
|
377
|
-
line-height: 1.4;
|
|
378
|
-
word-break: break-word;
|
|
379
|
-
}
|
|
380
|
-
.log-time {
|
|
381
|
-
color: var(--text-muted);
|
|
382
|
-
font-size: 10px;
|
|
383
|
-
font-family: var(--font-mono);
|
|
384
|
-
flex-shrink: 0;
|
|
385
|
-
margin-left: auto;
|
|
386
|
-
padding-left: 8px;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/* ── Search Overlay ────────────────────────────────────────────── */
|
|
390
|
-
.search-bar {
|
|
391
|
-
display: none;
|
|
392
|
-
padding: 8px 16px;
|
|
393
|
-
background: var(--bg-surface);
|
|
394
|
-
border-bottom: 1px solid var(--border);
|
|
395
|
-
}
|
|
396
|
-
.search-bar.active { display: flex; }
|
|
397
|
-
.search-input {
|
|
398
|
-
width: 100%;
|
|
399
|
-
background: var(--bg);
|
|
400
|
-
border: 1px solid var(--border);
|
|
401
|
-
color: var(--text);
|
|
402
|
-
font-family: var(--font-mono);
|
|
403
|
-
font-size: 13px;
|
|
404
|
-
padding: 6px 12px;
|
|
405
|
-
border-radius: var(--radius-sm);
|
|
406
|
-
outline: none;
|
|
407
|
-
}
|
|
408
|
-
.search-input:focus { border-color: var(--accent); }
|
|
409
|
-
|
|
410
|
-
/* ── Footer ────────────────────────────────────────────────────── */
|
|
411
|
-
.footer {
|
|
412
|
-
grid-column: 1 / -1;
|
|
413
|
-
padding: 8px 20px;
|
|
414
|
-
background: var(--bg-surface);
|
|
415
|
-
border-top: 1px solid var(--border);
|
|
416
|
-
font-size: 11px;
|
|
417
|
-
color: var(--text-muted);
|
|
418
|
-
display: flex;
|
|
419
|
-
gap: 14px;
|
|
420
|
-
flex-wrap: wrap;
|
|
421
|
-
user-select: none;
|
|
422
|
-
}
|
|
423
|
-
.footer kbd {
|
|
424
|
-
display: inline-block;
|
|
425
|
-
background: var(--bg);
|
|
426
|
-
border: 1px solid var(--border);
|
|
427
|
-
border-radius: 3px;
|
|
428
|
-
padding: 0 5px;
|
|
429
|
-
font-family: var(--font-mono);
|
|
430
|
-
font-size: 10px;
|
|
431
|
-
color: var(--text-dim);
|
|
432
|
-
line-height: 18px;
|
|
433
|
-
margin-right: 2px;
|
|
434
|
-
box-shadow: 0 1px 0 var(--border);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/* ── Flash Message ─────────────────────────────────────────────── */
|
|
438
|
-
.flash {
|
|
439
|
-
position: fixed;
|
|
440
|
-
top: 60px;
|
|
441
|
-
left: 50%;
|
|
442
|
-
transform: translateX(-50%);
|
|
443
|
-
padding: 8px 20px;
|
|
444
|
-
border-radius: var(--radius);
|
|
445
|
-
font-size: 13px;
|
|
446
|
-
font-weight: 500;
|
|
447
|
-
z-index: 100;
|
|
448
|
-
opacity: 0;
|
|
449
|
-
transition: opacity 0.3s;
|
|
450
|
-
pointer-events: none;
|
|
451
|
-
}
|
|
452
|
-
.flash.visible { opacity: 1; }
|
|
453
|
-
.flash.info { background: var(--accent-dim); color: #fff; }
|
|
454
|
-
.flash.success { background: var(--green-dim); color: #fff; }
|
|
455
|
-
.flash.warning { background: var(--yellow); color: #000; }
|
|
456
|
-
.flash.error { background: var(--red-dim); color: #fff; }
|
|
457
|
-
.flash.update { background: var(--cyan); color: #000; }
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
/* ── Connection indicator ──────────────────────────────────────── */
|
|
461
|
-
.connection-dot {
|
|
462
|
-
width: 8px;
|
|
463
|
-
height: 8px;
|
|
464
|
-
border-radius: 50%;
|
|
465
|
-
display: inline-block;
|
|
466
|
-
}
|
|
467
|
-
.connection-dot.connected { background: var(--green); box-shadow: 0 0 6px rgba(63,185,80,0.5), 0 0 2px var(--green); }
|
|
468
|
-
.connection-dot.disconnected { background: var(--red); box-shadow: 0 0 6px rgba(248,81,73,0.5), 0 0 2px var(--red); animation: pulse-dot 2s ease-in-out infinite; }
|
|
469
|
-
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
470
|
-
|
|
471
|
-
/* ── Empty state ───────────────────────────────────────────────── */
|
|
472
|
-
.empty-state {
|
|
473
|
-
display: flex;
|
|
474
|
-
flex-direction: column;
|
|
475
|
-
align-items: center;
|
|
476
|
-
justify-content: center;
|
|
477
|
-
height: 200px;
|
|
478
|
-
color: var(--text-muted);
|
|
479
|
-
font-size: 13px;
|
|
480
|
-
}
|
|
481
|
-
.empty-state-icon { font-size: 32px; margin-bottom: 12px; opacity: 0.5; }
|
|
482
|
-
|
|
483
|
-
/* ── Tab Bar ───────────────────────────────────────────────────── */
|
|
484
|
-
.tab-bar {
|
|
485
|
-
display: none;
|
|
486
|
-
background: var(--bg-surface);
|
|
487
|
-
border-bottom: 1px solid var(--border);
|
|
488
|
-
padding: 0 16px;
|
|
489
|
-
gap: 2px;
|
|
490
|
-
overflow-x: auto;
|
|
491
|
-
scrollbar-width: none;
|
|
492
|
-
flex-shrink: 0;
|
|
493
|
-
align-items: stretch;
|
|
494
|
-
box-shadow: inset 0 -1px 0 var(--border);
|
|
495
|
-
}
|
|
496
|
-
.tab-bar::-webkit-scrollbar { display: none; }
|
|
497
|
-
.tab-bar.visible { display: flex; }
|
|
498
|
-
.tab {
|
|
499
|
-
padding: 10px 20px 9px;
|
|
500
|
-
font-size: 13px;
|
|
501
|
-
font-weight: 500;
|
|
502
|
-
color: var(--text-muted);
|
|
503
|
-
cursor: pointer;
|
|
504
|
-
border-bottom: 2px solid transparent;
|
|
505
|
-
white-space: nowrap;
|
|
506
|
-
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
|
507
|
-
user-select: none;
|
|
508
|
-
position: relative;
|
|
509
|
-
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
510
|
-
}
|
|
511
|
-
.tab:hover { color: var(--text-dim); background: rgba(255,255,255,0.03); }
|
|
512
|
-
.tab.active {
|
|
513
|
-
color: var(--text);
|
|
514
|
-
font-weight: 600;
|
|
515
|
-
border-bottom-color: var(--accent);
|
|
516
|
-
background: var(--bg);
|
|
517
|
-
}
|
|
518
|
-
.tab .tab-dot {
|
|
519
|
-
display: inline-block;
|
|
520
|
-
width: 7px;
|
|
521
|
-
height: 7px;
|
|
522
|
-
border-radius: 50%;
|
|
523
|
-
margin-right: 8px;
|
|
524
|
-
background: var(--green);
|
|
525
|
-
box-shadow: 0 0 4px rgba(63,185,80,0.4);
|
|
526
|
-
}
|
|
527
|
-
.tab .tab-number {
|
|
528
|
-
font-size: 10px;
|
|
529
|
-
color: var(--text-muted);
|
|
530
|
-
margin-left: 6px;
|
|
531
|
-
font-family: var(--font-mono);
|
|
532
|
-
opacity: 0.6;
|
|
533
|
-
}
|
|
534
|
-
.tab.active .tab-number { color: var(--accent); opacity: 0.8; }
|
|
535
|
-
|
|
536
|
-
/* ── Confirm Dialog ────────────────────────────────────────────── */
|
|
537
|
-
.confirm-overlay {
|
|
538
|
-
display: none;
|
|
539
|
-
position: fixed;
|
|
540
|
-
inset: 0;
|
|
541
|
-
background: rgba(0,0,0,0.6);
|
|
542
|
-
z-index: 200;
|
|
543
|
-
justify-content: center;
|
|
544
|
-
align-items: center;
|
|
545
|
-
}
|
|
546
|
-
.confirm-overlay.active { display: flex; }
|
|
547
|
-
.confirm-box {
|
|
548
|
-
background: var(--bg-surface);
|
|
549
|
-
border: 1px solid var(--border);
|
|
550
|
-
border-radius: var(--radius);
|
|
551
|
-
padding: 28px;
|
|
552
|
-
min-width: 360px;
|
|
553
|
-
max-width: 480px;
|
|
554
|
-
box-shadow: var(--shadow-lg);
|
|
555
|
-
}
|
|
556
|
-
.confirm-title {
|
|
557
|
-
font-size: 15px;
|
|
558
|
-
font-weight: 600;
|
|
559
|
-
color: var(--text);
|
|
560
|
-
margin-bottom: 8px;
|
|
561
|
-
}
|
|
562
|
-
.confirm-message {
|
|
563
|
-
font-size: 13px;
|
|
564
|
-
color: var(--text-dim);
|
|
565
|
-
margin-bottom: 20px;
|
|
566
|
-
line-height: 1.5;
|
|
567
|
-
}
|
|
568
|
-
.confirm-actions {
|
|
569
|
-
display: flex;
|
|
570
|
-
gap: 8px;
|
|
571
|
-
justify-content: flex-end;
|
|
572
|
-
}
|
|
573
|
-
.confirm-btn {
|
|
574
|
-
padding: 6px 16px;
|
|
575
|
-
border-radius: var(--radius-sm);
|
|
576
|
-
border: 1px solid var(--border);
|
|
577
|
-
font-size: 13px;
|
|
578
|
-
font-weight: 500;
|
|
579
|
-
cursor: pointer;
|
|
580
|
-
background: var(--bg);
|
|
581
|
-
color: var(--text);
|
|
582
|
-
transition: background 0.15s;
|
|
583
|
-
}
|
|
584
|
-
.confirm-btn:hover { background: var(--bg-surface-hover); }
|
|
585
|
-
.confirm-btn.primary {
|
|
586
|
-
background: var(--accent-dim);
|
|
587
|
-
border-color: var(--accent-dim);
|
|
588
|
-
color: #fff;
|
|
589
|
-
}
|
|
590
|
-
.confirm-btn.primary:hover { background: var(--accent); }
|
|
591
|
-
.confirm-btn.danger {
|
|
592
|
-
background: var(--red-dim);
|
|
593
|
-
border-color: var(--red-dim);
|
|
594
|
-
color: #fff;
|
|
595
|
-
}
|
|
596
|
-
.confirm-btn.danger:hover { background: var(--red); }
|
|
597
|
-
|
|
598
|
-
/* ── Toast Notifications ───────────────────────────────────────── */
|
|
599
|
-
.toast-container {
|
|
600
|
-
position: fixed;
|
|
601
|
-
bottom: 60px;
|
|
602
|
-
right: 20px;
|
|
603
|
-
z-index: 150;
|
|
604
|
-
display: flex;
|
|
605
|
-
flex-direction: column-reverse;
|
|
606
|
-
gap: 8px;
|
|
607
|
-
pointer-events: none;
|
|
608
|
-
}
|
|
609
|
-
.toast {
|
|
610
|
-
padding: 10px 16px;
|
|
611
|
-
border-radius: var(--radius);
|
|
612
|
-
font-size: 13px;
|
|
613
|
-
font-weight: 500;
|
|
614
|
-
pointer-events: auto;
|
|
615
|
-
opacity: 0;
|
|
616
|
-
transform: translateX(20px);
|
|
617
|
-
transition: opacity 0.3s, transform 0.3s;
|
|
618
|
-
max-width: 360px;
|
|
619
|
-
display: flex;
|
|
620
|
-
align-items: center;
|
|
621
|
-
gap: 8px;
|
|
622
|
-
border: 1px solid;
|
|
623
|
-
}
|
|
624
|
-
.toast.visible { opacity: 1; transform: translateX(0); }
|
|
625
|
-
.toast.success { background: rgba(35,134,54,0.9); border-color: var(--green); color: #fff; }
|
|
626
|
-
.toast.error { background: rgba(218,54,51,0.9); border-color: var(--red); color: #fff; }
|
|
627
|
-
.toast.info { background: rgba(31,111,235,0.9); border-color: var(--accent); color: #fff; }
|
|
628
|
-
.toast.warning { background: rgba(210,153,34,0.9); border-color: var(--yellow); color: #000; }
|
|
629
|
-
.toast-icon { font-size: 14px; flex-shrink: 0; }
|
|
630
|
-
.toast-action {
|
|
631
|
-
cursor: pointer;
|
|
632
|
-
text-decoration: underline;
|
|
633
|
-
font-weight: 600;
|
|
634
|
-
margin-left: 4px;
|
|
635
|
-
opacity: 0.9;
|
|
636
|
-
}
|
|
637
|
-
.toast-action:hover { opacity: 1; }
|
|
638
|
-
|
|
639
|
-
/* ── Modal Overlay (shared) ──────────────────────────────────── */
|
|
640
|
-
.modal-overlay {
|
|
641
|
-
display: none;
|
|
642
|
-
position: fixed;
|
|
643
|
-
inset: 0;
|
|
644
|
-
background: rgba(0,0,0,0.6);
|
|
645
|
-
z-index: 200;
|
|
646
|
-
justify-content: center;
|
|
647
|
-
align-items: center;
|
|
648
|
-
}
|
|
649
|
-
.modal-overlay.active { display: flex; }
|
|
650
|
-
.modal-box {
|
|
651
|
-
background: var(--bg-surface);
|
|
652
|
-
border: 1px solid var(--border);
|
|
653
|
-
border-radius: var(--radius);
|
|
654
|
-
padding: 24px;
|
|
655
|
-
min-width: 400px;
|
|
656
|
-
max-width: 600px;
|
|
657
|
-
max-height: 80vh;
|
|
658
|
-
overflow-y: auto;
|
|
659
|
-
box-shadow: var(--shadow-lg);
|
|
660
|
-
}
|
|
661
|
-
.modal-title {
|
|
662
|
-
font-size: 15px;
|
|
663
|
-
font-weight: 600;
|
|
664
|
-
color: var(--text);
|
|
665
|
-
margin-bottom: 16px;
|
|
666
|
-
display: flex;
|
|
667
|
-
align-items: center;
|
|
668
|
-
gap: 8px;
|
|
669
|
-
}
|
|
670
|
-
.modal-close {
|
|
671
|
-
margin-left: auto;
|
|
672
|
-
background: none;
|
|
673
|
-
border: none;
|
|
674
|
-
color: var(--text-muted);
|
|
675
|
-
cursor: pointer;
|
|
676
|
-
font-size: 16px;
|
|
677
|
-
padding: 4px 8px;
|
|
678
|
-
}
|
|
679
|
-
.modal-close:hover { color: var(--text); }
|
|
680
|
-
|
|
681
|
-
/* ── Log Viewer ──────────────────────────────────────────────── */
|
|
682
|
-
.log-viewer-tabs {
|
|
683
|
-
display: flex;
|
|
684
|
-
gap: 2px;
|
|
685
|
-
margin-bottom: 12px;
|
|
686
|
-
border-bottom: 1px solid var(--border);
|
|
687
|
-
}
|
|
688
|
-
.log-viewer-tab {
|
|
689
|
-
padding: 6px 14px;
|
|
690
|
-
font-size: 12px;
|
|
691
|
-
font-weight: 500;
|
|
692
|
-
color: var(--text-muted);
|
|
693
|
-
cursor: pointer;
|
|
694
|
-
border-bottom: 2px solid transparent;
|
|
695
|
-
background: none;
|
|
696
|
-
border-top: none;
|
|
697
|
-
border-left: none;
|
|
698
|
-
border-right: none;
|
|
699
|
-
}
|
|
700
|
-
.log-viewer-tab:hover { color: var(--text-dim); }
|
|
701
|
-
.log-viewer-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
702
|
-
.log-viewer-content {
|
|
703
|
-
font-family: var(--font-mono);
|
|
704
|
-
font-size: 12px;
|
|
705
|
-
line-height: 1.6;
|
|
706
|
-
max-height: 400px;
|
|
707
|
-
overflow-y: auto;
|
|
708
|
-
background: var(--bg);
|
|
709
|
-
border: 1px solid var(--border);
|
|
710
|
-
border-radius: var(--radius-sm);
|
|
711
|
-
padding: 12px;
|
|
712
|
-
scrollbar-width: thin;
|
|
713
|
-
scrollbar-color: var(--border) transparent;
|
|
714
|
-
}
|
|
715
|
-
.log-line { padding: 1px 0; white-space: pre-wrap; word-break: break-all; }
|
|
716
|
-
.log-line.error { color: var(--red); }
|
|
717
|
-
.log-line .log-ts { color: var(--text-muted); margin-right: 8px; }
|
|
718
|
-
|
|
719
|
-
/* ── Branch Action Modal ─────────────────────────────────────── */
|
|
720
|
-
.action-list {
|
|
721
|
-
display: flex;
|
|
722
|
-
flex-direction: column;
|
|
723
|
-
gap: 4px;
|
|
724
|
-
}
|
|
725
|
-
.action-item {
|
|
726
|
-
display: flex;
|
|
727
|
-
align-items: center;
|
|
728
|
-
gap: 10px;
|
|
729
|
-
padding: 8px 12px;
|
|
730
|
-
border-radius: var(--radius-sm);
|
|
731
|
-
cursor: pointer;
|
|
732
|
-
color: var(--text);
|
|
733
|
-
font-size: 13px;
|
|
734
|
-
background: none;
|
|
735
|
-
border: 1px solid transparent;
|
|
736
|
-
text-align: left;
|
|
737
|
-
width: 100%;
|
|
738
|
-
transition: background 0.15s, border-color 0.15s;
|
|
739
|
-
}
|
|
740
|
-
.action-item:hover { background: var(--bg-surface-hover); border-color: var(--border); }
|
|
741
|
-
.action-item .action-icon { font-size: 14px; width: 20px; text-align: center; flex-shrink: 0; }
|
|
742
|
-
.action-item .action-label { flex: 1; }
|
|
743
|
-
.action-item .action-kbd {
|
|
744
|
-
font-family: var(--font-mono);
|
|
745
|
-
font-size: 10px;
|
|
746
|
-
color: var(--text-muted);
|
|
747
|
-
background: var(--bg);
|
|
748
|
-
border: 1px solid var(--border);
|
|
749
|
-
border-radius: 3px;
|
|
750
|
-
padding: 1px 6px;
|
|
751
|
-
}
|
|
752
|
-
.action-item.disabled { opacity: 0.4; cursor: not-allowed; }
|
|
753
|
-
|
|
754
|
-
/* ── Info Panel ──────────────────────────────────────────────── */
|
|
755
|
-
.info-grid {
|
|
756
|
-
display: grid;
|
|
757
|
-
grid-template-columns: auto 1fr;
|
|
758
|
-
gap: 6px 16px;
|
|
759
|
-
font-size: 13px;
|
|
760
|
-
}
|
|
761
|
-
.info-label { color: var(--text-muted); font-weight: 500; }
|
|
762
|
-
.info-value { color: var(--text); font-family: var(--font-mono); }
|
|
763
|
-
|
|
764
|
-
/* ── Session Stats (footer) ──────────────────────────────────── */
|
|
765
|
-
.stats-bar {
|
|
766
|
-
display: flex;
|
|
767
|
-
gap: 16px;
|
|
768
|
-
align-items: center;
|
|
769
|
-
font-size: 11px;
|
|
770
|
-
color: var(--text-muted);
|
|
771
|
-
font-family: var(--font-mono);
|
|
772
|
-
flex-wrap: wrap;
|
|
773
|
-
}
|
|
774
|
-
.stats-bar .stat-item {
|
|
775
|
-
display: flex;
|
|
776
|
-
align-items: center;
|
|
777
|
-
gap: 4px;
|
|
778
|
-
}
|
|
779
|
-
.stats-bar .stat-value { color: var(--text-dim); font-weight: 600; }
|
|
780
|
-
.stats-bar .stat-label { font-family: var(--font); }
|
|
781
|
-
|
|
782
|
-
/* ── Cleanup Modal ───────────────────────────────────────────── */
|
|
783
|
-
.cleanup-branch-list {
|
|
784
|
-
display: flex;
|
|
785
|
-
flex-direction: column;
|
|
786
|
-
gap: 4px;
|
|
787
|
-
margin: 12px 0;
|
|
788
|
-
max-height: 200px;
|
|
789
|
-
overflow-y: auto;
|
|
790
|
-
}
|
|
791
|
-
.cleanup-branch-item {
|
|
792
|
-
display: flex;
|
|
793
|
-
align-items: center;
|
|
794
|
-
gap: 8px;
|
|
795
|
-
padding: 6px 10px;
|
|
796
|
-
font-family: var(--font-mono);
|
|
797
|
-
font-size: 12px;
|
|
798
|
-
color: var(--text-dim);
|
|
799
|
-
background: var(--bg);
|
|
800
|
-
border-radius: var(--radius-sm);
|
|
801
|
-
border: 1px solid var(--border-subtle);
|
|
802
|
-
}
|
|
803
|
-
.cleanup-branch-icon { color: var(--red); font-size: 10px; }
|
|
804
|
-
|
|
805
|
-
/* ── Update Modal ────────────────────────────────────────────── */
|
|
806
|
-
.update-info {
|
|
807
|
-
font-size: 13px;
|
|
808
|
-
color: var(--text-dim);
|
|
809
|
-
margin-bottom: 16px;
|
|
810
|
-
line-height: 1.5;
|
|
811
|
-
}
|
|
812
|
-
.update-versions {
|
|
813
|
-
display: flex;
|
|
814
|
-
align-items: center;
|
|
815
|
-
gap: 12px;
|
|
816
|
-
margin-bottom: 16px;
|
|
817
|
-
font-family: var(--font-mono);
|
|
818
|
-
font-size: 13px;
|
|
819
|
-
}
|
|
820
|
-
.update-versions .old-version { color: var(--text-muted); }
|
|
821
|
-
.update-versions .arrow { color: var(--text-muted); }
|
|
822
|
-
.update-versions .new-version { color: var(--green); font-weight: 600; }
|
|
823
|
-
.update-progress {
|
|
824
|
-
font-size: 12px;
|
|
825
|
-
color: var(--yellow);
|
|
826
|
-
font-style: italic;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
/* ── Clickable Links ────────────────────────────────────────── */
|
|
830
|
-
.branch-name a, .branch-commit a, .pr-badge a {
|
|
831
|
-
color: inherit;
|
|
832
|
-
text-decoration: none;
|
|
833
|
-
transition: color 0.15s, text-decoration 0.15s;
|
|
834
|
-
}
|
|
835
|
-
.branch-name a:hover { text-decoration: underline; color: var(--accent); }
|
|
836
|
-
.branch-commit a:hover { text-decoration: underline; color: var(--yellow); }
|
|
837
|
-
.pr-badge a:hover { text-decoration: underline; }
|
|
838
|
-
|
|
839
|
-
/* ── Copy to Clipboard Button ───────────────────────────────── */
|
|
840
|
-
.copy-btn {
|
|
841
|
-
display: inline-flex;
|
|
842
|
-
align-items: center;
|
|
843
|
-
justify-content: center;
|
|
844
|
-
width: 20px;
|
|
845
|
-
height: 20px;
|
|
846
|
-
border: none;
|
|
847
|
-
background: none;
|
|
848
|
-
color: var(--text-muted);
|
|
849
|
-
cursor: pointer;
|
|
850
|
-
border-radius: var(--radius-sm);
|
|
851
|
-
font-size: 11px;
|
|
852
|
-
opacity: 0;
|
|
853
|
-
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
|
854
|
-
flex-shrink: 0;
|
|
855
|
-
padding: 0;
|
|
856
|
-
vertical-align: middle;
|
|
857
|
-
}
|
|
858
|
-
.branch-item:hover .copy-btn,
|
|
859
|
-
.copy-btn:focus { opacity: 0.7; }
|
|
860
|
-
.copy-btn:hover { opacity: 1; background: var(--bg-surface-active); color: var(--text); }
|
|
861
|
-
.copy-btn.copied { color: var(--green); opacity: 1; }
|
|
862
|
-
|
|
863
|
-
/* ── Notification Permission Button ─────────────────────────── */
|
|
864
|
-
.notif-btn {
|
|
865
|
-
font-size: 11px;
|
|
866
|
-
padding: 3px 10px;
|
|
867
|
-
border-radius: 10px;
|
|
868
|
-
border: 1px solid var(--border);
|
|
869
|
-
background: var(--bg);
|
|
870
|
-
color: var(--text-dim);
|
|
871
|
-
cursor: pointer;
|
|
872
|
-
text-transform: uppercase;
|
|
873
|
-
letter-spacing: 0.5px;
|
|
874
|
-
font-weight: 600;
|
|
875
|
-
transition: background 0.15s, border-color 0.15s;
|
|
876
|
-
}
|
|
877
|
-
.notif-btn:hover { background: var(--bg-surface-hover); border-color: var(--accent); }
|
|
878
|
-
.notif-btn.granted { background: var(--green-dim); color: #fff; border-color: var(--green-dim); cursor: default; }
|
|
879
|
-
.notif-btn.denied { background: var(--red-dim); color: #fff; border-color: var(--red-dim); cursor: default; opacity: 0.6; }
|
|
880
|
-
|
|
881
|
-
/* ── Sidebar Toggle ─────────────────────────────────────────── */
|
|
882
|
-
.sidebar-toggle {
|
|
883
|
-
background: none;
|
|
884
|
-
border: none;
|
|
885
|
-
color: var(--text-muted);
|
|
886
|
-
cursor: pointer;
|
|
887
|
-
font-size: 12px;
|
|
888
|
-
padding: 2px 6px;
|
|
889
|
-
border-radius: var(--radius-sm);
|
|
890
|
-
transition: color 0.15s, background 0.15s;
|
|
891
|
-
}
|
|
892
|
-
.sidebar-toggle:hover { color: var(--text); background: var(--bg-surface-hover); }
|
|
893
|
-
|
|
894
|
-
/* ── Collapsed sidebar ──────────────────────────────────────── */
|
|
895
|
-
.layout.sidebar-collapsed { grid-template-columns: 1fr 0px; }
|
|
896
|
-
.layout.sidebar-collapsed .side-panel { display: none; }
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
/* ── Preferences bar in footer ──────────────────────────────── */
|
|
900
|
-
.pref-btn {
|
|
901
|
-
background: none;
|
|
902
|
-
border: 1px solid var(--border);
|
|
903
|
-
color: var(--text-muted);
|
|
904
|
-
cursor: pointer;
|
|
905
|
-
font-size: 10px;
|
|
906
|
-
padding: 1px 8px;
|
|
907
|
-
border-radius: var(--radius-sm);
|
|
908
|
-
font-family: var(--font);
|
|
909
|
-
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
910
|
-
}
|
|
911
|
-
.pref-btn:hover { background: var(--bg-surface-hover); color: var(--text-dim); border-color: var(--text-muted); }
|
|
912
|
-
.pref-btn.active { background: var(--accent-dim); color: #fff; border-color: var(--accent-dim); }
|
|
913
|
-
|
|
914
|
-
@media (max-width: 900px) {
|
|
915
|
-
}
|
|
916
|
-
</style>
|
|
917
|
-
</head>
|
|
918
|
-
<body>
|
|
919
|
-
|
|
920
|
-
<div class="header">
|
|
921
|
-
<div class="header-left">
|
|
922
|
-
<span class="header-title">🏰 Git Watchtower</span>
|
|
923
|
-
<span class="header-version" id="version"></span>
|
|
924
|
-
<span class="header-project" id="project-name">-</span>
|
|
925
|
-
</div>
|
|
926
|
-
<div class="header-right">
|
|
927
|
-
<button class="notif-btn" id="notif-btn" title="Enable desktop notifications">notifications</button>
|
|
928
|
-
<span class="badge" id="status-badge">connecting</span>
|
|
929
|
-
<span class="connection-dot disconnected" id="connection-dot"></span>
|
|
930
|
-
</div>
|
|
931
|
-
</div>
|
|
932
|
-
|
|
933
|
-
<div class="tab-bar" id="tab-bar"></div>
|
|
934
|
-
|
|
935
|
-
<div class="layout">
|
|
936
|
-
<div class="branch-panel">
|
|
937
|
-
<div class="search-bar" id="search-bar">
|
|
938
|
-
<input type="text" class="search-input" id="search-input" placeholder="Filter branches..." autocomplete="off" spellcheck="false">
|
|
939
|
-
</div>
|
|
940
|
-
<div class="panel-header">
|
|
941
|
-
<span>Active Branches</span>
|
|
942
|
-
<span class="branch-count" id="branch-count">0</span>
|
|
943
|
-
</div>
|
|
944
|
-
<div class="branch-list" id="branch-list"></div>
|
|
945
|
-
</div>
|
|
946
|
-
|
|
947
|
-
<div class="side-panel" id="side-panel">
|
|
948
|
-
<div class="panel-header">Activity Log <button class="sidebar-toggle" id="sidebar-toggle" title="Toggle sidebar">▶</button></div>
|
|
949
|
-
<div class="activity-log" id="activity-log"></div>
|
|
950
|
-
</div>
|
|
951
|
-
|
|
952
|
-
<div class="footer" id="footer">
|
|
953
|
-
<span><kbd>j</kbd><kbd>k</kbd> navigate</span>
|
|
954
|
-
<span><kbd>Enter</kbd> switch</span>
|
|
955
|
-
<span><kbd>/</kbd> search</span>
|
|
956
|
-
<span><kbd>b</kbd> actions</span>
|
|
957
|
-
<span><kbd>i</kbd> info</span>
|
|
958
|
-
<span><kbd>l</kbd> logs</span>
|
|
959
|
-
<span><kbd>p</kbd> pull</span>
|
|
960
|
-
<span><kbd>f</kbd> fetch</span>
|
|
961
|
-
<span><kbd>S</kbd> stash</span>
|
|
962
|
-
<span><kbd>d</kbd> cleanup</span>
|
|
963
|
-
<span><kbd>h</kbd> history</span>
|
|
964
|
-
<span><kbd>Esc</kbd> close</span>
|
|
965
|
-
<span class="stats-bar" id="stats-bar"></span>
|
|
966
|
-
</div>
|
|
967
|
-
</div>
|
|
968
|
-
|
|
969
|
-
<div class="flash" id="flash"></div>
|
|
970
|
-
<div class="confirm-overlay" id="confirm-overlay">
|
|
971
|
-
<div class="confirm-box" id="confirm-box"></div>
|
|
972
|
-
</div>
|
|
973
|
-
<div class="toast-container" id="toast-container"></div>
|
|
974
|
-
|
|
975
|
-
<!-- Log Viewer Modal -->
|
|
976
|
-
<div class="modal-overlay" id="log-viewer-overlay">
|
|
977
|
-
<div class="modal-box" style="min-width:500px;max-width:750px;">
|
|
978
|
-
<div class="modal-title">
|
|
979
|
-
Server Logs
|
|
980
|
-
<button class="modal-close" id="log-viewer-close">×</button>
|
|
981
|
-
</div>
|
|
982
|
-
<div class="log-viewer-tabs" id="log-viewer-tabs">
|
|
983
|
-
<button class="log-viewer-tab active" data-tab="server">Server</button>
|
|
984
|
-
<button class="log-viewer-tab" data-tab="activity">Activity</button>
|
|
985
|
-
</div>
|
|
986
|
-
<div class="log-viewer-content" id="log-viewer-content"></div>
|
|
987
|
-
</div>
|
|
988
|
-
</div>
|
|
989
|
-
|
|
990
|
-
<!-- Branch Action Modal -->
|
|
991
|
-
<div class="modal-overlay" id="branch-action-overlay">
|
|
992
|
-
<div class="modal-box">
|
|
993
|
-
<div class="modal-title">
|
|
994
|
-
<span id="branch-action-title">Branch Actions</span>
|
|
995
|
-
<button class="modal-close" id="branch-action-close">×</button>
|
|
996
|
-
</div>
|
|
997
|
-
<div class="action-list" id="branch-action-list"></div>
|
|
998
|
-
</div>
|
|
999
|
-
</div>
|
|
1000
|
-
|
|
1001
|
-
<!-- Info Panel Modal -->
|
|
1002
|
-
<div class="modal-overlay" id="info-overlay">
|
|
1003
|
-
<div class="modal-box" style="min-width:380px;">
|
|
1004
|
-
<div class="modal-title">
|
|
1005
|
-
Server Info
|
|
1006
|
-
<button class="modal-close" id="info-close">×</button>
|
|
1007
|
-
</div>
|
|
1008
|
-
<div class="info-grid" id="info-grid"></div>
|
|
1009
|
-
</div>
|
|
1010
|
-
</div>
|
|
1011
|
-
|
|
1012
|
-
<!-- Branch Cleanup Modal -->
|
|
1013
|
-
<div class="modal-overlay" id="cleanup-overlay">
|
|
1014
|
-
<div class="modal-box">
|
|
1015
|
-
<div class="modal-title">
|
|
1016
|
-
Branch Cleanup
|
|
1017
|
-
<button class="modal-close" id="cleanup-close">×</button>
|
|
1018
|
-
</div>
|
|
1019
|
-
<div id="cleanup-content"></div>
|
|
1020
|
-
</div>
|
|
1021
|
-
</div>
|
|
1022
|
-
|
|
1023
|
-
<!-- Update Notification Modal -->
|
|
1024
|
-
<div class="modal-overlay" id="update-overlay">
|
|
1025
|
-
<div class="modal-box" style="min-width:380px;">
|
|
1026
|
-
<div class="modal-title">
|
|
1027
|
-
Update Available
|
|
1028
|
-
<button class="modal-close" id="update-close">×</button>
|
|
1029
|
-
</div>
|
|
1030
|
-
<div id="update-content"></div>
|
|
1031
|
-
</div>
|
|
1032
|
-
</div>
|
|
1033
|
-
|
|
1034
|
-
<!-- Stash Confirm Modal -->
|
|
1035
|
-
<div class="modal-overlay" id="stash-overlay">
|
|
1036
|
-
<div class="modal-box" style="min-width:380px;">
|
|
1037
|
-
<div class="modal-title">
|
|
1038
|
-
Stash Changes
|
|
1039
|
-
<button class="modal-close" id="stash-close">×</button>
|
|
1040
|
-
</div>
|
|
1041
|
-
<div id="stash-content"></div>
|
|
1042
|
-
</div>
|
|
1043
|
-
</div>
|
|
1044
|
-
|
|
1045
|
-
<script>
|
|
1046
|
-
(function() {
|
|
1047
|
-
'use strict';
|
|
1048
|
-
|
|
1049
|
-
// ── State ──────────────────────────────────────────────────────
|
|
1050
|
-
var state = null;
|
|
1051
|
-
var prevBranches = null; // for notification diffing
|
|
1052
|
-
var selectedIndex = 0;
|
|
1053
|
-
var searchMode = false;
|
|
1054
|
-
var searchQuery = '';
|
|
1055
|
-
var confirmMode = false;
|
|
1056
|
-
var confirmCallback = null;
|
|
1057
|
-
var connected = false;
|
|
1058
|
-
var flashTimer = null;
|
|
1059
|
-
var activeTabId = null;
|
|
1060
|
-
var logViewerMode = false;
|
|
1061
|
-
var logViewerTab = 'server';
|
|
1062
|
-
var branchActionMode = false;
|
|
1063
|
-
var infoMode = false;
|
|
1064
|
-
var cleanupMode = false;
|
|
1065
|
-
var updateMode = false;
|
|
1066
|
-
var stashMode = false;
|
|
1067
|
-
var pendingStashBranch = null;
|
|
1068
|
-
var updateNotificationShown = false;
|
|
1069
|
-
|
|
1070
|
-
// ── Persistent Preferences (localStorage) ─────────────────────
|
|
1071
|
-
var PREFS_KEY = 'git-watchtower-prefs';
|
|
1072
|
-
function loadPrefs() {
|
|
1073
|
-
try {
|
|
1074
|
-
return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
|
|
1075
|
-
} catch (e) { return {}; }
|
|
1076
|
-
}
|
|
1077
|
-
function savePrefs(updates) {
|
|
1078
|
-
var prefs = loadPrefs();
|
|
1079
|
-
for (var k in updates) { if (updates.hasOwnProperty(k)) prefs[k] = updates[k]; }
|
|
1080
|
-
try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch (e) { /* ignore */ }
|
|
1081
|
-
return prefs;
|
|
1082
|
-
}
|
|
1083
|
-
var prefs = loadPrefs();
|
|
1084
|
-
var sidebarCollapsed = prefs.sidebarCollapsed || false;
|
|
1085
|
-
var sortOrder = prefs.sortOrder || 'default';
|
|
1086
|
-
var pinnedBranches = prefs.pinnedBranches || [];
|
|
1087
|
-
|
|
1088
|
-
// Apply initial sidebar state
|
|
1089
|
-
(function() {
|
|
1090
|
-
var layout = document.querySelector('.layout');
|
|
1091
|
-
if (sidebarCollapsed) layout.classList.add('sidebar-collapsed');
|
|
1092
|
-
})();
|
|
1093
|
-
|
|
1094
|
-
// ── Browser Notifications ─────────────────────────────────────
|
|
1095
|
-
var notifPermission = typeof Notification !== 'undefined' ? Notification.permission : 'denied';
|
|
1096
|
-
|
|
1097
|
-
function updateNotifButton() {
|
|
1098
|
-
var btn = document.getElementById('notif-btn');
|
|
1099
|
-
if (notifPermission === 'granted') {
|
|
1100
|
-
btn.className = 'notif-btn granted';
|
|
1101
|
-
btn.textContent = 'notifs on';
|
|
1102
|
-
} else if (notifPermission === 'denied') {
|
|
1103
|
-
btn.className = 'notif-btn denied';
|
|
1104
|
-
btn.textContent = 'notifs blocked';
|
|
1105
|
-
} else {
|
|
1106
|
-
btn.className = 'notif-btn';
|
|
1107
|
-
btn.textContent = 'notifications';
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
updateNotifButton();
|
|
1111
|
-
|
|
1112
|
-
document.getElementById('notif-btn').addEventListener('click', function() {
|
|
1113
|
-
if (notifPermission === 'granted' || notifPermission === 'denied') return;
|
|
1114
|
-
if (typeof Notification === 'undefined') {
|
|
1115
|
-
showToast('Notifications not supported in this browser', 'warning');
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
Notification.requestPermission().then(function(perm) {
|
|
1119
|
-
notifPermission = perm;
|
|
1120
|
-
updateNotifButton();
|
|
1121
|
-
if (perm === 'granted') {
|
|
1122
|
-
showToast('Desktop notifications enabled', 'success');
|
|
1123
|
-
}
|
|
1124
|
-
});
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
function sendNotification(title, body, tag) {
|
|
1128
|
-
if (notifPermission !== 'granted') return;
|
|
1129
|
-
try {
|
|
1130
|
-
var n = new Notification(title, { body: body, tag: tag || 'git-watchtower', icon: '', silent: false });
|
|
1131
|
-
setTimeout(function() { n.close(); }, 8000);
|
|
1132
|
-
} catch (e) { /* ignore */ }
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
function diffBranchesForNotifications(oldBranches, newBranches) {
|
|
1136
|
-
if (!oldBranches || !newBranches) return;
|
|
1137
|
-
var oldMap = {};
|
|
1138
|
-
for (var i = 0; i < oldBranches.length; i++) {
|
|
1139
|
-
oldMap[oldBranches[i].name] = oldBranches[i];
|
|
1140
|
-
}
|
|
1141
|
-
for (var j = 0; j < newBranches.length; j++) {
|
|
1142
|
-
var nb = newBranches[j];
|
|
1143
|
-
var ob = oldMap[nb.name];
|
|
1144
|
-
if (!ob && nb.isNew) {
|
|
1145
|
-
sendNotification('New Branch', nb.name + ' was created', 'new-' + nb.name);
|
|
1146
|
-
} else if (ob && !ob.justUpdated && nb.justUpdated) {
|
|
1147
|
-
sendNotification('Branch Updated', nb.name + ' has new commits', 'update-' + nb.name);
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
// Check PR state changes
|
|
1151
|
-
if (state && state.branchPrStatusMap) {
|
|
1152
|
-
for (var bn in state.branchPrStatusMap) {
|
|
1153
|
-
if (!state.branchPrStatusMap.hasOwnProperty(bn)) continue;
|
|
1154
|
-
var pr = state.branchPrStatusMap[bn];
|
|
1155
|
-
if (pr && pr.state === 'MERGED') {
|
|
1156
|
-
// Only notify once - check if it was not merged before
|
|
1157
|
-
var oldBranch = oldMap[bn];
|
|
1158
|
-
if (oldBranch) {
|
|
1159
|
-
sendNotification('PR Merged', 'PR #' + pr.number + ' for ' + bn + ' was merged', 'merged-' + bn);
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
// ── Clipboard Helper ──────────────────────────────────────────
|
|
1167
|
-
function copyToClipboard(text, btnEl) {
|
|
1168
|
-
navigator.clipboard.writeText(text).then(function() {
|
|
1169
|
-
if (btnEl) {
|
|
1170
|
-
btnEl.classList.add('copied');
|
|
1171
|
-
btnEl.innerHTML = '✓';
|
|
1172
|
-
setTimeout(function() {
|
|
1173
|
-
btnEl.classList.remove('copied');
|
|
1174
|
-
btnEl.innerHTML = '📋';
|
|
1175
|
-
}, 1500);
|
|
1176
|
-
}
|
|
1177
|
-
showToast('Copied: ' + text, 'success');
|
|
1178
|
-
}).catch(function() {
|
|
1179
|
-
showToast('Failed to copy', 'error');
|
|
1180
|
-
});
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
// ── URL Building Helpers ──────────────────────────────────────
|
|
1184
|
-
function getRepoUrl() {
|
|
1185
|
-
return (state && state.repoWebUrl) ? state.repoWebUrl.replace(/\\/tree\\/.*$/, '') : null;
|
|
1186
|
-
}
|
|
1187
|
-
function getBranchUrl(branchName) {
|
|
1188
|
-
var base = getRepoUrl();
|
|
1189
|
-
if (!base) return null;
|
|
1190
|
-
return base + '/tree/' + encodeURIComponent(branchName);
|
|
1191
|
-
}
|
|
1192
|
-
function getCommitUrl(hash) {
|
|
1193
|
-
var base = getRepoUrl();
|
|
1194
|
-
if (!base || !hash) return null;
|
|
1195
|
-
return base + '/commit/' + hash;
|
|
1196
|
-
}
|
|
1197
|
-
function getPrUrl(prNumber) {
|
|
1198
|
-
var base = getRepoUrl();
|
|
1199
|
-
if (!base || !prNumber) return null;
|
|
1200
|
-
// Detect GitLab by URL pattern
|
|
1201
|
-
if (base.indexOf('gitlab') !== -1) {
|
|
1202
|
-
return base + '/-/merge_requests/' + prNumber;
|
|
1203
|
-
}
|
|
1204
|
-
return base + '/pull/' + prNumber;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// ── SSE Connection ─────────────────────────────────────────────
|
|
1208
|
-
var evtSource = null;
|
|
1209
|
-
|
|
1210
|
-
function connect() {
|
|
1211
|
-
if (evtSource) { evtSource.close(); }
|
|
1212
|
-
evtSource = new EventSource('/api/events');
|
|
1213
|
-
|
|
1214
|
-
evtSource.onopen = function() {
|
|
1215
|
-
connected = true;
|
|
1216
|
-
updateConnectionStatus();
|
|
1217
|
-
};
|
|
1218
|
-
|
|
1219
|
-
evtSource.addEventListener('state', function(e) {
|
|
1220
|
-
try {
|
|
1221
|
-
var newState = JSON.parse(e.data);
|
|
1222
|
-
// Diff branches for desktop notifications
|
|
1223
|
-
if (state && state.branches) {
|
|
1224
|
-
diffBranchesForNotifications(state.branches, newState.branches || []);
|
|
1225
|
-
}
|
|
1226
|
-
prevBranches = state ? state.branches : null;
|
|
1227
|
-
state = newState;
|
|
1228
|
-
if (!activeTabId && state.activeProjectId) {
|
|
1229
|
-
activeTabId = state.activeProjectId;
|
|
1230
|
-
}
|
|
1231
|
-
renderTabs();
|
|
1232
|
-
render();
|
|
1233
|
-
} catch (err) { /* ignore parse errors */ }
|
|
1234
|
-
});
|
|
1235
|
-
|
|
1236
|
-
evtSource.addEventListener('flash', function(e) {
|
|
1237
|
-
try {
|
|
1238
|
-
var data = JSON.parse(e.data);
|
|
1239
|
-
showFlash(data.text, data.type);
|
|
1240
|
-
} catch (err) { /* ignore */ }
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1243
|
-
evtSource.addEventListener('actionResult', function(e) {
|
|
1244
|
-
try {
|
|
1245
|
-
var data = JSON.parse(e.data);
|
|
1246
|
-
if (!data.success && data.message && data.message.indexOf('uncommitted') !== -1) {
|
|
1247
|
-
pendingStashBranch = data.branch || null;
|
|
1248
|
-
showErrorToastWithHint(data.message, 'Press S to stash');
|
|
1249
|
-
} else {
|
|
1250
|
-
showToast(data.message, data.success ? 'success' : 'error');
|
|
1251
|
-
}
|
|
1252
|
-
} catch (err) { /* ignore */ }
|
|
1253
|
-
});
|
|
1254
|
-
|
|
1255
|
-
evtSource.onerror = function() {
|
|
1256
|
-
connected = false;
|
|
1257
|
-
updateConnectionStatus();
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
function updateConnectionStatus() {
|
|
1262
|
-
var dot = document.getElementById('connection-dot');
|
|
1263
|
-
var badge = document.getElementById('status-badge');
|
|
1264
|
-
if (connected) {
|
|
1265
|
-
dot.className = 'connection-dot connected';
|
|
1266
|
-
badge.className = 'badge badge-online';
|
|
1267
|
-
badge.textContent = 'live';
|
|
1268
|
-
} else {
|
|
1269
|
-
dot.className = 'connection-dot disconnected';
|
|
1270
|
-
badge.className = 'badge badge-offline';
|
|
1271
|
-
badge.textContent = 'reconnecting';
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
// ── Actions ────────────────────────────────────────────────────
|
|
1276
|
-
function sendAction(action, payload) {
|
|
1277
|
-
var xhr = new XMLHttpRequest();
|
|
1278
|
-
xhr.open('POST', '/api/action');
|
|
1279
|
-
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
1280
|
-
var data = { action: action, payload: payload || {} };
|
|
1281
|
-
if (activeTabId) data.projectId = activeTabId;
|
|
1282
|
-
xhr.send(JSON.stringify(data));
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
// ── Flash Messages ─────────────────────────────────────────────
|
|
1286
|
-
function showFlash(text, type) {
|
|
1287
|
-
var el = document.getElementById('flash');
|
|
1288
|
-
el.textContent = text;
|
|
1289
|
-
el.className = 'flash visible ' + (type || 'info');
|
|
1290
|
-
clearTimeout(flashTimer);
|
|
1291
|
-
flashTimer = setTimeout(function() {
|
|
1292
|
-
el.className = 'flash';
|
|
1293
|
-
}, 3000);
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// ── Toast Notifications ────────────────────────────────────────
|
|
1297
|
-
function showToast(text, type) {
|
|
1298
|
-
var container = document.getElementById('toast-container');
|
|
1299
|
-
var toast = document.createElement('div');
|
|
1300
|
-
var icons = { success: '\\u2713', error: '\\u2717', info: '\\u2139', warning: '\\u26a0' };
|
|
1301
|
-
toast.className = 'toast ' + (type || 'info');
|
|
1302
|
-
toast.innerHTML = '<span class="toast-icon">' + (icons[type] || icons.info) + '</span>' + escHtml(text);
|
|
1303
|
-
container.appendChild(toast);
|
|
1304
|
-
requestAnimationFrame(function() {
|
|
1305
|
-
requestAnimationFrame(function() { toast.classList.add('visible'); });
|
|
1306
|
-
});
|
|
1307
|
-
setTimeout(function() {
|
|
1308
|
-
toast.classList.remove('visible');
|
|
1309
|
-
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
1310
|
-
}, 4000);
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
// ── Confirm Dialog ─────────────────────────────────────────────
|
|
1314
|
-
function showConfirm(title, message, onConfirm, opts) {
|
|
1315
|
-
opts = opts || {};
|
|
1316
|
-
confirmMode = true;
|
|
1317
|
-
confirmCallback = onConfirm;
|
|
1318
|
-
var box = document.getElementById('confirm-box');
|
|
1319
|
-
box.innerHTML =
|
|
1320
|
-
'<div class="confirm-title">' + escHtml(title) + '</div>' +
|
|
1321
|
-
'<div class="confirm-message">' + escHtml(message) + '</div>' +
|
|
1322
|
-
'<div class="confirm-actions">' +
|
|
1323
|
-
'<button class="confirm-btn" id="confirm-cancel">Cancel</button>' +
|
|
1324
|
-
'<button class="confirm-btn ' + (opts.danger ? 'danger' : 'primary') + '" id="confirm-ok">' +
|
|
1325
|
-
escHtml(opts.label || 'Confirm') +
|
|
1326
|
-
'</button>' +
|
|
1327
|
-
'</div>';
|
|
1328
|
-
document.getElementById('confirm-overlay').className = 'confirm-overlay active';
|
|
1329
|
-
document.getElementById('confirm-cancel').onclick = hideConfirm;
|
|
1330
|
-
document.getElementById('confirm-ok').onclick = function() {
|
|
1331
|
-
hideConfirm();
|
|
1332
|
-
if (confirmCallback) confirmCallback();
|
|
1333
|
-
};
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
function hideConfirm() {
|
|
1337
|
-
confirmMode = false;
|
|
1338
|
-
confirmCallback = null;
|
|
1339
|
-
document.getElementById('confirm-overlay').className = 'confirm-overlay';
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// ── Tabs ───────────────────────────────────────────────────────
|
|
1343
|
-
function renderTabs() {
|
|
1344
|
-
var tabBar = document.getElementById('tab-bar');
|
|
1345
|
-
var projects = (state && state.projects) || [];
|
|
1346
|
-
if (projects.length <= 1) {
|
|
1347
|
-
tabBar.className = 'tab-bar';
|
|
1348
|
-
return;
|
|
1349
|
-
}
|
|
1350
|
-
tabBar.className = 'tab-bar visible';
|
|
1351
|
-
// Adjust layout height for tab bar
|
|
1352
|
-
document.querySelector('.layout').style.height = 'calc(100vh - 49px - 40px)';
|
|
1353
|
-
var html = '';
|
|
1354
|
-
for (var i = 0; i < projects.length; i++) {
|
|
1355
|
-
var p = projects[i];
|
|
1356
|
-
var isActive = p.id === activeTabId;
|
|
1357
|
-
html += '<div class="tab' + (isActive ? ' active' : '') + '" data-project-id="' + escHtml(p.id) + '">';
|
|
1358
|
-
html += '<span class="tab-dot"></span>';
|
|
1359
|
-
html += escHtml(p.name);
|
|
1360
|
-
if (i < 9) html += '<span class="tab-number">' + (i + 1) + '</span>';
|
|
1361
|
-
html += '</div>';
|
|
1362
|
-
}
|
|
1363
|
-
tabBar.innerHTML = html;
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
function switchTab(projectId) {
|
|
1367
|
-
if (projectId === activeTabId) return;
|
|
1368
|
-
activeTabId = projectId;
|
|
1369
|
-
selectedIndex = 0;
|
|
1370
|
-
searchQuery = '';
|
|
1371
|
-
searchMode = false;
|
|
1372
|
-
document.getElementById('search-bar').className = 'search-bar';
|
|
1373
|
-
document.getElementById('search-input').value = '';
|
|
1374
|
-
renderTabs();
|
|
1375
|
-
// Fetch the project's state
|
|
1376
|
-
var xhr = new XMLHttpRequest();
|
|
1377
|
-
xhr.open('GET', '/api/projects/' + projectId + '/state');
|
|
1378
|
-
xhr.onload = function() {
|
|
1379
|
-
if (xhr.status === 200) {
|
|
1380
|
-
try {
|
|
1381
|
-
var pState = JSON.parse(xhr.responseText);
|
|
1382
|
-
// Merge into current state for rendering
|
|
1383
|
-
state.branches = pState.branches || [];
|
|
1384
|
-
state.currentBranch = pState.currentBranch;
|
|
1385
|
-
state.activityLog = pState.activityLog || [];
|
|
1386
|
-
state.switchHistory = pState.switchHistory || [];
|
|
1387
|
-
state.sparklineCache = pState.sparklineCache || {};
|
|
1388
|
-
state.branchPrStatusMap = pState.branchPrStatusMap || {};
|
|
1389
|
-
state.aheadBehindCache = pState.aheadBehindCache || {};
|
|
1390
|
-
state.projectName = pState.projectName || '';
|
|
1391
|
-
state.pollingStatus = pState.pollingStatus || 'idle';
|
|
1392
|
-
state.isOffline = pState.isOffline || false;
|
|
1393
|
-
state.serverMode = pState.serverMode || 'none';
|
|
1394
|
-
render();
|
|
1395
|
-
} catch (err) { /* ignore */ }
|
|
1396
|
-
}
|
|
1397
|
-
};
|
|
1398
|
-
xhr.send();
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
// ── Time Formatting ────────────────────────────────────────────
|
|
1402
|
-
function timeAgo(dateStr) {
|
|
1403
|
-
if (!dateStr) return '';
|
|
1404
|
-
var ts = new Date(dateStr).getTime();
|
|
1405
|
-
if (isNaN(ts)) return '';
|
|
1406
|
-
var diff = Date.now() - ts;
|
|
1407
|
-
if (diff < 0) return 'now';
|
|
1408
|
-
var s = Math.floor(diff / 1000);
|
|
1409
|
-
if (s < 60) return s + 's ago';
|
|
1410
|
-
var m = Math.floor(s / 60);
|
|
1411
|
-
if (m < 60) return m + 'm ago';
|
|
1412
|
-
var h = Math.floor(m / 60);
|
|
1413
|
-
if (h < 24) return h + 'h ago';
|
|
1414
|
-
var d = Math.floor(h / 24);
|
|
1415
|
-
return d + 'd ago';
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
// ── Sparkline Rendering ────────────────────────────────────────
|
|
1419
|
-
function renderSparklineBars(sparkStr) {
|
|
1420
|
-
if (!sparkStr) return '';
|
|
1421
|
-
var chars = '\\u2581\\u2582\\u2583\\u2584\\u2585\\u2586\\u2587\\u2588';
|
|
1422
|
-
var html = '<div class="sparkline-bar">';
|
|
1423
|
-
for (var i = 0; i < sparkStr.length; i++) {
|
|
1424
|
-
var ch = sparkStr[i];
|
|
1425
|
-
var idx = chars.indexOf(ch);
|
|
1426
|
-
if (idx < 0) {
|
|
1427
|
-
html += '<div class="spark-bar" style="height:1px"></div>';
|
|
1428
|
-
} else {
|
|
1429
|
-
var pct = Math.round(((idx + 1) / 8) * 100);
|
|
1430
|
-
html += '<div class="spark-bar" style="height:' + pct + '%"></div>';
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
html += '</div>';
|
|
1434
|
-
return html;
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
// ── Compact number ─────────────────────────────────────────────
|
|
1438
|
-
function fmtCompact(n) {
|
|
1439
|
-
if (n < 1000) return String(n);
|
|
1440
|
-
if (n < 10000) return (n / 1000).toFixed(1) + 'k';
|
|
1441
|
-
if (n < 1000000) return Math.round(n / 1000) + 'k';
|
|
1442
|
-
return (n / 1000000).toFixed(1) + 'm';
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
// ── Get Display Branches ───────────────────────────────────────
|
|
1446
|
-
function getDisplayBranches() {
|
|
1447
|
-
if (!state || !state.branches) return [];
|
|
1448
|
-
var branches = state.branches.slice();
|
|
1449
|
-
if (searchQuery) {
|
|
1450
|
-
var q = searchQuery.toLowerCase();
|
|
1451
|
-
branches = branches.filter(function(b) {
|
|
1452
|
-
return b.name.toLowerCase().indexOf(q) !== -1;
|
|
1453
|
-
});
|
|
1454
|
-
}
|
|
1455
|
-
// Pin branches to top
|
|
1456
|
-
if (pinnedBranches.length > 0) {
|
|
1457
|
-
var pinSet = {};
|
|
1458
|
-
for (var i = 0; i < pinnedBranches.length; i++) pinSet[pinnedBranches[i]] = true;
|
|
1459
|
-
branches.sort(function(a, b) {
|
|
1460
|
-
var aPin = pinSet[a.name] ? 1 : 0;
|
|
1461
|
-
var bPin = pinSet[b.name] ? 1 : 0;
|
|
1462
|
-
return bPin - aPin; // pinned first
|
|
1463
|
-
});
|
|
1464
|
-
}
|
|
1465
|
-
// Sort
|
|
1466
|
-
if (sortOrder === 'alpha') {
|
|
1467
|
-
var pinSet2 = {};
|
|
1468
|
-
for (var j = 0; j < pinnedBranches.length; j++) pinSet2[pinnedBranches[j]] = true;
|
|
1469
|
-
branches.sort(function(a, b) {
|
|
1470
|
-
// Pinned branches always first
|
|
1471
|
-
var aPin = pinSet2[a.name] ? 1 : 0;
|
|
1472
|
-
var bPin = pinSet2[b.name] ? 1 : 0;
|
|
1473
|
-
if (aPin !== bPin) return bPin - aPin;
|
|
1474
|
-
return a.name.localeCompare(b.name);
|
|
1475
|
-
});
|
|
1476
|
-
} else if (sortOrder === 'recent') {
|
|
1477
|
-
var pinSet3 = {};
|
|
1478
|
-
for (var k = 0; k < pinnedBranches.length; k++) pinSet3[pinnedBranches[k]] = true;
|
|
1479
|
-
branches.sort(function(a, b) {
|
|
1480
|
-
var aPin = pinSet3[a.name] ? 1 : 0;
|
|
1481
|
-
var bPin = pinSet3[b.name] ? 1 : 0;
|
|
1482
|
-
if (aPin !== bPin) return bPin - aPin;
|
|
1483
|
-
var aDate = a.date ? new Date(a.date).getTime() : 0;
|
|
1484
|
-
var bDate = b.date ? new Date(b.date).getTime() : 0;
|
|
1485
|
-
return bDate - aDate;
|
|
1486
|
-
});
|
|
1487
|
-
}
|
|
1488
|
-
return branches;
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// ── Render ─────────────────────────────────────────────────────
|
|
1492
|
-
function render() {
|
|
1493
|
-
if (!state) return;
|
|
1494
|
-
|
|
1495
|
-
// Header — hide project name pill when tabs are showing it
|
|
1496
|
-
var projectEl = document.getElementById('project-name');
|
|
1497
|
-
var hasTabs = state.projects && state.projects.length > 1;
|
|
1498
|
-
if (hasTabs) {
|
|
1499
|
-
projectEl.style.display = 'none';
|
|
1500
|
-
} else {
|
|
1501
|
-
projectEl.style.display = '';
|
|
1502
|
-
projectEl.textContent = state.projectName || '-';
|
|
1503
|
-
}
|
|
1504
|
-
var versionEl = document.getElementById('version');
|
|
1505
|
-
if (state.version) versionEl.textContent = 'v' + state.version;
|
|
1506
|
-
|
|
1507
|
-
// Status badge
|
|
1508
|
-
if (connected) {
|
|
1509
|
-
var badge = document.getElementById('status-badge');
|
|
1510
|
-
if (state.isOffline) {
|
|
1511
|
-
badge.className = 'badge badge-offline';
|
|
1512
|
-
badge.textContent = 'offline';
|
|
1513
|
-
} else if (state.pollingStatus === 'fetching') {
|
|
1514
|
-
badge.className = 'badge badge-fetching';
|
|
1515
|
-
badge.textContent = 'fetching';
|
|
1516
|
-
} else {
|
|
1517
|
-
badge.className = 'badge badge-online';
|
|
1518
|
-
badge.textContent = 'live';
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
renderBranches();
|
|
1523
|
-
renderActivityLog();
|
|
1524
|
-
renderSessionStats();
|
|
1525
|
-
renderPrefsBar();
|
|
1526
|
-
|
|
1527
|
-
// Auto-show update notification (once per session)
|
|
1528
|
-
if (state.updateAvailable && !updateNotificationShown && !anyModalOpen()) {
|
|
1529
|
-
updateNotificationShown = true;
|
|
1530
|
-
showUpdateModal();
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// Update log viewer if open
|
|
1534
|
-
if (logViewerMode) renderLogViewer();
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
function renderBranches() {
|
|
1538
|
-
var container = document.getElementById('branch-list');
|
|
1539
|
-
var branches = getDisplayBranches();
|
|
1540
|
-
var countEl = document.getElementById('branch-count');
|
|
1541
|
-
countEl.textContent = branches.length;
|
|
1542
|
-
|
|
1543
|
-
if (selectedIndex >= branches.length) {
|
|
1544
|
-
selectedIndex = Math.max(0, branches.length - 1);
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
if (branches.length === 0) {
|
|
1548
|
-
container.innerHTML = '<div class="empty-state">' +
|
|
1549
|
-
'<div class="empty-state-icon">🌿</div>' +
|
|
1550
|
-
(searchQuery ? 'No branches matching "' + escHtml(searchQuery) + '"' : 'No branches found') +
|
|
1551
|
-
'</div>';
|
|
1552
|
-
return;
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
var html = '';
|
|
1556
|
-
for (var i = 0; i < branches.length; i++) {
|
|
1557
|
-
var b = branches[i];
|
|
1558
|
-
var isSelected = i === selectedIndex;
|
|
1559
|
-
var isCurrent = b.name === state.currentBranch;
|
|
1560
|
-
|
|
1561
|
-
// Sparkline
|
|
1562
|
-
var sparkStr = state.sparklineCache ? state.sparklineCache[b.name] : null;
|
|
1563
|
-
|
|
1564
|
-
// PR status
|
|
1565
|
-
var prStatus = state.branchPrStatusMap ? state.branchPrStatusMap[b.name] : null;
|
|
1566
|
-
var isMerged = prStatus && prStatus.state === 'MERGED';
|
|
1567
|
-
|
|
1568
|
-
// Ahead/behind
|
|
1569
|
-
var ab = state.aheadBehindCache ? state.aheadBehindCache[b.name] : null;
|
|
1570
|
-
|
|
1571
|
-
var itemClasses = 'branch-item';
|
|
1572
|
-
if (isSelected) itemClasses += ' selected';
|
|
1573
|
-
if (isCurrent) itemClasses += ' current';
|
|
1574
|
-
if (isMerged) itemClasses += ' merged';
|
|
1575
|
-
|
|
1576
|
-
html += '<div class="' + itemClasses + '" data-index="' + i + '">';
|
|
1577
|
-
if (isCurrent) {
|
|
1578
|
-
html += '<span class="branch-current-icon">●</span>';
|
|
1579
|
-
}
|
|
1580
|
-
html += '<span class="branch-cursor">▶</span>';
|
|
1581
|
-
html += '<div class="branch-info">';
|
|
1582
|
-
html += '<div class="branch-name-row">';
|
|
1583
|
-
// Branch name - clickable link to GitHub/GitLab
|
|
1584
|
-
var branchUrl = getBranchUrl(b.name);
|
|
1585
|
-
var isPinned = pinnedBranches.indexOf(b.name) !== -1;
|
|
1586
|
-
html += '<span class="branch-name">';
|
|
1587
|
-
if (branchUrl) {
|
|
1588
|
-
html += '<a href="' + escHtml(branchUrl) + '" target="_blank" rel="noopener" title="Open on web" onclick="event.stopPropagation()">' + escHtml(b.name) + '</a>';
|
|
1589
|
-
} else {
|
|
1590
|
-
html += escHtml(b.name);
|
|
1591
|
-
}
|
|
1592
|
-
html += '</span>';
|
|
1593
|
-
// Copy branch name button
|
|
1594
|
-
html += '<button class="copy-btn" data-copy="' + escHtml(b.name) + '" title="Copy branch name" onclick="event.stopPropagation()">📋</button>';
|
|
1595
|
-
html += '</div>'; // branch-name-row
|
|
1596
|
-
|
|
1597
|
-
html += '<div class="branch-meta">';
|
|
1598
|
-
// Commit hash - clickable link
|
|
1599
|
-
var commitUrl = getCommitUrl(b.commit);
|
|
1600
|
-
html += '<span class="branch-commit">';
|
|
1601
|
-
if (commitUrl) {
|
|
1602
|
-
html += '<a href="' + escHtml(commitUrl) + '" target="_blank" rel="noopener" title="View commit" onclick="event.stopPropagation()">' + escHtml(b.commit || '') + '</a>';
|
|
1603
|
-
} else {
|
|
1604
|
-
html += escHtml(b.commit || '');
|
|
1605
|
-
}
|
|
1606
|
-
html += '</span>';
|
|
1607
|
-
// Copy commit hash
|
|
1608
|
-
if (b.commit) {
|
|
1609
|
-
html += '<button class="copy-btn" data-copy="' + escHtml(b.commit) + '" title="Copy commit hash" onclick="event.stopPropagation()">📋</button>';
|
|
1610
|
-
}
|
|
1611
|
-
html += '<span class="branch-subject">' + escHtml(b.subject || '') + '</span>';
|
|
1612
|
-
html += '</div>'; // branch-meta
|
|
1613
|
-
html += '</div>'; // branch-info
|
|
1614
|
-
|
|
1615
|
-
html += '<div class="branch-right">';
|
|
1616
|
-
html += '<span class="branch-time">' + timeAgo(b.date) + '</span>';
|
|
1617
|
-
// Badges
|
|
1618
|
-
var badges = '';
|
|
1619
|
-
if (isCurrent) badges += '<span class="branch-current-badge">HEAD</span>';
|
|
1620
|
-
if (isPinned) badges += '<span class="branch-new-badge" style="color:var(--orange);background:rgba(219,109,40,0.15)">pinned</span>';
|
|
1621
|
-
if (b.isNew) badges += '<span class="branch-new-badge">new</span>';
|
|
1622
|
-
if (b.isDeleted) badges += '<span class="branch-deleted-badge">deleted</span>';
|
|
1623
|
-
if (b.justUpdated) badges += '<span class="branch-updated-badge">updated</span>';
|
|
1624
|
-
if (prStatus) {
|
|
1625
|
-
var prClass = prStatus.state === 'OPEN' ? 'pr-open' : prStatus.state === 'MERGED' ? 'pr-merged' : 'pr-closed';
|
|
1626
|
-
var prUrl = getPrUrl(prStatus.number);
|
|
1627
|
-
badges += '<span class="pr-badge ' + prClass + '">';
|
|
1628
|
-
if (prUrl) badges += '<a href="' + escHtml(prUrl) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()">';
|
|
1629
|
-
badges += (prStatus.state === 'MERGED' ? 'merged' : 'PR #' + prStatus.number);
|
|
1630
|
-
if (prUrl) badges += '</a>';
|
|
1631
|
-
badges += '</span>';
|
|
1632
|
-
if (prUrl) badges += '<button class="copy-btn" data-copy="' + escHtml(prUrl) + '" title="Copy PR URL" onclick="event.stopPropagation()">📋</button>';
|
|
1633
|
-
}
|
|
1634
|
-
if (badges) html += '<div class="branch-badges">' + badges + '</div>';
|
|
1635
|
-
if (ab && (ab.ahead || ab.behind)) {
|
|
1636
|
-
html += '<div class="branch-diff">';
|
|
1637
|
-
html += '<span class="diff-added">+' + fmtCompact(ab.ahead || 0) + '</span>';
|
|
1638
|
-
html += '<span class="diff-deleted">-' + fmtCompact(ab.behind || 0) + '</span>';
|
|
1639
|
-
html += '<span class="diff-label">commits</span>';
|
|
1640
|
-
if (ab.linesAdded || ab.linesDeleted) {
|
|
1641
|
-
html += ' <span class="diff-added">+' + fmtCompact(ab.linesAdded || 0) + '</span>';
|
|
1642
|
-
html += '<span class="diff-deleted">-' + fmtCompact(ab.linesDeleted || 0) + '</span>';
|
|
1643
|
-
html += '<span class="diff-label">lines</span>';
|
|
1644
|
-
}
|
|
1645
|
-
html += '</div>';
|
|
1646
|
-
}
|
|
1647
|
-
html += renderSparklineBars(sparkStr);
|
|
1648
|
-
html += '</div>';
|
|
1649
|
-
html += '</div>'; // branch-item
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
container.innerHTML = html;
|
|
1653
|
-
|
|
1654
|
-
// Scroll selected into view
|
|
1655
|
-
var selected = container.querySelector('.branch-item.selected');
|
|
1656
|
-
if (selected) {
|
|
1657
|
-
selected.scrollIntoView({ block: 'nearest' });
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
function renderActivityLog() {
|
|
1662
|
-
var container = document.getElementById('activity-log');
|
|
1663
|
-
var log = (state && state.activityLog) || [];
|
|
1664
|
-
if (log.length === 0) {
|
|
1665
|
-
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📋</div>No activity yet</div>';
|
|
1666
|
-
return;
|
|
1667
|
-
}
|
|
1668
|
-
var html = '';
|
|
1669
|
-
for (var i = 0; i < log.length; i++) {
|
|
1670
|
-
var entry = log[i];
|
|
1671
|
-
var t = '';
|
|
1672
|
-
if (entry.timestamp) {
|
|
1673
|
-
var d = new Date(entry.timestamp);
|
|
1674
|
-
t = isNaN(d.getTime()) ? '' : d.toLocaleTimeString();
|
|
1675
|
-
}
|
|
1676
|
-
html += '<div class="log-entry">';
|
|
1677
|
-
html += '<span class="log-dot ' + (entry.type || 'info') + '"></span>';
|
|
1678
|
-
html += '<span class="log-text">' + escHtml(entry.message) + '</span>';
|
|
1679
|
-
html += '<span class="log-time">' + t + '</span>';
|
|
1680
|
-
html += '</div>';
|
|
1681
|
-
}
|
|
1682
|
-
container.innerHTML = html;
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
// ── Log Viewer ─────────────────────────────────────────────────
|
|
1686
|
-
function showLogViewer() {
|
|
1687
|
-
logViewerMode = true;
|
|
1688
|
-
logViewerTab = 'server';
|
|
1689
|
-
renderLogViewer();
|
|
1690
|
-
document.getElementById('log-viewer-overlay').className = 'modal-overlay active';
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
function hideLogViewer() {
|
|
1694
|
-
logViewerMode = false;
|
|
1695
|
-
document.getElementById('log-viewer-overlay').className = 'modal-overlay';
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
function renderLogViewer() {
|
|
1699
|
-
if (!state) return;
|
|
1700
|
-
var container = document.getElementById('log-viewer-content');
|
|
1701
|
-
// Update tab active state
|
|
1702
|
-
var tabs = document.querySelectorAll('.log-viewer-tab');
|
|
1703
|
-
for (var t = 0; t < tabs.length; t++) {
|
|
1704
|
-
tabs[t].className = 'log-viewer-tab' + (tabs[t].getAttribute('data-tab') === logViewerTab ? ' active' : '');
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
var html = '';
|
|
1708
|
-
if (logViewerTab === 'server') {
|
|
1709
|
-
var logs = state.serverLogBuffer || [];
|
|
1710
|
-
if (logs.length === 0) {
|
|
1711
|
-
html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No server logs</div>';
|
|
1712
|
-
} else {
|
|
1713
|
-
for (var i = 0; i < logs.length; i++) {
|
|
1714
|
-
var log = logs[i];
|
|
1715
|
-
html += '<div class="log-line' + (log.isError ? ' error' : '') + '">';
|
|
1716
|
-
html += '<span class="log-ts">' + escHtml(log.timestamp || '') + '</span>';
|
|
1717
|
-
html += escHtml(log.line || '');
|
|
1718
|
-
html += '</div>';
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
} else {
|
|
1722
|
-
var alog = (state.activityLog || []);
|
|
1723
|
-
if (alog.length === 0) {
|
|
1724
|
-
html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No activity</div>';
|
|
1725
|
-
} else {
|
|
1726
|
-
for (var j = 0; j < alog.length; j++) {
|
|
1727
|
-
var entry = alog[j];
|
|
1728
|
-
var ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
|
|
1729
|
-
html += '<div class="log-line">';
|
|
1730
|
-
html += '<span class="log-ts">' + ts + '</span>';
|
|
1731
|
-
html += escHtml(entry.message || '');
|
|
1732
|
-
html += '</div>';
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
container.innerHTML = html;
|
|
1737
|
-
container.scrollTop = container.scrollHeight;
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
document.getElementById('log-viewer-tabs').addEventListener('click', function(e) {
|
|
1741
|
-
var tab = e.target.closest('.log-viewer-tab');
|
|
1742
|
-
if (!tab) return;
|
|
1743
|
-
logViewerTab = tab.getAttribute('data-tab');
|
|
1744
|
-
renderLogViewer();
|
|
1745
|
-
});
|
|
1746
|
-
|
|
1747
|
-
document.getElementById('log-viewer-close').addEventListener('click', hideLogViewer);
|
|
1748
|
-
document.getElementById('log-viewer-overlay').addEventListener('click', function(e) {
|
|
1749
|
-
if (e.target === this) hideLogViewer();
|
|
1750
|
-
});
|
|
1751
|
-
|
|
1752
|
-
// ── Branch Action Modal ────────────────────────────────────────
|
|
1753
|
-
function showBranchActions() {
|
|
1754
|
-
var branches = getDisplayBranches();
|
|
1755
|
-
if (!branches.length || selectedIndex >= branches.length) return;
|
|
1756
|
-
var branch = branches[selectedIndex];
|
|
1757
|
-
branchActionMode = true;
|
|
1758
|
-
document.getElementById('branch-action-title').textContent = 'Actions: ' + branch.name;
|
|
1759
|
-
|
|
1760
|
-
var prStatus = (state.branchPrStatusMap || {})[branch.name];
|
|
1761
|
-
var isCurrent = branch.name === state.currentBranch;
|
|
1762
|
-
|
|
1763
|
-
var actions = [];
|
|
1764
|
-
|
|
1765
|
-
// Open on web (GitHub/GitLab) — direct link if we have repo URL
|
|
1766
|
-
var brUrl = getBranchUrl(branch.name);
|
|
1767
|
-
if (brUrl) {
|
|
1768
|
-
actions.push({ icon: '\\u{1f310}', label: 'Open branch on web', key: 'openLink', data: { url: brUrl } });
|
|
1769
|
-
} else {
|
|
1770
|
-
actions.push({ icon: '\\u{1f310}', label: 'Open branch on web', key: 'openBranchWeb', data: { branch: branch.name } });
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
// PR actions
|
|
1774
|
-
var prUrl = prStatus ? getPrUrl(prStatus.number) : null;
|
|
1775
|
-
if (prStatus && prUrl) {
|
|
1776
|
-
actions.push({ icon: '\\u{1f517}', label: 'View PR #' + prStatus.number, key: 'openLink', data: { url: prUrl } });
|
|
1777
|
-
} else if (prStatus && prStatus.url) {
|
|
1778
|
-
actions.push({ icon: '\\u{1f517}', label: 'View PR #' + prStatus.number, key: 'openPrUrl', data: { url: prStatus.url } });
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
// Copy actions
|
|
1782
|
-
actions.push({ icon: '\\u{1f4cb}', label: 'Copy branch name', key: 'copy', data: { text: branch.name } });
|
|
1783
|
-
if (branch.commit) {
|
|
1784
|
-
actions.push({ icon: '\\u{1f4cb}', label: 'Copy commit hash (' + branch.commit + ')', key: 'copy', data: { text: branch.commit } });
|
|
1785
|
-
}
|
|
1786
|
-
if (prUrl) {
|
|
1787
|
-
actions.push({ icon: '\\u{1f4cb}', label: 'Copy PR URL', key: 'copy', data: { text: prUrl } });
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
// Pin/Unpin
|
|
1791
|
-
var isPinnedBranch = pinnedBranches.indexOf(branch.name) !== -1;
|
|
1792
|
-
actions.push({ icon: isPinnedBranch ? '\\u{1f4cc}' : '\\u{1f4cc}', label: isPinnedBranch ? 'Unpin branch' : 'Pin branch to top', key: 'pin', data: { branch: branch.name } });
|
|
1793
|
-
|
|
1794
|
-
// Switch to branch
|
|
1795
|
-
if (!isCurrent) {
|
|
1796
|
-
actions.push({ icon: '\\u{27a1}', label: 'Switch to this branch', key: 'switchBranch', data: { branch: branch.name } });
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
// Pull
|
|
1800
|
-
if (isCurrent) {
|
|
1801
|
-
actions.push({ icon: '\\u{2b07}', label: 'Pull latest changes', key: 'pull', data: {} });
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
// Fetch
|
|
1805
|
-
actions.push({ icon: '\\u{1f504}', label: 'Fetch all remotes', key: 'fetch', data: {} });
|
|
1806
|
-
|
|
1807
|
-
var html = '';
|
|
1808
|
-
for (var i = 0; i < actions.length; i++) {
|
|
1809
|
-
var a = actions[i];
|
|
1810
|
-
html += '<button class="action-item" data-action-key="' + escHtml(a.key) + '" data-action-data=\\'' + escHtml(JSON.stringify(a.data)) + '\\'>';
|
|
1811
|
-
html += '<span class="action-icon">' + a.icon + '</span>';
|
|
1812
|
-
html += '<span class="action-label">' + escHtml(a.label) + '</span>';
|
|
1813
|
-
html += '</button>';
|
|
1814
|
-
}
|
|
1815
|
-
document.getElementById('branch-action-list').innerHTML = html;
|
|
1816
|
-
document.getElementById('branch-action-overlay').className = 'modal-overlay active';
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
function hideBranchActions() {
|
|
1820
|
-
branchActionMode = false;
|
|
1821
|
-
document.getElementById('branch-action-overlay').className = 'modal-overlay';
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
document.getElementById('branch-action-close').addEventListener('click', hideBranchActions);
|
|
1825
|
-
document.getElementById('branch-action-overlay').addEventListener('click', function(e) {
|
|
1826
|
-
if (e.target === this) hideBranchActions();
|
|
1827
|
-
});
|
|
1828
|
-
|
|
1829
|
-
document.getElementById('branch-action-list').addEventListener('click', function(e) {
|
|
1830
|
-
var btn = e.target.closest('.action-item');
|
|
1831
|
-
if (!btn) return;
|
|
1832
|
-
var key = btn.getAttribute('data-action-key');
|
|
1833
|
-
var data = {};
|
|
1834
|
-
try { data = JSON.parse(btn.getAttribute('data-action-data') || '{}'); } catch (err) { /* ignore */ }
|
|
1835
|
-
|
|
1836
|
-
hideBranchActions();
|
|
1837
|
-
|
|
1838
|
-
if (key === 'openLink') {
|
|
1839
|
-
// Direct client-side link opening
|
|
1840
|
-
window.open(data.url, '_blank', 'noopener');
|
|
1841
|
-
showToast('Opening in browser...', 'info');
|
|
1842
|
-
} else if (key === 'copy') {
|
|
1843
|
-
copyToClipboard(data.text, null);
|
|
1844
|
-
} else if (key === 'pin') {
|
|
1845
|
-
var pIdx = pinnedBranches.indexOf(data.branch);
|
|
1846
|
-
if (pIdx === -1) {
|
|
1847
|
-
pinnedBranches.push(data.branch);
|
|
1848
|
-
showToast('Pinned: ' + data.branch, 'success');
|
|
1849
|
-
} else {
|
|
1850
|
-
pinnedBranches.splice(pIdx, 1);
|
|
1851
|
-
showToast('Unpinned: ' + data.branch, 'info');
|
|
1852
|
-
}
|
|
1853
|
-
savePrefs({ pinnedBranches: pinnedBranches });
|
|
1854
|
-
renderBranches();
|
|
1855
|
-
} else if (key === 'openBranchWeb' || key === 'openPrUrl') {
|
|
1856
|
-
// Fallback: handled by the server sending back a URL
|
|
1857
|
-
sendAction('openBrowser', data);
|
|
1858
|
-
showToast('Opening in browser...', 'info');
|
|
1859
|
-
} else if (key === 'switchBranch') {
|
|
1860
|
-
sendAction('switchBranch', data);
|
|
1861
|
-
showToast('Switching to ' + data.branch + '...', 'info');
|
|
1862
|
-
} else {
|
|
1863
|
-
sendAction(key, data);
|
|
1864
|
-
showToast(key + '...', 'info');
|
|
1865
|
-
}
|
|
1866
|
-
});
|
|
1867
|
-
|
|
1868
|
-
// ── Info Panel ─────────────────────────────────────────────────
|
|
1869
|
-
function showInfo() {
|
|
1870
|
-
if (!state) return;
|
|
1871
|
-
infoMode = true;
|
|
1872
|
-
var grid = document.getElementById('info-grid');
|
|
1873
|
-
var rows = [
|
|
1874
|
-
['Project', state.projectName || '-'],
|
|
1875
|
-
['Version', 'v' + (state.version || '-')],
|
|
1876
|
-
['Server Mode', state.serverMode || 'none'],
|
|
1877
|
-
['Server Port', state.noServer ? 'N/A' : String(state.port || '-')],
|
|
1878
|
-
['Server Running', state.serverRunning ? 'Yes' : 'No'],
|
|
1879
|
-
['SSE Clients', String(state.clientCount || 0)],
|
|
1880
|
-
['Current Branch', state.currentBranch || '-'],
|
|
1881
|
-
['Polling Status', state.pollingStatus || 'idle'],
|
|
1882
|
-
['Network', state.isOffline ? 'Offline' : 'Online'],
|
|
1883
|
-
['Branches', String((state.branches || []).length)],
|
|
1884
|
-
];
|
|
1885
|
-
var html = '';
|
|
1886
|
-
for (var i = 0; i < rows.length; i++) {
|
|
1887
|
-
html += '<span class="info-label">' + escHtml(rows[i][0]) + '</span>';
|
|
1888
|
-
html += '<span class="info-value">' + escHtml(rows[i][1]) + '</span>';
|
|
1889
|
-
}
|
|
1890
|
-
grid.innerHTML = html;
|
|
1891
|
-
document.getElementById('info-overlay').className = 'modal-overlay active';
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
function hideInfo() {
|
|
1895
|
-
infoMode = false;
|
|
1896
|
-
document.getElementById('info-overlay').className = 'modal-overlay';
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
document.getElementById('info-close').addEventListener('click', hideInfo);
|
|
1900
|
-
document.getElementById('info-overlay').addEventListener('click', function(e) {
|
|
1901
|
-
if (e.target === this) hideInfo();
|
|
1902
|
-
});
|
|
1903
|
-
|
|
1904
|
-
// ── Stash Management ───────────────────────────────────────────
|
|
1905
|
-
function showStashDialog(pendingBranch) {
|
|
1906
|
-
stashMode = true;
|
|
1907
|
-
pendingStashBranch = pendingBranch || null;
|
|
1908
|
-
var msg = pendingBranch
|
|
1909
|
-
? 'You have uncommitted changes. Stash them before switching to <strong>' + escHtml(pendingBranch) + '</strong>?'
|
|
1910
|
-
: 'Stash all uncommitted changes in the working directory?';
|
|
1911
|
-
var html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:16px;">' + msg + '</div>';
|
|
1912
|
-
html += '<div class="confirm-actions">';
|
|
1913
|
-
html += '<button class="confirm-btn" id="stash-cancel">Cancel</button>';
|
|
1914
|
-
html += '<button class="confirm-btn primary" id="stash-confirm">Stash & Continue</button>';
|
|
1915
|
-
html += '</div>';
|
|
1916
|
-
document.getElementById('stash-content').innerHTML = html;
|
|
1917
|
-
document.getElementById('stash-overlay').className = 'modal-overlay active';
|
|
1918
|
-
document.getElementById('stash-cancel').onclick = hideStash;
|
|
1919
|
-
document.getElementById('stash-confirm').onclick = function() {
|
|
1920
|
-
sendAction('stash', { pendingBranch: pendingStashBranch });
|
|
1921
|
-
showToast('Stashing changes...', 'info');
|
|
1922
|
-
hideStash();
|
|
1923
|
-
};
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
function hideStash() {
|
|
1927
|
-
stashMode = false;
|
|
1928
|
-
pendingStashBranch = null;
|
|
1929
|
-
document.getElementById('stash-overlay').className = 'modal-overlay';
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
document.getElementById('stash-close').addEventListener('click', hideStash);
|
|
1933
|
-
document.getElementById('stash-overlay').addEventListener('click', function(e) {
|
|
1934
|
-
if (e.target === this) hideStash();
|
|
1935
|
-
});
|
|
1936
|
-
|
|
1937
|
-
// ── Branch Cleanup ─────────────────────────────────────────────
|
|
1938
|
-
function showCleanup() {
|
|
1939
|
-
cleanupMode = true;
|
|
1940
|
-
var html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:12px;">Scanning for branches with deleted remotes...</div>';
|
|
1941
|
-
document.getElementById('cleanup-content').innerHTML = html;
|
|
1942
|
-
document.getElementById('cleanup-overlay').className = 'modal-overlay active';
|
|
1943
|
-
|
|
1944
|
-
// Ask the server to find gone branches (we inspect state.branches for gone tracking hints)
|
|
1945
|
-
// For now, look at branches that have no remote
|
|
1946
|
-
var goneBranches = [];
|
|
1947
|
-
if (state && state.branches) {
|
|
1948
|
-
for (var i = 0; i < state.branches.length; i++) {
|
|
1949
|
-
var b = state.branches[i];
|
|
1950
|
-
if (b.isLocal && !b.hasRemote && b.name !== state.currentBranch) {
|
|
1951
|
-
goneBranches.push(b.name);
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
if (goneBranches.length === 0) {
|
|
1957
|
-
html = '<div style="color:var(--text-dim);font-size:13px;padding:12px 0;">No stale branches found. All branches have active remotes.</div>';
|
|
1958
|
-
html += '<div class="confirm-actions"><button class="confirm-btn" id="cleanup-done">OK</button></div>';
|
|
1959
|
-
document.getElementById('cleanup-content').innerHTML = html;
|
|
1960
|
-
document.getElementById('cleanup-done').onclick = hideCleanup;
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:8px;">Found ' + goneBranches.length + ' branch(es) with no remote tracking:</div>';
|
|
1965
|
-
html += '<div class="cleanup-branch-list">';
|
|
1966
|
-
for (var j = 0; j < goneBranches.length; j++) {
|
|
1967
|
-
html += '<div class="cleanup-branch-item"><span class="cleanup-branch-icon">✖</span>' + escHtml(goneBranches[j]) + '</div>';
|
|
1968
|
-
}
|
|
1969
|
-
html += '</div>';
|
|
1970
|
-
html += '<div class="confirm-actions">';
|
|
1971
|
-
html += '<button class="confirm-btn" id="cleanup-cancel">Cancel</button>';
|
|
1972
|
-
html += '<button class="confirm-btn danger" id="cleanup-safe">Safe Delete (-d)</button>';
|
|
1973
|
-
html += '<button class="confirm-btn danger" id="cleanup-force">Force Delete (-D)</button>';
|
|
1974
|
-
html += '</div>';
|
|
1975
|
-
|
|
1976
|
-
document.getElementById('cleanup-content').innerHTML = html;
|
|
1977
|
-
document.getElementById('cleanup-cancel').onclick = hideCleanup;
|
|
1978
|
-
document.getElementById('cleanup-safe').onclick = function() {
|
|
1979
|
-
sendAction('deleteBranches', { branches: goneBranches, force: false });
|
|
1980
|
-
showToast('Deleting ' + goneBranches.length + ' branches (safe)...', 'info');
|
|
1981
|
-
hideCleanup();
|
|
1982
|
-
};
|
|
1983
|
-
document.getElementById('cleanup-force').onclick = function() {
|
|
1984
|
-
showConfirm(
|
|
1985
|
-
'Force Delete',
|
|
1986
|
-
'Force delete ' + goneBranches.length + ' branch(es)? This may delete unmerged work.',
|
|
1987
|
-
function() {
|
|
1988
|
-
sendAction('deleteBranches', { branches: goneBranches, force: true });
|
|
1989
|
-
showToast('Force deleting ' + goneBranches.length + ' branches...', 'warning');
|
|
1990
|
-
hideCleanup();
|
|
1991
|
-
},
|
|
1992
|
-
{ danger: true, label: 'Force Delete' }
|
|
1993
|
-
);
|
|
1994
|
-
};
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
function hideCleanup() {
|
|
1998
|
-
cleanupMode = false;
|
|
1999
|
-
document.getElementById('cleanup-overlay').className = 'modal-overlay';
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
document.getElementById('cleanup-close').addEventListener('click', hideCleanup);
|
|
2003
|
-
document.getElementById('cleanup-overlay').addEventListener('click', function(e) {
|
|
2004
|
-
if (e.target === this) hideCleanup();
|
|
2005
|
-
});
|
|
2006
|
-
|
|
2007
|
-
// ── Update Notification ────────────────────────────────────────
|
|
2008
|
-
function showUpdateModal() {
|
|
2009
|
-
if (!state || !state.updateAvailable) return;
|
|
2010
|
-
updateMode = true;
|
|
2011
|
-
var html = '<div class="update-versions">';
|
|
2012
|
-
html += '<span class="old-version">v' + escHtml(state.version || '?') + '</span>';
|
|
2013
|
-
html += '<span class="arrow">→</span>';
|
|
2014
|
-
html += '<span class="new-version">v' + escHtml(state.updateAvailable) + '</span>';
|
|
2015
|
-
html += '</div>';
|
|
2016
|
-
html += '<div class="update-info">A new version of git-watchtower is available.</div>';
|
|
2017
|
-
if (state.updateInProgress) {
|
|
2018
|
-
html += '<div class="update-progress">Update in progress...</div>';
|
|
2019
|
-
} else {
|
|
2020
|
-
html += '<div class="confirm-actions">';
|
|
2021
|
-
html += '<button class="confirm-btn" id="update-dismiss">Dismiss</button>';
|
|
2022
|
-
html += '<button class="confirm-btn primary" id="update-install">Update Now</button>';
|
|
2023
|
-
html += '</div>';
|
|
2024
|
-
}
|
|
2025
|
-
document.getElementById('update-content').innerHTML = html;
|
|
2026
|
-
document.getElementById('update-overlay').className = 'modal-overlay active';
|
|
2027
|
-
if (!state.updateInProgress) {
|
|
2028
|
-
document.getElementById('update-dismiss').onclick = hideUpdate;
|
|
2029
|
-
document.getElementById('update-install').onclick = function() {
|
|
2030
|
-
sendAction('checkUpdate', { install: true });
|
|
2031
|
-
showToast('Installing update...', 'info');
|
|
2032
|
-
hideUpdate();
|
|
2033
|
-
};
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
function hideUpdate() {
|
|
2038
|
-
updateMode = false;
|
|
2039
|
-
document.getElementById('update-overlay').className = 'modal-overlay';
|
|
2040
|
-
}
|
|
2041
|
-
|
|
2042
|
-
document.getElementById('update-close').addEventListener('click', hideUpdate);
|
|
2043
|
-
document.getElementById('update-overlay').addEventListener('click', function(e) {
|
|
2044
|
-
if (e.target === this) hideUpdate();
|
|
2045
|
-
});
|
|
2046
|
-
|
|
2047
|
-
// ── Session Stats ──────────────────────────────────────────────
|
|
2048
|
-
function renderSessionStats() {
|
|
2049
|
-
if (!state || !state.sessionStats) return;
|
|
2050
|
-
var s = state.sessionStats;
|
|
2051
|
-
var bar = document.getElementById('stats-bar');
|
|
2052
|
-
var activeBranches = 0;
|
|
2053
|
-
var staleBranches = 0;
|
|
2054
|
-
if (state.branches) {
|
|
2055
|
-
for (var i = 0; i < state.branches.length; i++) {
|
|
2056
|
-
var b = state.branches[i];
|
|
2057
|
-
// Consider stale if no updates and not current
|
|
2058
|
-
if (b.justUpdated || b.name === state.currentBranch) {
|
|
2059
|
-
activeBranches++;
|
|
2060
|
-
} else {
|
|
2061
|
-
staleBranches++;
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
}
|
|
2065
|
-
var html = '';
|
|
2066
|
-
html += '<span class="stat-item"><span class="stat-label">Session:</span> <span class="stat-value">' + escHtml(s.sessionDuration || '0m') + '</span></span>';
|
|
2067
|
-
html += '<span class="stat-item"><span class="stat-label">Lines:</span> <span class="stat-value">+' + (s.linesAdded || 0) + '/-' + (s.linesDeleted || 0) + '</span></span>';
|
|
2068
|
-
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>';
|
|
2069
|
-
if (s.lastUpdate) {
|
|
2070
|
-
html += '<span class="stat-item"><span class="stat-label">Last update:</span> <span class="stat-value">' + escHtml(s.lastUpdate) + '</span></span>';
|
|
2071
|
-
}
|
|
2072
|
-
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>';
|
|
2073
|
-
bar.innerHTML = html;
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
// ── Error Toast with Stash Hint ────────────────────────────────
|
|
2077
|
-
function showErrorToastWithHint(message, hint) {
|
|
2078
|
-
var container = document.getElementById('toast-container');
|
|
2079
|
-
var toast = document.createElement('div');
|
|
2080
|
-
toast.className = 'toast error';
|
|
2081
|
-
var html = '<span class="toast-icon">\\u2717</span>' + escHtml(message);
|
|
2082
|
-
if (hint) {
|
|
2083
|
-
html += '<span class="toast-action" data-hint="' + escHtml(hint) + '">' + escHtml(hint) + '</span>';
|
|
2084
|
-
}
|
|
2085
|
-
toast.innerHTML = html;
|
|
2086
|
-
container.appendChild(toast);
|
|
2087
|
-
requestAnimationFrame(function() {
|
|
2088
|
-
requestAnimationFrame(function() { toast.classList.add('visible'); });
|
|
2089
|
-
});
|
|
2090
|
-
|
|
2091
|
-
// Handle hint click
|
|
2092
|
-
var hintEl = toast.querySelector('.toast-action');
|
|
2093
|
-
if (hintEl) {
|
|
2094
|
-
hintEl.addEventListener('click', function() {
|
|
2095
|
-
var h = this.getAttribute('data-hint');
|
|
2096
|
-
if (h === 'Press S to stash') {
|
|
2097
|
-
showStashDialog(pendingStashBranch);
|
|
2098
|
-
}
|
|
2099
|
-
toast.classList.remove('visible');
|
|
2100
|
-
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
setTimeout(function() {
|
|
2105
|
-
toast.classList.remove('visible');
|
|
2106
|
-
setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
|
|
2107
|
-
}, 6000);
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
// ── Any modal open check ───────────────────────────────────────
|
|
2111
|
-
function anyModalOpen() {
|
|
2112
|
-
return logViewerMode || branchActionMode || infoMode || cleanupMode || updateMode || stashMode || confirmMode;
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
|
-
// ── Keyboard ───────────────────────────────────────────────────
|
|
2116
|
-
document.addEventListener('keydown', function(e) {
|
|
2117
|
-
// Ignore when typing in input fields (other than search)
|
|
2118
|
-
if (e.target.tagName === 'INPUT' && e.target.id !== 'search-input') return;
|
|
2119
|
-
if (e.target.tagName === 'BUTTON') return;
|
|
2120
|
-
|
|
2121
|
-
// Any modal — Escape to close
|
|
2122
|
-
if (logViewerMode && e.key === 'Escape') { e.preventDefault(); hideLogViewer(); return; }
|
|
2123
|
-
if (branchActionMode && e.key === 'Escape') { e.preventDefault(); hideBranchActions(); return; }
|
|
2124
|
-
if (infoMode && e.key === 'Escape') { e.preventDefault(); hideInfo(); return; }
|
|
2125
|
-
if (cleanupMode && e.key === 'Escape') { e.preventDefault(); hideCleanup(); return; }
|
|
2126
|
-
if (updateMode && e.key === 'Escape') { e.preventDefault(); hideUpdate(); return; }
|
|
2127
|
-
if (stashMode && e.key === 'Escape') { e.preventDefault(); hideStash(); return; }
|
|
2128
|
-
|
|
2129
|
-
// Log viewer tab switching
|
|
2130
|
-
if (logViewerMode) {
|
|
2131
|
-
if (e.key === 'Tab' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
2132
|
-
e.preventDefault();
|
|
2133
|
-
logViewerTab = logViewerTab === 'server' ? 'activity' : 'server';
|
|
2134
|
-
renderLogViewer();
|
|
2135
|
-
}
|
|
2136
|
-
return;
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
// Block other keys while modals are open
|
|
2140
|
-
if (branchActionMode || infoMode || cleanupMode || updateMode || stashMode) return;
|
|
2141
|
-
|
|
2142
|
-
// Confirm dialog mode — Escape to cancel, Enter to confirm
|
|
2143
|
-
if (confirmMode) {
|
|
2144
|
-
if (e.key === 'Escape') { e.preventDefault(); hideConfirm(); }
|
|
2145
|
-
if (e.key === 'Enter') {
|
|
2146
|
-
e.preventDefault();
|
|
2147
|
-
var cb = confirmCallback;
|
|
2148
|
-
hideConfirm();
|
|
2149
|
-
if (cb) cb();
|
|
2150
|
-
}
|
|
2151
|
-
return;
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
// Search mode
|
|
2155
|
-
if (searchMode) {
|
|
2156
|
-
if (e.key === 'Escape') {
|
|
2157
|
-
e.preventDefault();
|
|
2158
|
-
searchMode = false;
|
|
2159
|
-
searchQuery = '';
|
|
2160
|
-
document.getElementById('search-bar').className = 'search-bar';
|
|
2161
|
-
document.getElementById('search-input').value = '';
|
|
2162
|
-
selectedIndex = 0;
|
|
2163
|
-
renderBranches();
|
|
2164
|
-
return;
|
|
2165
|
-
}
|
|
2166
|
-
if (e.key === 'Enter') {
|
|
2167
|
-
e.preventDefault();
|
|
2168
|
-
searchMode = false;
|
|
2169
|
-
document.getElementById('search-bar').className = 'search-bar';
|
|
2170
|
-
return;
|
|
2171
|
-
}
|
|
2172
|
-
if (e.key === 'ArrowDown' || (e.key === 'j' && e.ctrlKey)) {
|
|
2173
|
-
e.preventDefault();
|
|
2174
|
-
moveSelection(1);
|
|
2175
|
-
return;
|
|
2176
|
-
}
|
|
2177
|
-
if (e.key === 'ArrowUp' || (e.key === 'k' && e.ctrlKey)) {
|
|
2178
|
-
e.preventDefault();
|
|
2179
|
-
moveSelection(-1);
|
|
2180
|
-
return;
|
|
2181
|
-
}
|
|
2182
|
-
return;
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
// Tab switching with number keys (1-9)
|
|
2186
|
-
var projects = (state && state.projects) || [];
|
|
2187
|
-
if (projects.length > 1 && e.key >= '1' && e.key <= '9') {
|
|
2188
|
-
var tabIdx = parseInt(e.key, 10) - 1;
|
|
2189
|
-
if (tabIdx < projects.length) {
|
|
2190
|
-
e.preventDefault();
|
|
2191
|
-
switchTab(projects[tabIdx].id);
|
|
2192
|
-
return;
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
// Tab cycling with Tab key
|
|
2197
|
-
if (e.key === 'Tab' && projects.length > 1) {
|
|
2198
|
-
e.preventDefault();
|
|
2199
|
-
var curIdx = projects.findIndex(function(p) { return p.id === activeTabId; });
|
|
2200
|
-
var nextIdx = e.shiftKey
|
|
2201
|
-
? (curIdx - 1 + projects.length) % projects.length
|
|
2202
|
-
: (curIdx + 1) % projects.length;
|
|
2203
|
-
switchTab(projects[nextIdx].id);
|
|
2204
|
-
return;
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
// Normal mode
|
|
2208
|
-
switch (e.key) {
|
|
2209
|
-
case 'j':
|
|
2210
|
-
case 'ArrowDown':
|
|
2211
|
-
e.preventDefault();
|
|
2212
|
-
moveSelection(1);
|
|
2213
|
-
break;
|
|
2214
|
-
case 'k':
|
|
2215
|
-
case 'ArrowUp':
|
|
2216
|
-
e.preventDefault();
|
|
2217
|
-
moveSelection(-1);
|
|
2218
|
-
break;
|
|
2219
|
-
case 'Enter':
|
|
2220
|
-
e.preventDefault();
|
|
2221
|
-
var branches = getDisplayBranches();
|
|
2222
|
-
if (branches.length > 0 && selectedIndex < branches.length) {
|
|
2223
|
-
var b = branches[selectedIndex];
|
|
2224
|
-
if (b.isDeleted) {
|
|
2225
|
-
showToast('Cannot switch to a deleted branch', 'error');
|
|
2226
|
-
} else if (b.name === state.currentBranch) {
|
|
2227
|
-
showToast('Already on ' + b.name, 'info');
|
|
2228
|
-
} else {
|
|
2229
|
-
sendAction('switchBranch', { branch: b.name });
|
|
2230
|
-
showToast('Switching to ' + b.name + '...', 'info');
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
break;
|
|
2234
|
-
case '/':
|
|
2235
|
-
e.preventDefault();
|
|
2236
|
-
searchMode = true;
|
|
2237
|
-
searchQuery = '';
|
|
2238
|
-
selectedIndex = 0;
|
|
2239
|
-
document.getElementById('search-bar').className = 'search-bar active';
|
|
2240
|
-
var input = document.getElementById('search-input');
|
|
2241
|
-
input.value = '';
|
|
2242
|
-
input.focus();
|
|
2243
|
-
break;
|
|
2244
|
-
case 'p':
|
|
2245
|
-
e.preventDefault();
|
|
2246
|
-
sendAction('pull');
|
|
2247
|
-
showToast('Pulling current branch...', 'info');
|
|
2248
|
-
break;
|
|
2249
|
-
case 'f':
|
|
2250
|
-
e.preventDefault();
|
|
2251
|
-
sendAction('fetch');
|
|
2252
|
-
showToast('Fetching all branches...', 'info');
|
|
2253
|
-
break;
|
|
2254
|
-
case 'r':
|
|
2255
|
-
e.preventDefault();
|
|
2256
|
-
if (state && state.serverMode === 'static') {
|
|
2257
|
-
sendAction('reloadBrowsers');
|
|
2258
|
-
showToast('Reloading browsers...', 'info');
|
|
2259
|
-
}
|
|
2260
|
-
break;
|
|
2261
|
-
case 'R':
|
|
2262
|
-
e.preventDefault();
|
|
2263
|
-
if (state && state.serverMode === 'command') {
|
|
2264
|
-
showConfirm(
|
|
2265
|
-
'Restart Server',
|
|
2266
|
-
'Restart the dev server process?',
|
|
2267
|
-
function() {
|
|
2268
|
-
sendAction('restartServer');
|
|
2269
|
-
showToast('Restarting server...', 'info');
|
|
2270
|
-
},
|
|
2271
|
-
{ label: 'Restart' }
|
|
2272
|
-
);
|
|
2273
|
-
}
|
|
2274
|
-
break;
|
|
2275
|
-
case 'c':
|
|
2276
|
-
e.preventDefault();
|
|
2277
|
-
sendAction('toggleCasino');
|
|
2278
|
-
break;
|
|
2279
|
-
case 'o':
|
|
2280
|
-
e.preventDefault();
|
|
2281
|
-
sendAction('openBrowser');
|
|
2282
|
-
showToast('Opening in browser...', 'info');
|
|
2283
|
-
break;
|
|
2284
|
-
case 'h':
|
|
2285
|
-
e.preventDefault();
|
|
2286
|
-
if (state && state.switchHistory && state.switchHistory.length > 0) {
|
|
2287
|
-
var last = state.switchHistory[0];
|
|
2288
|
-
var histMsg = 'Last: ' + last.from + ' \\u2192 ' + last.to;
|
|
2289
|
-
if (state.switchHistory.length > 1) histMsg += ' (+' + (state.switchHistory.length - 1) + ' more)';
|
|
2290
|
-
showToast(histMsg, 'info');
|
|
2291
|
-
} else {
|
|
2292
|
-
showToast('No switch history yet', 'info');
|
|
2293
|
-
}
|
|
2294
|
-
break;
|
|
2295
|
-
case 'u':
|
|
2296
|
-
e.preventDefault();
|
|
2297
|
-
sendAction('undo');
|
|
2298
|
-
showToast('Undoing last switch...', 'info');
|
|
2299
|
-
break;
|
|
2300
|
-
case 's':
|
|
2301
|
-
e.preventDefault();
|
|
2302
|
-
sendAction('toggleSound');
|
|
2303
|
-
showToast(state && state.soundEnabled ? 'Sound off' : 'Sound on', 'info');
|
|
2304
|
-
break;
|
|
2305
|
-
case 'b':
|
|
2306
|
-
e.preventDefault();
|
|
2307
|
-
showBranchActions();
|
|
2308
|
-
break;
|
|
2309
|
-
case 'i':
|
|
2310
|
-
e.preventDefault();
|
|
2311
|
-
showInfo();
|
|
2312
|
-
break;
|
|
2313
|
-
case 'l':
|
|
2314
|
-
e.preventDefault();
|
|
2315
|
-
showLogViewer();
|
|
2316
|
-
break;
|
|
2317
|
-
case 'S':
|
|
2318
|
-
e.preventDefault();
|
|
2319
|
-
showStashDialog(null);
|
|
2320
|
-
break;
|
|
2321
|
-
case 'd':
|
|
2322
|
-
e.preventDefault();
|
|
2323
|
-
showCleanup();
|
|
2324
|
-
break;
|
|
2325
|
-
case 'Escape':
|
|
2326
|
-
e.preventDefault();
|
|
2327
|
-
break;
|
|
2328
|
-
}
|
|
2329
|
-
});
|
|
2330
|
-
|
|
2331
|
-
// Search input handler
|
|
2332
|
-
document.getElementById('search-input').addEventListener('input', function(e) {
|
|
2333
|
-
searchQuery = e.target.value;
|
|
2334
|
-
selectedIndex = 0;
|
|
2335
|
-
renderBranches();
|
|
2336
|
-
});
|
|
2337
|
-
|
|
2338
|
-
function moveSelection(delta) {
|
|
2339
|
-
var branches = getDisplayBranches();
|
|
2340
|
-
var newIndex = selectedIndex + delta;
|
|
2341
|
-
if (newIndex >= 0 && newIndex < branches.length) {
|
|
2342
|
-
selectedIndex = newIndex;
|
|
2343
|
-
renderBranches();
|
|
2344
|
-
}
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
// ── Click Handlers ─────────────────────────────────────────────
|
|
2348
|
-
document.getElementById('branch-list').addEventListener('click', function(e) {
|
|
2349
|
-
var item = e.target.closest('.branch-item');
|
|
2350
|
-
if (!item) return;
|
|
2351
|
-
var idx = parseInt(item.getAttribute('data-index'), 10);
|
|
2352
|
-
if (isNaN(idx)) return;
|
|
2353
|
-
selectedIndex = idx;
|
|
2354
|
-
renderBranches();
|
|
2355
|
-
|
|
2356
|
-
// Double-click to switch with confirmation
|
|
2357
|
-
if (e.detail === 2) {
|
|
2358
|
-
var branches = getDisplayBranches();
|
|
2359
|
-
var br = branches[idx];
|
|
2360
|
-
if (br && !br.isDeleted && br.name !== state.currentBranch) {
|
|
2361
|
-
sendAction('switchBranch', { branch: br.name });
|
|
2362
|
-
showToast('Switching to ' + br.name + '...', 'info');
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
});
|
|
2366
|
-
|
|
2367
|
-
document.getElementById('confirm-overlay').addEventListener('click', function(e) {
|
|
2368
|
-
if (e.target === this) hideConfirm();
|
|
2369
|
-
});
|
|
2370
|
-
|
|
2371
|
-
// Tab clicks
|
|
2372
|
-
document.getElementById('tab-bar').addEventListener('click', function(e) {
|
|
2373
|
-
var tab = e.target.closest('.tab');
|
|
2374
|
-
if (!tab) return;
|
|
2375
|
-
var projectId = tab.getAttribute('data-project-id');
|
|
2376
|
-
if (projectId) switchTab(projectId);
|
|
2377
|
-
});
|
|
2378
|
-
|
|
2379
|
-
// ── Preferences Bar ─────────────────────────────────────────────
|
|
2380
|
-
function renderPrefsBar() {
|
|
2381
|
-
// Insert prefs controls into footer if not already there
|
|
2382
|
-
var footer = document.getElementById('footer');
|
|
2383
|
-
var existing = document.getElementById('prefs-bar');
|
|
2384
|
-
if (!existing) {
|
|
2385
|
-
var div = document.createElement('span');
|
|
2386
|
-
div.id = 'prefs-bar';
|
|
2387
|
-
div.style.cssText = 'display:flex;gap:6px;align-items:center;margin-left:auto;';
|
|
2388
|
-
div.innerHTML =
|
|
2389
|
-
'<button class="pref-btn' + (sortOrder === 'default' ? ' active' : '') + '" data-sort="default" title="Default sort">Default</button>' +
|
|
2390
|
-
'<button class="pref-btn' + (sortOrder === 'alpha' ? ' active' : '') + '" data-sort="alpha" title="Sort alphabetically">A-Z</button>' +
|
|
2391
|
-
'<button class="pref-btn' + (sortOrder === 'recent' ? ' active' : '') + '" data-sort="recent" title="Sort by most recent">Recent</button>' +
|
|
2392
|
-
'<button class="pref-btn" id="pin-selected-btn" title="Pin/unpin selected branch">Pin</button>' +
|
|
2393
|
-
'<button class="pref-btn' + (sidebarCollapsed ? ' active' : '') + '" id="toggle-sidebar-btn" title="Toggle sidebar">Sidebar</button>';
|
|
2394
|
-
footer.appendChild(div);
|
|
2395
|
-
}
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
// Prefs bar click handler
|
|
2399
|
-
document.getElementById('footer').addEventListener('click', function(e) {
|
|
2400
|
-
var sortBtn = e.target.closest('[data-sort]');
|
|
2401
|
-
if (sortBtn) {
|
|
2402
|
-
sortOrder = sortBtn.getAttribute('data-sort');
|
|
2403
|
-
savePrefs({ sortOrder: sortOrder });
|
|
2404
|
-
var sortBtns = document.querySelectorAll('[data-sort]');
|
|
2405
|
-
for (var i = 0; i < sortBtns.length; i++) {
|
|
2406
|
-
sortBtns[i].className = 'pref-btn' + (sortBtns[i].getAttribute('data-sort') === sortOrder ? ' active' : '');
|
|
2407
|
-
}
|
|
2408
|
-
renderBranches();
|
|
2409
|
-
return;
|
|
2410
|
-
}
|
|
2411
|
-
if (e.target.id === 'pin-selected-btn') {
|
|
2412
|
-
var branches = getDisplayBranches();
|
|
2413
|
-
if (branches.length > 0 && selectedIndex < branches.length) {
|
|
2414
|
-
var bn = branches[selectedIndex].name;
|
|
2415
|
-
var idx = pinnedBranches.indexOf(bn);
|
|
2416
|
-
if (idx === -1) {
|
|
2417
|
-
pinnedBranches.push(bn);
|
|
2418
|
-
showToast('Pinned: ' + bn, 'success');
|
|
2419
|
-
} else {
|
|
2420
|
-
pinnedBranches.splice(idx, 1);
|
|
2421
|
-
showToast('Unpinned: ' + bn, 'info');
|
|
2422
|
-
}
|
|
2423
|
-
savePrefs({ pinnedBranches: pinnedBranches });
|
|
2424
|
-
renderBranches();
|
|
2425
|
-
}
|
|
2426
|
-
return;
|
|
2427
|
-
}
|
|
2428
|
-
if (e.target.id === 'toggle-sidebar-btn') {
|
|
2429
|
-
sidebarCollapsed = !sidebarCollapsed;
|
|
2430
|
-
savePrefs({ sidebarCollapsed: sidebarCollapsed });
|
|
2431
|
-
var layout = document.querySelector('.layout');
|
|
2432
|
-
if (sidebarCollapsed) {
|
|
2433
|
-
layout.classList.add('sidebar-collapsed');
|
|
2434
|
-
} else {
|
|
2435
|
-
layout.classList.remove('sidebar-collapsed');
|
|
2436
|
-
}
|
|
2437
|
-
e.target.className = 'pref-btn' + (sidebarCollapsed ? ' active' : '');
|
|
2438
|
-
return;
|
|
2439
|
-
}
|
|
2440
|
-
});
|
|
2441
|
-
|
|
2442
|
-
// ── Sidebar Toggle (header) ───────────────────────────────────
|
|
2443
|
-
document.getElementById('sidebar-toggle').addEventListener('click', function() {
|
|
2444
|
-
sidebarCollapsed = !sidebarCollapsed;
|
|
2445
|
-
savePrefs({ sidebarCollapsed: sidebarCollapsed });
|
|
2446
|
-
var layout = document.querySelector('.layout');
|
|
2447
|
-
layout.classList.toggle('sidebar-collapsed', sidebarCollapsed);
|
|
2448
|
-
var btn = document.getElementById('toggle-sidebar-btn');
|
|
2449
|
-
if (btn) btn.className = 'pref-btn' + (sidebarCollapsed ? ' active' : '');
|
|
2450
|
-
});
|
|
2451
|
-
|
|
2452
|
-
// ── Copy button delegation ────────────────────────────────────
|
|
2453
|
-
document.addEventListener('click', function(e) {
|
|
2454
|
-
var copyBtn = e.target.closest('.copy-btn');
|
|
2455
|
-
if (!copyBtn) return;
|
|
2456
|
-
var text = copyBtn.getAttribute('data-copy');
|
|
2457
|
-
if (text) {
|
|
2458
|
-
e.preventDefault();
|
|
2459
|
-
e.stopPropagation();
|
|
2460
|
-
copyToClipboard(text, copyBtn);
|
|
2461
|
-
}
|
|
2462
|
-
});
|
|
2463
|
-
|
|
2464
|
-
// ── Utility ────────────────────────────────────────────────────
|
|
2465
|
-
function escHtml(s) {
|
|
2466
|
-
if (!s) return '';
|
|
2467
|
-
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
// ── Init ───────────────────────────────────────────────────────
|
|
2471
|
-
connect();
|
|
2472
|
-
})();
|
|
2473
|
-
</script>
|
|
2474
|
-
</body>
|
|
2475
|
-
</html>`;
|
|
2476
|
-
}
|
|
2477
|
-
|
|
2478
|
-
module.exports = { getWebDashboardHtml };
|
|
14
|
+
module.exports = require('./web-ui/index');
|