termbeam 1.11.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4837 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta
6
- name="viewport"
7
- content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
8
- />
9
- <meta name="apple-mobile-web-app-capable" content="yes" />
10
- <meta name="mobile-web-app-capable" content="yes" />
11
- <meta name="theme-color" content="#1e1e1e" />
12
- <meta
13
- name="description"
14
- content="TermBeam terminal session — access your terminal remotely from any browser with a mobile-optimized touch interface."
15
- />
16
- <link rel="manifest" href="/manifest.json" />
17
- <link rel="apple-touch-icon" href="/icons/icon-192.png" />
18
- <link rel="stylesheet" href="/css/themes.css" />
19
- <title>TermBeam — Terminal</title>
20
- <link
21
- rel="stylesheet"
22
- href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
23
- />
24
- <style>
25
- :root {
26
- --key-bg: #4a4a4c;
27
- --key-border: #5a5a5c;
28
- --key-shadow: rgba(0, 0, 0, 0.5);
29
- --key-special-bg: #333335;
30
- --overlay-bg: rgba(0, 0, 0, 0.85);
31
- }
32
- [data-theme='light'] { --key-bg: #ffffff; --key-border: #b5b5b5; --key-shadow: rgba(0, 0, 0, 0.12); --key-special-bg: #adb5bd; --overlay-bg: rgba(0, 0, 0, 0.5); }
33
- [data-theme='monokai'] { --key-bg: #49483e; --key-border: #5c5c4f; --key-shadow: rgba(0, 0, 0, 0.4); --key-special-bg: #3e3d32; }
34
- [data-theme='solarized-dark'] { --key-bg: #073642; --key-border: #586e75; --key-shadow: rgba(0, 0, 0, 0.3); --key-special-bg: #002b36; }
35
- [data-theme='solarized-light'] { --key-bg: #ffffff; --key-border: #b5b5b5; --key-shadow: rgba(0, 0, 0, 0.12); --key-special-bg: #adb5bd; }
36
- [data-theme='nord'] { --key-bg: #434c5e; --key-border: #4c566a; --key-shadow: rgba(0, 0, 0, 0.3); --key-special-bg: #3b4252; }
37
- [data-theme='dracula'] { --key-bg: #44475a; --key-border: #525568; --key-shadow: rgba(0, 0, 0, 0.4); --key-special-bg: #343746; }
38
- [data-theme='github-dark'] { --key-bg: #161b22; --key-border: #30363d; --key-shadow: rgba(0, 0, 0, 0.4); --key-special-bg: #0d1117; }
39
- [data-theme='one-dark'] { --key-bg: #3e4452; --key-border: #4b5263; --key-shadow: rgba(0, 0, 0, 0.3); --key-special-bg: #21252b; }
40
- [data-theme='catppuccin'] { --key-bg: #45475a; --key-border: #585b70; --key-shadow: rgba(0, 0, 0, 0.3); --key-special-bg: #313244; }
41
- [data-theme='gruvbox'] { --key-bg: #504945; --key-border: #665c54; --key-shadow: rgba(0, 0, 0, 0.4); --key-special-bg: #3c3836; }
42
- [data-theme='night-owl'] { --key-bg: #1d3b53; --key-border: #264863; --key-shadow: rgba(0, 0, 0, 0.4); --key-special-bg: #0d2a45; }
43
- @font-face {
44
- font-family: 'NerdFont';
45
- src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')
46
- format('truetype');
47
- font-weight: normal;
48
- font-style: normal;
49
- font-display: swap;
50
- }
51
- @font-face {
52
- font-family: 'NerdFont';
53
- src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf')
54
- format('truetype');
55
- font-weight: bold;
56
- font-style: normal;
57
- font-display: swap;
58
- }
59
- :root {
60
- --sab: env(safe-area-inset-bottom, 0px);
61
- }
62
- * {
63
- margin: 0;
64
- padding: 0;
65
- box-sizing: border-box;
66
- }
67
- html,
68
- body {
69
- height: 100%;
70
- width: 100%;
71
- background: var(--bg);
72
- color: var(--text);
73
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
74
- overflow: hidden;
75
- touch-action: manipulation;
76
- height: 100dvh;
77
- overscroll-behavior: none;
78
- transition:
79
- background 0.3s,
80
- color 0.3s;
81
- }
82
-
83
- /* ===== Top Bar (unified) ===== */
84
- #top-bar {
85
- height: 40px;
86
- display: flex;
87
- align-items: center;
88
- background: var(--surface);
89
- border-bottom: 1px solid var(--border);
90
- padding: 0 calc(4px + env(safe-area-inset-right, 0px)) 0
91
- calc(4px + env(safe-area-inset-left, 0px));
92
- padding-top: env(safe-area-inset-top, 0px);
93
- gap: 2px;
94
- transition:
95
- background 0.3s,
96
- border-color 0.3s;
97
- }
98
- #top-bar .left {
99
- display: flex;
100
- align-items: center;
101
- gap: 4px;
102
- flex: 1;
103
- min-width: 0;
104
- }
105
- #top-bar .right {
106
- display: flex;
107
- align-items: center;
108
- gap: 2px;
109
- flex-shrink: 0;
110
- }
111
- #status-dot {
112
- width: 8px;
113
- height: 8px;
114
- border-radius: 50%;
115
- background: var(--danger);
116
- display: inline-block;
117
- transition: background 0.3s;
118
- }
119
- #status-dot.connected {
120
- background: var(--success);
121
- }
122
- #session-name {
123
- font-weight: 600;
124
- font-size: 13px;
125
- white-space: nowrap;
126
- overflow: hidden;
127
- text-overflow: ellipsis;
128
- max-width: 120px;
129
- }
130
- #status-text {
131
- color: var(--text-secondary);
132
- font-size: 11px;
133
- white-space: nowrap;
134
- }
135
- #tab-list {
136
- display: flex;
137
- align-items: center;
138
- flex: 1;
139
- overflow-x: auto;
140
- -webkit-overflow-scrolling: touch;
141
- scrollbar-width: none;
142
- gap: 2px;
143
- padding: 0 4px;
144
- height: 100%;
145
- min-width: 0;
146
- }
147
- #tab-list::-webkit-scrollbar {
148
- display: none;
149
- }
150
- .session-tab {
151
- display: flex;
152
- align-items: center;
153
- gap: 6px;
154
- padding: 4px 10px;
155
- background: transparent;
156
- border: none;
157
- border-left: 3px solid transparent;
158
- border-radius: 6px;
159
- color: var(--text-dim);
160
- font-size: 12px;
161
- font-weight: 500;
162
- cursor: pointer;
163
- white-space: nowrap;
164
- flex-shrink: 0;
165
- height: 30px;
166
- transition:
167
- background 0.15s,
168
- color 0.15s;
169
- -webkit-tap-highlight-color: transparent;
170
- user-select: none;
171
- }
172
- .session-tab:hover {
173
- background: var(--border);
174
- color: var(--text);
175
- }
176
- .session-tab.active {
177
- background: var(--bg);
178
- color: var(--text);
179
- font-weight: 600;
180
- }
181
- .session-tab.in-split {
182
- background: rgba(0, 120, 212, 0.1);
183
- color: var(--text);
184
- }
185
- .session-tab.dragging {
186
- opacity: 0.4;
187
- }
188
- .tab-dot {
189
- width: 8px;
190
- height: 8px;
191
- border-radius: 50%;
192
- flex-shrink: 0;
193
- }
194
- .tab-name {
195
- max-width: 100px;
196
- overflow: hidden;
197
- text-overflow: ellipsis;
198
- }
199
- .tab-activity {
200
- font-size: 10px;
201
- color: var(--text-muted);
202
- flex-shrink: 0;
203
- }
204
- .tab-status {
205
- width: 6px;
206
- height: 6px;
207
- border-radius: 50%;
208
- flex-shrink: 0;
209
- }
210
- .tab-close {
211
- display: none;
212
- background: none;
213
- border: none;
214
- color: var(--text-muted);
215
- font-size: 14px;
216
- cursor: pointer;
217
- padding: 0 2px;
218
- line-height: 1;
219
- transition: color 0.15s;
220
- }
221
- .session-tab:hover .tab-close,
222
- .session-tab.active .tab-close {
223
- display: block;
224
- }
225
- .tab-close:hover {
226
- color: var(--danger);
227
- }
228
- .tab-unread {
229
- width: 7px;
230
- height: 7px;
231
- border-radius: 50%;
232
- background: var(--accent);
233
- flex-shrink: 0;
234
- animation: tab-pulse 1.5s ease-in-out infinite;
235
- }
236
- @keyframes tab-pulse {
237
- 0%,
238
- 100% {
239
- opacity: 1;
240
- }
241
- 50% {
242
- opacity: 0.4;
243
- }
244
- }
245
- /* ===== Tab Preview ===== */
246
- #tab-preview {
247
- display: none;
248
- position: fixed;
249
- z-index: 300;
250
- width: min(85vw, 340px);
251
- background: var(--bg);
252
- border: 1px solid var(--border);
253
- border-radius: 10px;
254
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
255
- overflow: hidden;
256
- pointer-events: none;
257
- }
258
- #tab-preview.visible {
259
- display: block;
260
- }
261
- .preview-header {
262
- display: flex;
263
- align-items: center;
264
- gap: 6px;
265
- padding: 6px 10px;
266
- border-bottom: 1px solid var(--border);
267
- font-size: 12px;
268
- font-weight: 600;
269
- }
270
- .preview-dot {
271
- width: 8px;
272
- height: 8px;
273
- border-radius: 50%;
274
- flex-shrink: 0;
275
- }
276
- .preview-body {
277
- padding: 6px 8px;
278
- font-family: 'NerdFont', 'JetBrains Mono', monospace;
279
- font-size: 10px;
280
- line-height: 1.35;
281
- color: var(--text);
282
- white-space: pre;
283
- overflow: hidden;
284
- max-height: 140px;
285
- -webkit-text-size-adjust: none;
286
- }
287
- .preview-empty {
288
- padding: 12px 10px;
289
- font-size: 11px;
290
- color: var(--text-muted);
291
- text-align: center;
292
- font-style: italic;
293
- }
294
-
295
- .tab-bar-btn {
296
- background: none;
297
- border: none;
298
- color: var(--text-dim);
299
- width: 30px;
300
- height: 30px;
301
- border-radius: 8px;
302
- cursor: pointer;
303
- display: flex;
304
- align-items: center;
305
- justify-content: center;
306
- flex-shrink: 0;
307
- font-size: 18px;
308
- font-weight: 600;
309
- transition:
310
- background 0.15s,
311
- color 0.15s;
312
- -webkit-tap-highlight-color: transparent;
313
- }
314
- .tab-bar-btn:hover {
315
- background: var(--border);
316
- color: var(--text);
317
- }
318
- .tab-bar-btn:active {
319
- background: var(--accent);
320
- color: #fff;
321
- }
322
- .tab-bar-btn.active {
323
- color: var(--accent);
324
- }
325
- #tab-new-btn {
326
- gap: 3px;
327
- width: auto;
328
- padding: 0 10px;
329
- font-size: 12px;
330
- }
331
- .new-btn-label {
332
- font-size: 11px;
333
- font-weight: 600;
334
- }
335
-
336
- .bar-btn {
337
- background: none;
338
- border: none;
339
- color: var(--text-dim);
340
- width: 30px;
341
- height: 30px;
342
- border-radius: 8px;
343
- cursor: pointer;
344
- display: flex;
345
- align-items: center;
346
- justify-content: center;
347
- transition:
348
- background 0.15s,
349
- color 0.15s;
350
- -webkit-tap-highlight-color: transparent;
351
- }
352
- .bar-btn:hover {
353
- background: var(--border);
354
- color: var(--text);
355
- }
356
- .bar-btn:active {
357
- background: var(--accent);
358
- color: #fff;
359
- }
360
- .bar-btn svg {
361
- display: block;
362
- }
363
- .bar-group {
364
- display: flex;
365
- align-items: center;
366
- background: var(--bg);
367
- border-radius: 8px;
368
- padding: 2px;
369
- gap: 1px;
370
- transition: background 0.3s;
371
- }
372
- .bar-group .bar-btn {
373
- width: 26px;
374
- height: 26px;
375
- border-radius: 6px;
376
- font-size: 14px;
377
- font-weight: 600;
378
- }
379
- .theme-wrap {
380
- position: relative;
381
- display: none;
382
- align-items: center;
383
- }
384
- .theme-picker {
385
- display: none;
386
- position: absolute;
387
- top: calc(100% + 4px);
388
- right: 0;
389
- background: var(--surface);
390
- border: 1px solid var(--border);
391
- border-radius: 8px;
392
- min-width: 160px;
393
- padding: 4px 0;
394
- z-index: 200;
395
- box-shadow: 0 4px 12px var(--shadow);
396
- }
397
- .theme-picker.open {
398
- display: block;
399
- }
400
- .theme-option {
401
- display: flex;
402
- align-items: center;
403
- gap: 8px;
404
- padding: 7px 12px;
405
- cursor: pointer;
406
- font-size: 13px;
407
- color: var(--text);
408
- transition: background 0.1s;
409
- white-space: nowrap;
410
- }
411
- .theme-option:hover {
412
- background: var(--border);
413
- }
414
- .theme-option.active {
415
- color: var(--accent);
416
- }
417
- .theme-swatch {
418
- width: 14px;
419
- height: 14px;
420
- border-radius: 50%;
421
- display: inline-block;
422
- flex-shrink: 0;
423
- border: 1px solid rgba(128, 128, 128, 0.3);
424
- }
425
- #stop-btn {
426
- background: none;
427
- border: none;
428
- color: var(--danger);
429
- height: 30px;
430
- border-radius: 8px;
431
- cursor: pointer;
432
- display: none;
433
- align-items: center;
434
- justify-content: center;
435
- gap: 4px;
436
- padding: 0 8px;
437
- font-size: 11px;
438
- font-weight: 600;
439
- transition:
440
- background 0.15s,
441
- color 0.15s,
442
- transform 0.1s;
443
- -webkit-tap-highlight-color: transparent;
444
- }
445
- #stop-btn:hover {
446
- background: var(--danger);
447
- color: #fff;
448
- }
449
- #stop-btn:active {
450
- background: var(--danger-hover);
451
- color: #fff;
452
- transform: scale(0.9);
453
- }
454
-
455
- /* ===== Notification Toggle ===== */
456
- .notify-btn.active {
457
- color: var(--accent) !important;
458
- }
459
-
460
- /* ===== Search Bar ===== */
461
- .search-bar {
462
- display: none;
463
- position: absolute;
464
- top: 4px;
465
- right: 12px;
466
- z-index: 100;
467
- background: var(--surface);
468
- border: 1px solid var(--border);
469
- border-radius: 8px;
470
- padding: 4px 6px;
471
- gap: 4px;
472
- align-items: center;
473
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
474
- font-size: 13px;
475
- color: var(--text);
476
- }
477
- .search-bar.visible {
478
- display: flex;
479
- }
480
- .search-bar input {
481
- background: var(--bg);
482
- border: 1px solid var(--border);
483
- border-radius: 4px;
484
- color: var(--text);
485
- padding: 3px 6px;
486
- font-size: 13px;
487
- font-family: inherit;
488
- width: 160px;
489
- outline: none;
490
- }
491
- .search-bar input:focus {
492
- border-color: var(--accent);
493
- }
494
- .search-bar .search-count {
495
- color: var(--text-secondary);
496
- font-size: 11px;
497
- min-width: 40px;
498
- text-align: center;
499
- white-space: nowrap;
500
- }
501
- .search-bar button {
502
- background: none;
503
- border: 1px solid transparent;
504
- color: var(--text-dim);
505
- width: 24px;
506
- height: 24px;
507
- border-radius: 4px;
508
- cursor: pointer;
509
- display: flex;
510
- align-items: center;
511
- justify-content: center;
512
- font-size: 13px;
513
- padding: 0;
514
- }
515
- .search-bar button:hover {
516
- background: var(--border);
517
- color: var(--text);
518
- }
519
- .search-bar button.active {
520
- color: var(--accent);
521
- border-color: var(--accent);
522
- }
523
-
524
- /* ===== Terminals Wrapper ===== */
525
- #terminals-wrapper {
526
- position: absolute;
527
- top: calc(41px + env(safe-area-inset-top, 0px));
528
- left: env(safe-area-inset-left, 0px);
529
- right: env(safe-area-inset-right, 0px);
530
- bottom: calc(80px + env(safe-area-inset-bottom, 0px));
531
- display: flex;
532
- overflow: hidden;
533
- }
534
- #terminals-wrapper.split-h {
535
- flex-direction: row;
536
- }
537
- #terminals-wrapper.split-v {
538
- flex-direction: column;
539
- }
540
- .terminal-pane {
541
- flex: 1;
542
- padding: 2px;
543
- overflow: hidden;
544
- position: relative;
545
- display: none;
546
- }
547
- .terminal-pane.visible {
548
- display: block;
549
- }
550
- #terminals-wrapper.split-h .terminal-pane.visible + .terminal-pane.visible {
551
- border-left: 2px solid var(--accent);
552
- }
553
- #terminals-wrapper.split-v .terminal-pane.visible + .terminal-pane.visible {
554
- border-top: 2px solid var(--accent);
555
- }
556
-
557
- /* ===== Key Bar ===== */
558
- #key-bar {
559
- position: fixed;
560
- bottom: 0;
561
- left: 0;
562
- right: 0;
563
- height: calc(80px + env(safe-area-inset-bottom, 0px));
564
- display: flex;
565
- flex-direction: column;
566
- background: #1c1c1e;
567
- border-top: 1px solid var(--border);
568
- padding: 4px calc(3px + env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px)
569
- calc(3px + env(safe-area-inset-left, 0px));
570
- gap: 6px;
571
- z-index: 50;
572
- transition:
573
- background 0.3s,
574
- border-color 0.3s;
575
- }
576
- [data-theme='light'] #key-bar {
577
- background: #d1d3d9;
578
- }
579
- [data-theme='solarized-light'] #key-bar {
580
- background: #d1d3d9;
581
- }
582
- .key-row {
583
- display: flex;
584
- align-items: center;
585
- gap: 4px;
586
- flex: 1;
587
- }
588
- .key-btn {
589
- min-width: 0;
590
- height: 34px;
591
- background: var(--key-bg);
592
- color: #fff;
593
- border: none;
594
- border-radius: 6px;
595
- font-size: 13px;
596
- font-weight: 500;
597
- cursor: pointer;
598
- display: flex;
599
- align-items: center;
600
- justify-content: center;
601
- -webkit-tap-highlight-color: transparent;
602
- user-select: none;
603
- white-space: nowrap;
604
- padding: 0 6px;
605
- flex: 1 1 0;
606
- line-height: 1;
607
- transition:
608
- background 0.1s,
609
- transform 0.08s,
610
- box-shadow 0.1s;
611
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
612
- }
613
- [data-theme='light'] .key-btn {
614
- color: #000;
615
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
616
- }
617
- [data-theme='solarized-light'] .key-btn {
618
- color: #000;
619
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
620
- }
621
- .key-btn:active {
622
- background: #6e6e72;
623
- transform: scale(0.95);
624
- box-shadow: none;
625
- }
626
- [data-theme='light'] .key-btn:active {
627
- background: #c8c8cc;
628
- }
629
- [data-theme='solarized-light'] .key-btn:active {
630
- background: #c8c8cc;
631
- }
632
- .key-btn.flash {
633
- background: #fff !important;
634
- color: #000 !important;
635
- transition: none;
636
- }
637
- [data-theme='light'] .key-btn.flash {
638
- background: #333 !important;
639
- color: #fff !important;
640
- }
641
- [data-theme='solarized-light'] .key-btn.flash {
642
- background: #333 !important;
643
- color: #fff !important;
644
- }
645
- .key-btn.modifier,
646
- .key-btn.special {
647
- background: var(--key-special-bg);
648
- font-size: 12px;
649
- font-weight: 600;
650
- }
651
- [data-theme='light'] .key-btn.modifier,
652
- [data-theme='light'] .key-btn.special {
653
- background: var(--key-special-bg);
654
- color: #000;
655
- }
656
- [data-theme='solarized-light'] .key-btn.modifier,
657
- [data-theme='solarized-light'] .key-btn.special {
658
- background: var(--key-special-bg);
659
- color: #000;
660
- }
661
- .key-btn.modifier.active {
662
- background: var(--accent);
663
- color: #fff;
664
- box-shadow:
665
- 0 1px 0 rgba(0, 0, 0, 0.2),
666
- 0 0 10px rgba(0, 120, 212, 0.5);
667
- }
668
- [data-theme='light'] .key-btn.modifier.active {
669
- background: var(--accent);
670
- color: #fff;
671
- box-shadow:
672
- 0 1px 0 rgba(0, 0, 0, 0.2),
673
- 0 0 10px rgba(0, 120, 212, 0.3);
674
- }
675
- .key-btn.icon-btn {
676
- font-size: 18px;
677
- }
678
- .key-btn.key-enter {
679
- background: var(--accent);
680
- color: #fff;
681
- font-size: 20px;
682
- }
683
- .key-btn.key-enter:active {
684
- background: var(--accent-active);
685
- }
686
- .key-btn.key-danger {
687
- background: #5c2222;
688
- color: #f87171;
689
- }
690
- [data-theme='light'] .key-btn.key-danger {
691
- background: #fee2e2;
692
- color: #dc2626;
693
- }
694
- [data-theme='solarized-light'] .key-btn.key-danger {
695
- background: #fee2e2;
696
- color: #dc2626;
697
- }
698
- .key-btn.key-danger:active {
699
- background: var(--danger);
700
- color: #fff;
701
- }
702
- .key-sep {
703
- width: 0;
704
- flex-shrink: 0;
705
- }
706
-
707
- .xterm {
708
- height: 100% !important;
709
- }
710
- .xterm-viewport {
711
- overflow-y: auto !important;
712
- scrollbar-width: none;
713
- overscroll-behavior: contain;
714
- }
715
- .xterm-viewport::-webkit-scrollbar {
716
- display: none;
717
- }
718
- .terminal-pane,
719
- .terminal-pane .xterm,
720
- .terminal-pane .xterm-screen,
721
- .terminal-pane .xterm-viewport,
722
- .terminal-pane .xterm-helper-textarea {
723
- touch-action: none;
724
- }
725
-
726
- /* ===== Scroll-to-bottom indicator ===== */
727
- .scroll-bottom-btn {
728
- display: none;
729
- position: absolute;
730
- bottom: 8px;
731
- right: 16px;
732
- width: 36px;
733
- height: 36px;
734
- border-radius: 50%;
735
- background: var(--accent);
736
- color: #fff;
737
- border: none;
738
- font-size: 18px;
739
- line-height: 36px;
740
- text-align: center;
741
- cursor: pointer;
742
- z-index: 50;
743
- opacity: 0.85;
744
- transition: opacity 0.15s;
745
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
746
- }
747
- .scroll-bottom-btn:hover {
748
- opacity: 1;
749
- }
750
- .scroll-bottom-btn.visible {
751
- display: block;
752
- }
753
-
754
- /* ===== Toasts & Overlays ===== */
755
- #copy-toast {
756
- position: fixed;
757
- top: 48px;
758
- left: 50%;
759
- transform: translateX(-50%) translateY(-8px);
760
- background: var(--surface);
761
- color: var(--text);
762
- border: 1px solid var(--border);
763
- padding: 6px 16px;
764
- border-radius: 8px;
765
- font-size: 13px;
766
- font-weight: 600;
767
- opacity: 0;
768
- pointer-events: none;
769
- transition:
770
- opacity 0.2s,
771
- transform 0.2s;
772
- z-index: 200;
773
- }
774
- #copy-toast.visible {
775
- opacity: 1;
776
- transform: translateX(-50%) translateY(0);
777
- }
778
-
779
- #paste-overlay {
780
- display: none;
781
- position: fixed;
782
- top: 0;
783
- left: 0;
784
- right: 0;
785
- bottom: 0;
786
- background: var(--overlay-bg);
787
- z-index: 150;
788
- flex-direction: column;
789
- align-items: center;
790
- justify-content: flex-start;
791
- padding-top: calc(80px + env(safe-area-inset-top, 0px));
792
- gap: 12px;
793
- }
794
- #paste-overlay.visible {
795
- display: flex;
796
- }
797
- #paste-overlay label {
798
- font-size: 15px;
799
- color: #fff;
800
- font-weight: 600;
801
- }
802
- #paste-input {
803
- width: 80%;
804
- max-width: 400px;
805
- min-height: 80px;
806
- background: var(--surface);
807
- color: var(--text);
808
- border: 1px solid var(--border);
809
- border-radius: 8px;
810
- padding: 10px;
811
- font-size: 14px;
812
- font-family: 'NerdFont', 'JetBrains Mono', monospace;
813
- resize: vertical;
814
- }
815
-
816
- #select-overlay {
817
- display: none;
818
- position: fixed;
819
- top: 0;
820
- left: 0;
821
- right: 0;
822
- bottom: 0;
823
- background: var(--bg);
824
- z-index: 160;
825
- flex-direction: column;
826
- }
827
- #select-overlay.visible {
828
- display: flex;
829
- }
830
- .select-overlay-header {
831
- display: flex;
832
- align-items: center;
833
- justify-content: space-between;
834
- padding: 12px 12px;
835
- border-bottom: 1px solid var(--border);
836
- font-size: 15px;
837
- font-weight: 600;
838
- color: var(--text);
839
- padding-top: calc(12px + env(safe-area-inset-top, 0px));
840
- min-height: 48px;
841
- box-sizing: border-box;
842
- }
843
- .select-overlay-header button {
844
- padding: 6px 12px;
845
- border: none;
846
- border-radius: 8px;
847
- font-size: 13px;
848
- font-weight: 600;
849
- cursor: pointer;
850
- white-space: nowrap;
851
- flex-shrink: 0;
852
- transition:
853
- background 0.15s,
854
- transform 0.1s;
855
- }
856
- .select-overlay-header button:active {
857
- transform: scale(0.95);
858
- }
859
- #select-copy {
860
- background: var(--accent);
861
- color: #fff;
862
- }
863
- #select-close {
864
- background: var(--border);
865
- color: var(--text);
866
- }
867
- #select-content {
868
- flex: 1;
869
- overflow: auto;
870
- padding: 12px 16px;
871
- font-family: 'NerdFont', 'JetBrains Mono', monospace;
872
- font-size: 13px;
873
- line-height: 1.4;
874
- color: var(--text);
875
- white-space: pre;
876
- word-break: normal;
877
- -webkit-user-select: text;
878
- user-select: text;
879
- margin: 0;
880
- padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
881
- }
882
-
883
- #reconnect-overlay {
884
- display: none;
885
- position: fixed;
886
- top: 0;
887
- left: 0;
888
- right: 0;
889
- bottom: 0;
890
- background: var(--overlay-bg);
891
- z-index: 100;
892
- flex-direction: column;
893
- align-items: center;
894
- justify-content: center;
895
- gap: 16px;
896
- }
897
- #reconnect-overlay.visible {
898
- display: flex;
899
- }
900
- #reconnect-overlay .msg {
901
- font-size: 17px;
902
- color: #fff;
903
- }
904
- .overlay-actions {
905
- display: flex;
906
- gap: 12px;
907
- }
908
- .overlay-actions button {
909
- padding: 10px 24px;
910
- border: none;
911
- border-radius: 8px;
912
- font-size: 15px;
913
- font-weight: 600;
914
- cursor: pointer;
915
- transition:
916
- background 0.15s,
917
- transform 0.1s;
918
- }
919
- .overlay-actions button:active {
920
- transform: scale(0.95);
921
- }
922
- #reconnect-btn {
923
- background: var(--accent);
924
- color: #fff;
925
- }
926
- #reconnect-btn:hover {
927
- background: var(--accent-hover);
928
- }
929
- #back-to-sessions {
930
- background: rgba(255, 255, 255, 0.15);
931
- color: #fff;
932
- }
933
- #back-to-sessions:hover {
934
- background: rgba(255, 255, 255, 0.25);
935
- }
936
-
937
- /* ===== Folder Browser ===== */
938
- .cwd-picker {
939
- display: flex;
940
- gap: 8px;
941
- }
942
- .cwd-picker input {
943
- flex: 1;
944
- }
945
- .cwd-browse-btn {
946
- background: var(--border);
947
- color: var(--text);
948
- border: 1px solid var(--border);
949
- border-radius: 8px;
950
- padding: 0 14px;
951
- font-size: 18px;
952
- cursor: pointer;
953
- flex-shrink: 0;
954
- display: flex;
955
- align-items: center;
956
- transition:
957
- background 0.15s,
958
- border-color 0.15s;
959
- }
960
- .cwd-browse-btn:hover {
961
- border-color: var(--accent);
962
- }
963
- .cwd-browse-btn:active {
964
- background: var(--accent);
965
- color: #ffffff;
966
- }
967
- .browser-overlay {
968
- display: none;
969
- position: fixed;
970
- top: 0;
971
- left: 0;
972
- right: 0;
973
- bottom: 0;
974
- background: var(--overlay-bg);
975
- z-index: 300;
976
- justify-content: center;
977
- align-items: flex-end;
978
- }
979
- .browser-overlay.visible {
980
- display: flex;
981
- }
982
- .browser-sheet {
983
- background: var(--surface);
984
- border-radius: 16px 16px 0 0;
985
- width: 100%;
986
- max-width: 500px;
987
- height: 80vh;
988
- display: flex;
989
- flex-direction: column;
990
- overflow: hidden;
991
- transition: background 0.3s;
992
- }
993
- .browser-header {
994
- padding: 16px 16px 12px;
995
- border-bottom: 1px solid var(--border);
996
- display: flex;
997
- align-items: center;
998
- justify-content: space-between;
999
- }
1000
- .browser-header h3 {
1001
- font-size: 17px;
1002
- font-weight: 600;
1003
- }
1004
- .browser-close {
1005
- background: none;
1006
- border: none;
1007
- color: var(--text-dim);
1008
- font-size: 24px;
1009
- cursor: pointer;
1010
- padding: 0 4px;
1011
- line-height: 1;
1012
- transition: color 0.15s;
1013
- }
1014
- .browser-close:hover {
1015
- color: var(--text);
1016
- }
1017
- .browser-close:active {
1018
- color: var(--text);
1019
- }
1020
- .browser-breadcrumb {
1021
- padding: 8px 16px;
1022
- display: flex;
1023
- align-items: center;
1024
- gap: 2px;
1025
- font-size: 13px;
1026
- overflow-x: auto;
1027
- white-space: nowrap;
1028
- border-bottom: 1px solid var(--border);
1029
- flex-shrink: 0;
1030
- -webkit-overflow-scrolling: touch;
1031
- }
1032
- .crumb {
1033
- background: none;
1034
- border: none;
1035
- color: var(--text-dim);
1036
- font-size: 13px;
1037
- cursor: pointer;
1038
- padding: 4px 6px;
1039
- border-radius: 4px;
1040
- flex-shrink: 0;
1041
- transition:
1042
- background 0.15s,
1043
- color 0.15s;
1044
- }
1045
- .crumb:active,
1046
- .crumb:hover {
1047
- background: var(--border);
1048
- color: var(--text);
1049
- }
1050
- .crumb.current {
1051
- color: var(--text);
1052
- font-weight: 600;
1053
- }
1054
- .crumb-sep {
1055
- color: var(--border-subtle);
1056
- flex-shrink: 0;
1057
- }
1058
- .browser-list {
1059
- flex: 1;
1060
- overflow-y: auto;
1061
- padding: 4px 0;
1062
- -webkit-overflow-scrolling: touch;
1063
- }
1064
- .browser-empty {
1065
- text-align: center;
1066
- padding: 40px 20px;
1067
- color: var(--text-muted);
1068
- font-size: 14px;
1069
- }
1070
- .folder-item {
1071
- display: flex;
1072
- align-items: center;
1073
- gap: 12px;
1074
- padding: 12px 16px;
1075
- cursor: pointer;
1076
- border-bottom: 1px solid rgba(60, 60, 60, 0.5);
1077
- transition: background 0.1s;
1078
- -webkit-tap-highlight-color: transparent;
1079
- }
1080
- .folder-item:active {
1081
- background: rgba(0, 120, 212, 0.2);
1082
- }
1083
- .folder-item:hover {
1084
- background: rgba(0, 120, 212, 0.1);
1085
- }
1086
- .folder-icon {
1087
- font-size: 22px;
1088
- flex-shrink: 0;
1089
- width: 28px;
1090
- text-align: center;
1091
- }
1092
- .folder-name {
1093
- font-size: 15px;
1094
- color: var(--text);
1095
- flex: 1;
1096
- overflow: hidden;
1097
- text-overflow: ellipsis;
1098
- white-space: nowrap;
1099
- }
1100
- .folder-arrow {
1101
- color: var(--border-subtle);
1102
- font-size: 18px;
1103
- flex-shrink: 0;
1104
- }
1105
- .browser-footer {
1106
- padding: 12px 16px calc(env(safe-area-inset-bottom, 8px) + 8px);
1107
- border-top: 1px solid var(--border);
1108
- display: flex;
1109
- flex-direction: column;
1110
- gap: 8px;
1111
- }
1112
- .browser-current-path {
1113
- font-size: 12px;
1114
- color: var(--text-dim);
1115
- overflow: hidden;
1116
- text-overflow: ellipsis;
1117
- white-space: nowrap;
1118
- }
1119
- .browser-select-btn {
1120
- width: 100%;
1121
- padding: 12px;
1122
- background: var(--accent);
1123
- color: #ffffff;
1124
- border: none;
1125
- border-radius: 10px;
1126
- font-size: 16px;
1127
- font-weight: 600;
1128
- cursor: pointer;
1129
- transition:
1130
- background 0.15s,
1131
- transform 0.1s;
1132
- }
1133
- .browser-select-btn:hover {
1134
- background: var(--accent-hover);
1135
- }
1136
- .browser-select-btn:active {
1137
- background: var(--accent-active);
1138
- transform: scale(0.98);
1139
- }
1140
-
1141
- /* ===== New Session Modal ===== */
1142
- .modal-overlay {
1143
- display: none;
1144
- position: fixed;
1145
- top: 0;
1146
- left: 0;
1147
- right: 0;
1148
- bottom: 0;
1149
- background: var(--overlay-bg);
1150
- z-index: 200;
1151
- justify-content: center;
1152
- align-items: flex-start;
1153
- padding-top: 15vh;
1154
- }
1155
- .modal-overlay.visible {
1156
- display: flex;
1157
- }
1158
- .modal {
1159
- background: var(--surface);
1160
- border-radius: 16px;
1161
- width: 90%;
1162
- max-width: 500px;
1163
- padding: 24px 20px;
1164
- transition: background 0.3s;
1165
- max-height: 90vh;
1166
- overflow-y: auto;
1167
- }
1168
- #upload-modal .modal {
1169
- overflow: visible;
1170
- }
1171
- .modal h2 {
1172
- font-size: 18px;
1173
- margin-bottom: 16px;
1174
- }
1175
- .modal label {
1176
- display: block;
1177
- font-size: 13px;
1178
- color: var(--text-secondary);
1179
- margin-bottom: 4px;
1180
- margin-top: 12px;
1181
- }
1182
- .modal input,
1183
- .modal select {
1184
- width: 100%;
1185
- padding: 10px 12px;
1186
- background: var(--bg);
1187
- border: 1px solid var(--border);
1188
- border-radius: 8px;
1189
- color: var(--text);
1190
- font-size: 15px;
1191
- outline: none;
1192
- -webkit-appearance: none;
1193
- appearance: none;
1194
- transition:
1195
- background 0.3s,
1196
- border-color 0.15s,
1197
- color 0.3s;
1198
- }
1199
- .modal select {
1200
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
1201
- background-repeat: no-repeat;
1202
- background-position: right 12px center;
1203
- padding-right: 32px;
1204
- cursor: pointer;
1205
- }
1206
- .modal input:focus,
1207
- .modal select:focus {
1208
- border-color: var(--accent);
1209
- }
1210
- .modal-actions {
1211
- display: flex;
1212
- gap: 12px;
1213
- margin-top: 20px;
1214
- }
1215
- .modal-actions button {
1216
- flex: 1;
1217
- padding: 12px;
1218
- border: none;
1219
- border-radius: 8px;
1220
- font-size: 15px;
1221
- font-weight: 600;
1222
- cursor: pointer;
1223
- transition:
1224
- background 0.15s,
1225
- transform 0.1s;
1226
- }
1227
- .modal-actions button:active {
1228
- transform: scale(0.95);
1229
- }
1230
- .btn-cancel {
1231
- background: var(--border);
1232
- color: var(--text);
1233
- }
1234
- .btn-cancel:hover {
1235
- background: var(--border-subtle);
1236
- }
1237
- .btn-create {
1238
- background: var(--accent);
1239
- color: #fff;
1240
- }
1241
- .btn-create:hover {
1242
- background: var(--accent-hover);
1243
- }
1244
-
1245
- .color-picker {
1246
- display: flex;
1247
- gap: 8px;
1248
- padding: 6px 0;
1249
- flex-wrap: wrap;
1250
- }
1251
- .color-swatch {
1252
- width: 32px;
1253
- height: 32px;
1254
- border-radius: 50%;
1255
- border: 3px solid transparent;
1256
- cursor: pointer;
1257
- padding: 0;
1258
- outline: none;
1259
- transition:
1260
- border-color 0.15s,
1261
- transform 0.1s;
1262
- -webkit-tap-highlight-color: transparent;
1263
- }
1264
- .color-swatch:hover {
1265
- transform: scale(1.1);
1266
- }
1267
- .color-swatch.selected {
1268
- border-color: var(--text);
1269
- transform: scale(1.15);
1270
- }
1271
-
1272
- /* ===== Sessions Side Panel (mobile) ===== */
1273
- #panel-toggle {
1274
- display: none;
1275
- background: none;
1276
- border: none;
1277
- color: var(--text-dim);
1278
- width: 30px;
1279
- height: 30px;
1280
- border-radius: 8px;
1281
- cursor: pointer;
1282
- align-items: center;
1283
- justify-content: center;
1284
- flex-shrink: 0;
1285
- transition:
1286
- background 0.15s,
1287
- color 0.15s;
1288
- -webkit-tap-highlight-color: transparent;
1289
- }
1290
- #panel-toggle:hover {
1291
- background: var(--border);
1292
- color: var(--text);
1293
- }
1294
-
1295
- #side-panel-backdrop {
1296
- display: none;
1297
- position: fixed;
1298
- top: 0;
1299
- left: 0;
1300
- right: 0;
1301
- bottom: 0;
1302
- background: rgba(0, 0, 0, 0.5);
1303
- z-index: 400;
1304
- opacity: 0;
1305
- transition: opacity 0.25s;
1306
- }
1307
- #side-panel-backdrop.visible {
1308
- display: block;
1309
- opacity: 1;
1310
- }
1311
-
1312
- #side-panel {
1313
- position: fixed;
1314
- top: 0;
1315
- left: 0;
1316
- bottom: 0;
1317
- width: min(85vw, 380px);
1318
- background: var(--surface);
1319
- border-right: 1px solid var(--border);
1320
- z-index: 410;
1321
- transform: translateX(-100%);
1322
- transition:
1323
- transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
1324
- background 0.3s;
1325
- display: flex;
1326
- flex-direction: column;
1327
- overflow: hidden;
1328
- padding-top: env(safe-area-inset-top, 0px);
1329
- padding-left: env(safe-area-inset-left, 0px);
1330
- padding-bottom: env(safe-area-inset-bottom, 0px);
1331
- }
1332
- #side-panel.open {
1333
- transform: translateX(0);
1334
- }
1335
-
1336
- .side-panel-header {
1337
- display: flex;
1338
- flex-direction: column;
1339
- padding: 16px 14px 12px;
1340
- border-bottom: 1px solid var(--border);
1341
- position: relative;
1342
- }
1343
- .side-panel-brand {
1344
- display: flex;
1345
- align-items: center;
1346
- gap: 8px;
1347
- font-size: 18px;
1348
- font-weight: 700;
1349
- letter-spacing: -0.02em;
1350
- }
1351
- .side-panel-brand svg {
1352
- flex-shrink: 0;
1353
- }
1354
- .side-panel-version {
1355
- font-size: 11px;
1356
- color: var(--text-muted);
1357
- font-weight: 400;
1358
- margin-top: 2px;
1359
- padding-left: 28px;
1360
- }
1361
- .side-panel-section-title {
1362
- font-size: 11px;
1363
- font-weight: 600;
1364
- text-transform: uppercase;
1365
- letter-spacing: 0.06em;
1366
- color: var(--text-dim);
1367
- padding: 10px 14px 4px;
1368
- }
1369
- .side-panel-close {
1370
- position: absolute;
1371
- top: 14px;
1372
- right: 10px;
1373
- background: none;
1374
- border: none;
1375
- color: var(--text-dim);
1376
- width: 30px;
1377
- height: 30px;
1378
- border-radius: 8px;
1379
- cursor: pointer;
1380
- display: flex;
1381
- align-items: center;
1382
- justify-content: center;
1383
- font-size: 20px;
1384
- transition:
1385
- background 0.15s,
1386
- color 0.15s;
1387
- -webkit-tap-highlight-color: transparent;
1388
- }
1389
- .side-panel-close:hover {
1390
- background: var(--border);
1391
- color: var(--text);
1392
- }
1393
-
1394
- .side-panel-list {
1395
- flex: 1 1 0;
1396
- min-height: 0;
1397
- overflow-y: auto;
1398
- padding: 8px;
1399
- -webkit-overflow-scrolling: touch;
1400
- display: flex;
1401
- flex-direction: column;
1402
- gap: 6px;
1403
- }
1404
-
1405
- .side-panel-card {
1406
- background: var(--bg);
1407
- border: 1px solid var(--border);
1408
- border-radius: 10px;
1409
- cursor: pointer;
1410
- overflow: hidden;
1411
- flex-shrink: 0;
1412
- transition:
1413
- background 0.15s,
1414
- border-color 0.15s;
1415
- -webkit-tap-highlight-color: transparent;
1416
- }
1417
- .side-panel-card:hover {
1418
- border-color: var(--accent);
1419
- }
1420
- .side-panel-card.active {
1421
- border-color: var(--accent);
1422
- border-width: 2px;
1423
- }
1424
-
1425
- .side-panel-card-header {
1426
- display: flex;
1427
- align-items: center;
1428
- gap: 8px;
1429
- padding: 10px 12px 6px;
1430
- }
1431
- .side-panel-card-dot {
1432
- width: 10px;
1433
- height: 10px;
1434
- border-radius: 50%;
1435
- flex-shrink: 0;
1436
- }
1437
- .side-panel-card-name {
1438
- font-size: 13px;
1439
- font-weight: 600;
1440
- flex: 1;
1441
- overflow: hidden;
1442
- text-overflow: ellipsis;
1443
- white-space: nowrap;
1444
- }
1445
- .side-panel-card-status {
1446
- width: 7px;
1447
- height: 7px;
1448
- border-radius: 50%;
1449
- flex-shrink: 0;
1450
- }
1451
- .side-panel-card-close {
1452
- background: none;
1453
- border: none;
1454
- color: var(--text-muted);
1455
- width: 26px;
1456
- height: 26px;
1457
- border-radius: 6px;
1458
- cursor: pointer;
1459
- display: flex;
1460
- align-items: center;
1461
- justify-content: center;
1462
- font-size: 16px;
1463
- flex-shrink: 0;
1464
- padding: 0;
1465
- transition:
1466
- background 0.15s,
1467
- color 0.15s;
1468
- -webkit-tap-highlight-color: transparent;
1469
- }
1470
- .side-panel-card-close:hover {
1471
- background: var(--danger);
1472
- color: #fff;
1473
- }
1474
- .side-panel-card-meta {
1475
- padding: 0 12px 4px;
1476
- font-size: 10px;
1477
- color: var(--text-muted);
1478
- }
1479
-
1480
- .side-panel-card-preview {
1481
- margin: 0 8px 8px;
1482
- padding: 6px 8px;
1483
- background: var(--surface);
1484
- border-radius: 6px;
1485
- font-family: 'NerdFont', 'JetBrains Mono', monospace;
1486
- font-size: 9px;
1487
- line-height: 1.3;
1488
- color: var(--text-secondary);
1489
- white-space: pre;
1490
- overflow: hidden;
1491
- max-height: 72px;
1492
- -webkit-text-size-adjust: none;
1493
- }
1494
- .side-panel-card-preview.empty {
1495
- font-style: italic;
1496
- color: var(--text-muted);
1497
- text-align: center;
1498
- font-family: inherit;
1499
- font-size: 11px;
1500
- white-space: normal;
1501
- }
1502
-
1503
- .side-panel-card-git {
1504
- display: flex;
1505
- flex-wrap: wrap;
1506
- gap: 3px 6px;
1507
- padding: 0 12px 4px;
1508
- font-size: 10px;
1509
- color: var(--text-secondary);
1510
- align-items: center;
1511
- }
1512
- .side-panel-card-git .git-badge {
1513
- display: inline-flex;
1514
- align-items: center;
1515
- gap: 3px;
1516
- background: var(--surface);
1517
- padding: 1px 6px;
1518
- border-radius: 3px;
1519
- }
1520
- .side-panel-card-git .git-status-clean {
1521
- color: var(--success);
1522
- }
1523
- .side-panel-card-git .git-status-dirty {
1524
- color: var(--warning, #fbbf24);
1525
- }
1526
-
1527
- @media (max-width: 640px) {
1528
- #panel-toggle {
1529
- display: flex;
1530
- }
1531
- #tab-list {
1532
- display: none;
1533
- }
1534
-
1535
- #back-btn {
1536
- display: none;
1537
- }
1538
- #theme-wrap {
1539
- display: none;
1540
- }
1541
- #stop-btn {
1542
- padding: 0 8px;
1543
- }
1544
- #session-name {
1545
- max-width: 30vw;
1546
- }
1547
- #status-text {
1548
- display: none;
1549
- }
1550
- #tab-new-btn {
1551
- font-size: 13px;
1552
- padding: 0 8px;
1553
- width: auto;
1554
- }
1555
- }
1556
-
1557
- /* ===== Command Palette / Tool Panel ===== */
1558
- .palette-backdrop {
1559
- position: fixed;
1560
- inset: 0;
1561
- background: rgba(0, 0, 0, 0.4);
1562
- z-index: 250;
1563
- opacity: 0;
1564
- pointer-events: none;
1565
- transition: opacity 0.3s;
1566
- }
1567
- .palette-backdrop.open {
1568
- opacity: 1;
1569
- pointer-events: auto;
1570
- }
1571
- .palette-panel {
1572
- position: fixed;
1573
- top: 0;
1574
- right: 0;
1575
- width: 280px;
1576
- max-width: 85vw;
1577
- height: 100%;
1578
- background: var(--surface);
1579
- border-left: 1px solid var(--border);
1580
- z-index: 260;
1581
- transform: translateX(100%);
1582
- transition: transform 0.3s ease;
1583
- display: flex;
1584
- flex-direction: column;
1585
- overflow-y: auto;
1586
- -webkit-overflow-scrolling: touch;
1587
- }
1588
- .palette-panel.open {
1589
- transform: translateX(0);
1590
- }
1591
- .palette-header {
1592
- display: flex;
1593
- align-items: center;
1594
- justify-content: space-between;
1595
- padding: 14px 16px;
1596
- border-bottom: 1px solid var(--border);
1597
- font-weight: 600;
1598
- font-size: 15px;
1599
- color: var(--text);
1600
- }
1601
- .palette-close {
1602
- background: none;
1603
- border: none;
1604
- color: var(--text-secondary);
1605
- font-size: 18px;
1606
- cursor: pointer;
1607
- padding: 4px 8px;
1608
- border-radius: 6px;
1609
- }
1610
- .palette-close:hover {
1611
- background: var(--hover-bg, rgba(255, 255, 255, 0.08));
1612
- color: var(--text);
1613
- }
1614
- .palette-body {
1615
- padding: 8px 0;
1616
- flex: 1;
1617
- }
1618
- .palette-category {
1619
- padding: 8px 16px 4px;
1620
- font-size: 11px;
1621
- font-weight: 600;
1622
- text-transform: uppercase;
1623
- letter-spacing: 0.5px;
1624
- color: var(--text-muted, var(--text-secondary));
1625
- }
1626
- .palette-action {
1627
- display: flex;
1628
- align-items: center;
1629
- gap: 10px;
1630
- width: 100%;
1631
- padding: 10px 16px;
1632
- background: none;
1633
- border: none;
1634
- color: var(--text);
1635
- font-size: 13px;
1636
- cursor: pointer;
1637
- text-align: left;
1638
- transition: background 0.15s;
1639
- }
1640
- .palette-action:hover {
1641
- background: rgba(255, 255, 255, 0.06);
1642
- }
1643
- .palette-action:active {
1644
- background: rgba(255, 255, 255, 0.1);
1645
- }
1646
- [data-theme='light'] .palette-action:hover,
1647
- [data-theme='solarized-light'] .palette-action:hover {
1648
- background: rgba(0, 0, 0, 0.06);
1649
- }
1650
- [data-theme='light'] .palette-action:active,
1651
- [data-theme='solarized-light'] .palette-action:active {
1652
- background: rgba(0, 0, 0, 0.1);
1653
- }
1654
- .palette-action-icon {
1655
- width: 20px;
1656
- height: 20px;
1657
- display: flex;
1658
- align-items: center;
1659
- justify-content: center;
1660
- flex-shrink: 0;
1661
- color: var(--text-secondary);
1662
- }
1663
- .palette-action-icon svg {
1664
- width: 16px;
1665
- }
1666
- /* ===== Theme Sub-Panel ===== */
1667
- .theme-subpanel {
1668
- display: none;
1669
- position: fixed;
1670
- top: 50%;
1671
- left: 50%;
1672
- transform: translate(-50%, -50%);
1673
- width: 240px;
1674
- max-height: 70vh;
1675
- background: var(--surface);
1676
- border: 1px solid var(--border);
1677
- border-radius: 12px;
1678
- z-index: 270;
1679
- overflow-y: auto;
1680
- -webkit-overflow-scrolling: touch;
1681
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
1682
- }
1683
- .theme-subpanel.open {
1684
- display: block;
1685
- }
1686
- .theme-subpanel-header {
1687
- display: flex;
1688
- align-items: center;
1689
- justify-content: space-between;
1690
- padding: 12px 14px;
1691
- border-bottom: 1px solid var(--border);
1692
- font-weight: 600;
1693
- font-size: 13px;
1694
- color: var(--text);
1695
- }
1696
- .theme-subpanel-close {
1697
- background: none;
1698
- border: none;
1699
- color: var(--text-secondary);
1700
- font-size: 16px;
1701
- cursor: pointer;
1702
- padding: 2px 6px;
1703
- border-radius: 6px;
1704
- }
1705
- .theme-subpanel-close:hover {
1706
- background: var(--hover-bg, rgba(255, 255, 255, 0.08));
1707
- color: var(--text);
1708
- }
1709
- .theme-subpanel-list {
1710
- padding: 6px 0;
1711
- }
1712
- .theme-subpanel-item {
1713
- display: flex;
1714
- align-items: center;
1715
- gap: 10px;
1716
- width: 100%;
1717
- padding: 9px 14px;
1718
- background: none;
1719
- border: none;
1720
- color: var(--text);
1721
- font-size: 13px;
1722
- cursor: pointer;
1723
- text-align: left;
1724
- transition: background 0.15s;
1725
- }
1726
- .theme-subpanel-item:hover {
1727
- background: rgba(255, 255, 255, 0.06);
1728
- }
1729
- [data-theme='light'] .theme-subpanel-item:hover,
1730
- [data-theme='solarized-light'] .theme-subpanel-item:hover {
1731
- background: rgba(0, 0, 0, 0.06);
1732
- }
1733
- .theme-subpanel-item.active {
1734
- color: var(--accent);
1735
- }
1736
- .theme-subpanel-swatch {
1737
- width: 14px;
1738
- height: 14px;
1739
- border-radius: 50%;
1740
- flex-shrink: 0;
1741
- border: 1px solid rgba(128, 128, 128, 0.3);
1742
- }
1743
- height: 16px;
1744
- }
1745
- </style>
1746
- </head>
1747
- <body>
1748
- <!-- Sessions Side Panel -->
1749
- <div id="side-panel-backdrop"></div>
1750
- <div id="side-panel">
1751
- <div class="side-panel-header">
1752
- <div class="side-panel-brand">
1753
- <svg
1754
- width="20"
1755
- height="20"
1756
- viewBox="0 0 24 24"
1757
- fill="none"
1758
- stroke="currentColor"
1759
- stroke-width="2"
1760
- stroke-linecap="round"
1761
- stroke-linejoin="round"
1762
- >
1763
- <polyline points="4 17 10 11 4 5"></polyline>
1764
- <line x1="12" y1="19" x2="20" y2="19"></line>
1765
- </svg>
1766
- TermBeam
1767
- </div>
1768
- <div class="side-panel-version" id="side-panel-version"></div>
1769
- <button class="side-panel-close" id="side-panel-close" title="Close">×</button>
1770
- </div>
1771
- <div class="side-panel-section-title">Sessions</div>
1772
- <div class="side-panel-list" id="side-panel-list"></div>
1773
- <div style="padding: 8px; border-top: 1px solid var(--border)">
1774
- <button
1775
- id="side-panel-new-btn"
1776
- style="
1777
- width: 100%;
1778
- padding: 10px;
1779
- border: 1px dashed var(--border);
1780
- border-radius: 8px;
1781
- background: none;
1782
- color: var(--accent);
1783
- font-size: 14px;
1784
- font-weight: 600;
1785
- cursor: pointer;
1786
- display: flex;
1787
- align-items: center;
1788
- justify-content: center;
1789
- gap: 6px;
1790
- transition: background 0.15s;
1791
- "
1792
- >
1793
- + New Session
1794
- </button>
1795
- </div>
1796
- </div>
1797
-
1798
- <!-- Top Bar (unified) -->
1799
- <div id="top-bar">
1800
- <div class="left">
1801
- <button id="panel-toggle" title="Sessions">
1802
- <svg
1803
- width="18"
1804
- height="18"
1805
- viewBox="0 0 24 24"
1806
- fill="none"
1807
- stroke="currentColor"
1808
- stroke-width="2"
1809
- stroke-linecap="round"
1810
- stroke-linejoin="round"
1811
- >
1812
- <line x1="3" y1="6" x2="21" y2="6" />
1813
- <line x1="3" y1="12" x2="21" y2="12" />
1814
- <line x1="3" y1="18" x2="21" y2="18" />
1815
- </svg>
1816
- </button>
1817
- <button
1818
- class="bar-btn"
1819
- id="back-btn"
1820
- onclick="location.href = '/'"
1821
- title="Back to sessions"
1822
- >
1823
- <svg
1824
- width="18"
1825
- height="18"
1826
- viewBox="0 0 24 24"
1827
- fill="none"
1828
- stroke="currentColor"
1829
- stroke-width="2"
1830
- stroke-linecap="round"
1831
- stroke-linejoin="round"
1832
- >
1833
- <polyline points="15 18 9 12 15 6" />
1834
- </svg>
1835
- </button>
1836
- <span id="status-dot"></span>
1837
- <span id="session-name">…</span>
1838
- <span id="status-text">Connecting…</span>
1839
- </div>
1840
- <div id="tab-list"></div>
1841
- <div class="right">
1842
- <button class="tab-bar-btn" id="tab-new-btn" title="New session">
1843
- <svg
1844
- width="14"
1845
- height="14"
1846
- viewBox="0 0 24 24"
1847
- fill="none"
1848
- stroke="currentColor"
1849
- stroke-width="2.5"
1850
- stroke-linecap="round"
1851
- >
1852
- <line x1="12" y1="5" x2="12" y2="19" />
1853
- <line x1="5" y1="12" x2="19" y2="12" /></svg
1854
- ><span class="new-btn-label">New</span>
1855
- </button>
1856
- <div class="theme-wrap" id="theme-wrap">
1857
- <button class="bar-btn" id="theme-toggle" title="Switch theme">
1858
- <svg
1859
- width="16"
1860
- height="16"
1861
- viewBox="0 0 24 24"
1862
- fill="none"
1863
- stroke="currentColor"
1864
- stroke-width="2"
1865
- stroke-linecap="round"
1866
- stroke-linejoin="round"
1867
- >
1868
- <circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
1869
- <circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
1870
- <circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
1871
- <circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
1872
- <path
1873
- d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"
1874
- />
1875
- </svg>
1876
- </button>
1877
- <div class="theme-picker" id="theme-picker">
1878
- <div class="theme-option" data-theme-option="dark">
1879
- <span class="theme-swatch" style="background: #1e1e1e"></span>Dark
1880
- </div>
1881
- <div class="theme-option" data-theme-option="light">
1882
- <span class="theme-swatch" style="background: #ffffff"></span>Light
1883
- </div>
1884
- <div class="theme-option" data-theme-option="monokai">
1885
- <span class="theme-swatch" style="background: #272822"></span>Monokai
1886
- </div>
1887
- <div class="theme-option" data-theme-option="solarized-dark">
1888
- <span class="theme-swatch" style="background: #002b36"></span>Solarized Dark
1889
- </div>
1890
- <div class="theme-option" data-theme-option="solarized-light">
1891
- <span class="theme-swatch" style="background: #fdf6e3"></span>Solarized Light
1892
- </div>
1893
- <div class="theme-option" data-theme-option="nord">
1894
- <span class="theme-swatch" style="background: #2e3440"></span>Nord
1895
- </div>
1896
- <div class="theme-option" data-theme-option="dracula">
1897
- <span class="theme-swatch" style="background: #282a36"></span>Dracula
1898
- </div>
1899
- <div class="theme-option" data-theme-option="github-dark">
1900
- <span class="theme-swatch" style="background: #0d1117"></span>GitHub Dark
1901
- </div>
1902
- <div class="theme-option" data-theme-option="one-dark">
1903
- <span class="theme-swatch" style="background: #282c34"></span>One Dark
1904
- </div>
1905
- <div class="theme-option" data-theme-option="catppuccin">
1906
- <span class="theme-swatch" style="background: #1e1e2e"></span>Catppuccin
1907
- </div>
1908
- <div class="theme-option" data-theme-option="gruvbox">
1909
- <span class="theme-swatch" style="background: #282828"></span>Gruvbox
1910
- </div>
1911
- <div class="theme-option" data-theme-option="night-owl">
1912
- <span class="theme-swatch" style="background: #011627"></span>Night Owl
1913
- </div>
1914
- </div>
1915
- </div>
1916
- <button class="bar-btn" id="palette-trigger" title="Tools (Ctrl+K)">
1917
- <svg
1918
- width="16"
1919
- height="16"
1920
- viewBox="0 0 24 24"
1921
- fill="none"
1922
- stroke="currentColor"
1923
- stroke-width="2"
1924
- stroke-linecap="round"
1925
- stroke-linejoin="round"
1926
- >
1927
- <rect x="3" y="3" width="7" height="7" rx="1" />
1928
- <rect x="14" y="3" width="7" height="7" rx="1" />
1929
- <rect x="3" y="14" width="7" height="7" rx="1" />
1930
- <rect x="14" y="14" width="7" height="7" rx="1" />
1931
- </svg>
1932
- </button>
1933
- <button id="stop-btn" title="Stop session">
1934
- <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
1935
- <rect x="6" y="6" width="12" height="12" rx="2" /></svg
1936
- >Stop
1937
- </button>
1938
- </div>
1939
- </div>
1940
-
1941
- <!-- Tab Preview Popover -->
1942
- <div id="tab-preview">
1943
- <div class="preview-header">
1944
- <span class="preview-dot" id="preview-dot"></span>
1945
- <span id="preview-name"></span>
1946
- </div>
1947
- <div class="preview-body" id="preview-body"></div>
1948
- </div>
1949
-
1950
- <!-- Terminals Wrapper (panes created dynamically) -->
1951
- <div id="terminals-wrapper">
1952
- <div class="search-bar" id="search-bar">
1953
- <input type="text" id="search-input" placeholder="Search…" autocomplete="off" />
1954
- <span class="search-count" id="search-count"></span>
1955
- <button id="search-regex" title="Regex">.*</button>
1956
- <button id="search-prev" title="Previous">▲</button>
1957
- <button id="search-next" title="Next">▼</button>
1958
- <button id="search-close" title="Close">✕</button>
1959
- </div>
1960
- </div>
1961
-
1962
- <div id="copy-toast">Copied!</div>
1963
-
1964
- <div id="key-bar">
1965
- <div class="key-row">
1966
- <button class="key-btn special" data-key="&#x1b;" title="Escape">Esc</button>
1967
- <button class="key-btn special" id="select-btn" title="Copy text">Copy</button>
1968
- <button class="key-btn special" id="paste-btn" title="Paste from clipboard">Paste</button>
1969
- <button class="key-btn special" data-key="&#x1b;OH" title="Home">Home</button>
1970
- <button class="key-btn special" data-key="&#x1b;OF" title="End">End</button>
1971
- <button class="key-btn icon-btn" data-key="&#x1b;[A" title="Up">↑</button>
1972
- <button class="key-btn icon-btn key-enter" data-key="enter" title="Enter / Return">
1973
-
1974
- </button>
1975
- </div>
1976
- <div class="key-row">
1977
- <button class="key-btn modifier" id="ctrl-btn" title="Toggle Ctrl modifier">Ctrl</button>
1978
- <button class="key-btn modifier" id="shift-btn" title="Toggle Shift modifier">Shift</button>
1979
- <button class="key-btn special" data-key="tab" title="Autocomplete">Tab</button>
1980
- <button class="key-btn special key-danger" data-key="&#x03;" title="Interrupt process">
1981
- ^C
1982
- </button>
1983
- <button class="key-btn icon-btn" data-key="&#x1b;[D" title="Left">←</button>
1984
- <button class="key-btn icon-btn" data-key="&#x1b;[B" title="Down">↓</button>
1985
- <button class="key-btn icon-btn" data-key="&#x1b;[C" title="Right">→</button>
1986
- </div>
1987
- </div>
1988
- <input type="file" id="upload-input" multiple hidden />
1989
-
1990
- <!-- Upload Confirm Modal -->
1991
- <div class="modal-overlay" id="upload-modal">
1992
- <div class="modal">
1993
- <h2>Upload Files</h2>
1994
- <div
1995
- id="upload-file-list"
1996
- style="
1997
- margin-bottom: 12px;
1998
- font-size: 13px;
1999
- color: var(--text-secondary);
2000
- max-height: 120px;
2001
- overflow-y: auto;
2002
- "
2003
- ></div>
2004
- <label for="upload-dir">Destination directory</label>
2005
- <div class="cwd-picker">
2006
- <input type="text" id="upload-dir" placeholder="Session working directory" />
2007
- <button
2008
- type="button"
2009
- class="cwd-browse-btn"
2010
- id="upload-browse-btn"
2011
- title="Browse folders"
2012
- >
2013
- <svg
2014
- width="18"
2015
- height="18"
2016
- viewBox="0 0 24 24"
2017
- fill="none"
2018
- stroke="currentColor"
2019
- stroke-width="2"
2020
- stroke-linecap="round"
2021
- stroke-linejoin="round"
2022
- >
2023
- <path
2024
- d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
2025
- />
2026
- </svg>
2027
- </button>
2028
- </div>
2029
- <div class="modal-actions">
2030
- <button class="btn-cancel" id="upload-cancel">Cancel</button>
2031
- <button class="btn-create" id="upload-confirm">Upload</button>
2032
- </div>
2033
- </div>
2034
- </div>
2035
-
2036
- <div id="reconnect-overlay">
2037
- <div class="msg">Session disconnected</div>
2038
- <div class="overlay-actions">
2039
- <button id="back-to-sessions" onclick="location.href = '/'">Sessions</button>
2040
- <button id="reconnect-btn">Reconnect</button>
2041
- </div>
2042
- </div>
2043
-
2044
- <div id="paste-overlay">
2045
- <label for="paste-input">Paste your text below</label>
2046
- <textarea id="paste-input" placeholder="Long-press here and paste…"></textarea>
2047
- <div class="overlay-actions">
2048
- <button id="paste-cancel" style="background: rgba(255, 255, 255, 0.15); color: #fff">
2049
- Cancel
2050
- </button>
2051
- <button id="paste-send" style="background: var(--accent); color: #fff">Send</button>
2052
- </div>
2053
- </div>
2054
-
2055
- <div id="select-overlay">
2056
- <div class="select-overlay-header">
2057
- <span id="select-title">Copy Text</span>
2058
- <div style="display: flex; gap: 8px">
2059
- <button id="select-copy">Copy</button>
2060
- <button id="select-close">Done</button>
2061
- </div>
2062
- </div>
2063
- <button
2064
- id="select-load-more"
2065
- style="
2066
- display: none;
2067
- width: 100%;
2068
- padding: 8px;
2069
- background: var(--surface);
2070
- color: var(--accent);
2071
- border: 1px solid var(--border);
2072
- border-radius: 6px;
2073
- font-size: 13px;
2074
- cursor: pointer;
2075
- "
2076
- >
2077
- ▲ Load more
2078
- </button>
2079
- <pre id="select-content"></pre>
2080
- </div>
2081
-
2082
- <!-- New Session Modal -->
2083
- <div class="modal-overlay" id="new-session-modal">
2084
- <div class="modal">
2085
- <h2>New Session</h2>
2086
- <label for="ns-name">Name</label>
2087
- <input type="text" id="ns-name" placeholder="My Session" />
2088
- <label for="ns-shell">Shell</label>
2089
- <select id="ns-shell">
2090
- <option value="">Loading shells…</option>
2091
- </select>
2092
- <label for="ns-cmd"
2093
- >Initial Command
2094
- <span style="color: var(--text-muted); font-weight: normal">(optional)</span></label
2095
- >
2096
- <input type="text" id="ns-cmd" placeholder="e.g. htop, vim" />
2097
- <label>Color</label>
2098
- <div class="color-picker" id="ns-color-picker">
2099
- <button
2100
- type="button"
2101
- class="color-swatch selected"
2102
- data-color="#4a9eff"
2103
- style="background: #4a9eff"
2104
- title="Blue"
2105
- ></button>
2106
- <button
2107
- type="button"
2108
- class="color-swatch"
2109
- data-color="#4ade80"
2110
- style="background: #4ade80"
2111
- title="Green"
2112
- ></button>
2113
- <button
2114
- type="button"
2115
- class="color-swatch"
2116
- data-color="#fbbf24"
2117
- style="background: #fbbf24"
2118
- title="Amber"
2119
- ></button>
2120
- <button
2121
- type="button"
2122
- class="color-swatch"
2123
- data-color="#c084fc"
2124
- style="background: #c084fc"
2125
- title="Purple"
2126
- ></button>
2127
- <button
2128
- type="button"
2129
- class="color-swatch"
2130
- data-color="#f87171"
2131
- style="background: #f87171"
2132
- title="Red"
2133
- ></button>
2134
- <button
2135
- type="button"
2136
- class="color-swatch"
2137
- data-color="#22d3ee"
2138
- style="background: #22d3ee"
2139
- title="Cyan"
2140
- ></button>
2141
- <button
2142
- type="button"
2143
- class="color-swatch"
2144
- data-color="#fb923c"
2145
- style="background: #fb923c"
2146
- title="Orange"
2147
- ></button>
2148
- <button
2149
- type="button"
2150
- class="color-swatch"
2151
- data-color="#f472b6"
2152
- style="background: #f472b6"
2153
- title="Pink"
2154
- ></button>
2155
- </div>
2156
- <label for="ns-cwd"
2157
- >Working Directory
2158
- <span style="color: var(--text-muted); font-weight: normal">(optional)</span></label
2159
- >
2160
- <div class="cwd-picker">
2161
- <input type="text" id="ns-cwd" placeholder="Uses server default" />
2162
- <button type="button" class="cwd-browse-btn" id="ns-browse-btn" title="Browse folders">
2163
- <svg
2164
- width="18"
2165
- height="18"
2166
- viewBox="0 0 24 24"
2167
- fill="none"
2168
- stroke="currentColor"
2169
- stroke-width="2"
2170
- stroke-linecap="round"
2171
- stroke-linejoin="round"
2172
- >
2173
- <path
2174
- d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
2175
- />
2176
- </svg>
2177
- </button>
2178
- </div>
2179
- <div class="modal-actions">
2180
- <button class="btn-cancel" id="ns-cancel">Cancel</button>
2181
- <button class="btn-create" id="ns-create">Create</button>
2182
- </div>
2183
- </div>
2184
- </div>
2185
-
2186
- <!-- Preview Port Modal -->
2187
- <div class="modal-overlay" id="preview-modal">
2188
- <div class="modal">
2189
- <h2>🌐 Preview Local Port</h2>
2190
- <p
2191
- style="
2192
- color: var(--text-secondary);
2193
- font-size: 13px;
2194
- margin: -8px 0 16px;
2195
- line-height: 1.4;
2196
- "
2197
- >
2198
- Open a local server running on your machine in a new tab — works through the tunnel.
2199
- </p>
2200
- <label for="preview-port-input">Port</label>
2201
- <div style="position: relative">
2202
- <input
2203
- type="number"
2204
- id="preview-port-input"
2205
- placeholder="e.g. 3000"
2206
- min="1"
2207
- max="65535"
2208
- />
2209
- <span
2210
- id="preview-detect-status"
2211
- style="
2212
- position: absolute;
2213
- right: 12px;
2214
- top: 50%;
2215
- transform: translateY(-50%);
2216
- font-size: 12px;
2217
- color: var(--text-secondary);
2218
- "
2219
- ></span>
2220
- </div>
2221
- <div
2222
- id="preview-hint"
2223
- style="
2224
- font-size: 12px;
2225
- color: var(--success);
2226
- margin-top: 6px;
2227
- display: none;
2228
- align-items: center;
2229
- gap: 4px;
2230
- "
2231
- >
2232
- <span>✓</span> <span id="preview-hint-text"></span>
2233
- </div>
2234
- <div class="modal-actions">
2235
- <button class="btn-cancel" id="preview-cancel">Cancel</button>
2236
- <button class="btn-create" id="preview-open">Open Preview ↗</button>
2237
- </div>
2238
- </div>
2239
- </div>
2240
-
2241
- <!-- Folder Browser -->
2242
- <div class="browser-overlay" id="ns-browser-overlay">
2243
- <div class="browser-sheet">
2244
- <div class="browser-header">
2245
- <h3>
2246
- <svg
2247
- width="18"
2248
- height="18"
2249
- viewBox="0 0 24 24"
2250
- fill="none"
2251
- stroke="currentColor"
2252
- stroke-width="2"
2253
- stroke-linecap="round"
2254
- stroke-linejoin="round"
2255
- style="vertical-align: -3px; margin-right: 6px"
2256
- >
2257
- <path
2258
- d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
2259
- /></svg
2260
- >Choose Folder
2261
- </h3>
2262
- <button class="browser-close" id="ns-browser-close">×</button>
2263
- </div>
2264
- <div class="browser-breadcrumb" id="ns-browser-breadcrumb"></div>
2265
- <div class="browser-list" id="ns-browser-list"></div>
2266
- <div class="browser-footer">
2267
- <div class="browser-current-path" id="ns-browser-path">/</div>
2268
- <button class="browser-select-btn" id="ns-browser-select">Select This Folder</button>
2269
- </div>
2270
- </div>
2271
- </div>
2272
-
2273
- <!-- Command Palette / Tool Panel -->
2274
- <div id="palette-backdrop" class="palette-backdrop"></div>
2275
- <div id="palette-panel" class="palette-panel">
2276
- <div class="palette-header">
2277
- <span>Tools</span>
2278
- <button class="palette-close" id="palette-close">✕</button>
2279
- </div>
2280
- <div class="palette-body" id="palette-body"></div>
2281
- </div>
2282
-
2283
- <!-- Theme Sub-Panel -->
2284
- <div class="theme-subpanel" id="theme-subpanel">
2285
- <div class="theme-subpanel-header">
2286
- <span>Theme</span>
2287
- <button class="theme-subpanel-close" id="theme-subpanel-close">✕</button>
2288
- </div>
2289
- <div class="theme-subpanel-list" id="theme-subpanel-list"></div>
2290
- </div>
2291
-
2292
- <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
2293
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
2294
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
2295
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
2296
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-canvas@0.7.0/lib/addon-canvas.min.js"></script>
2297
- <script src="/js/shared.js"></script>
2298
- <script src="/js/themes.js"></script>
2299
- <script src="/js/terminal-themes.js"></script>
2300
- <script src="/js/keybar.js"></script>
2301
- <script src="/js/search.js"></script>
2302
- <script>
2303
- // ===== Constants =====
2304
- const SESSION_COLORS = [
2305
- '#4a9eff',
2306
- '#4ade80',
2307
- '#fbbf24',
2308
- '#c084fc',
2309
- '#f87171',
2310
- '#22d3ee',
2311
- '#fb923c',
2312
- '#f472b6',
2313
- ];
2314
-
2315
- // ===== State =====
2316
- const managed = new Map(); // sessionId -> ManagedSession
2317
- let activeId = null;
2318
- let splitMode = false;
2319
-
2320
- let splitSecondId = null;
2321
-
2322
- // ===== Notification State =====
2323
- let notificationsEnabled = localStorage.getItem('termbeam-notifications') !== 'false';
2324
-
2325
- function updateNotifyToggle() {
2326
- // notify-toggle button removed from top bar; function kept for palette use
2327
- }
2328
-
2329
- function sendCommandNotification(sessionName) {
2330
- if (Notification.permission !== 'granted') return;
2331
- try {
2332
- new Notification('Command finished in ' + sessionName, {
2333
- icon: '/icons/icon-192.png',
2334
- tag: 'termbeam-cmd',
2335
- });
2336
- } catch {}
2337
- }
2338
-
2339
- function resetSilenceTimer(ms) {
2340
- if (ms.silenceTimer) clearTimeout(ms.silenceTimer);
2341
- ms.silenceTimer = setTimeout(() => {
2342
- ms.silenceTimer = null;
2343
- if (document.hidden && notificationsEnabled) {
2344
- sendCommandNotification(ms.name || ms.id);
2345
- }
2346
- }, 3000);
2347
- }
2348
-
2349
- // Clipboard copy fallback for non-secure contexts (HTTP over LAN)
2350
- function copyFallback(text) {
2351
- const ta = document.createElement('textarea');
2352
- ta.value = text;
2353
- ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
2354
- document.body.appendChild(ta);
2355
- ta.select();
2356
- try {
2357
- document.execCommand('copy');
2358
- showToast('Copied!');
2359
- } catch {}
2360
- document.body.removeChild(ta);
2361
- }
2362
-
2363
- // Hook into shared theme system to update xterm terminal themes
2364
- window.onThemeApplied = function (theme) {
2365
- const termTheme = TERM_THEMES[theme] || darkTermTheme;
2366
- for (const [, ms] of managed) {
2367
- ms.term.options.theme = termTheme;
2368
- }
2369
- };
2370
-
2371
- // ===== DOM Refs =====
2372
- const statusDot = document.getElementById('status-dot');
2373
- const statusText = document.getElementById('status-text');
2374
- const sessionNameEl = document.getElementById('session-name');
2375
- const reconnectOverlay = document.getElementById('reconnect-overlay');
2376
- const tabListEl = document.getElementById('tab-list');
2377
- const terminalsWrapper = document.getElementById('terminals-wrapper');
2378
-
2379
- // ===== Font Loading (non-blocking) =====
2380
- // Hook for font-load refit (set inside init, called by font loader)
2381
- let onFontReady = null;
2382
-
2383
- const nerdFont = new FontFace(
2384
- 'NerdFont',
2385
- "url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')",
2386
- );
2387
- nerdFont
2388
- .load()
2389
- .then((font) => {
2390
- document.fonts.add(font);
2391
- })
2392
- .catch(() => {
2393
- console.warn('Nerd Font failed to load, using fallback');
2394
- })
2395
- .finally(() => {
2396
- // Refit all terminals once font metrics are stable
2397
- if (onFontReady) onFontReady();
2398
- });
2399
-
2400
- // Start immediately — don't wait for font
2401
- init();
2402
-
2403
- // ===== Helpers =====
2404
- function escAttr(str) {
2405
- return String(str)
2406
- .replace(/&/g, '&amp;')
2407
- .replace(/"/g, '&quot;')
2408
- .replace(/'/g, '&#39;')
2409
- .replace(/</g, '&lt;')
2410
- .replace(/>/g, '&gt;');
2411
- }
2412
- function safeColor(c) {
2413
- return /^(#[0-9a-fA-F]{3,8}|var\(--[a-z-]+\)|[a-z]+)$/.test(c) ? c : 'var(--text-muted)';
2414
- }
2415
-
2416
- function showToast(msg) {
2417
- const toast = document.getElementById('copy-toast');
2418
- toast.textContent = msg;
2419
- toast.classList.add('visible');
2420
- clearTimeout(toast._timer);
2421
- toast._timer = setTimeout(() => toast.classList.remove('visible'), 1500);
2422
- }
2423
-
2424
- function getActivityLabel(ts) {
2425
- if (!ts) return '';
2426
- const diff = (Date.now() - ts) / 1000;
2427
- if (diff < 5) return '';
2428
- if (diff < 60) return Math.floor(diff) + 's';
2429
- if (diff < 3600) return Math.floor(diff / 60) + 'm';
2430
- return Math.floor(diff / 3600) + 'h';
2431
- }
2432
-
2433
- // ===== Zoom =====
2434
- const MIN_FONT = 2,
2435
- MAX_FONT = 32;
2436
- function defaultFontSize() {
2437
- const w = window.innerWidth;
2438
- if (w <= 480) return 12;
2439
- if (w <= 768) return 13;
2440
- if (w <= 1280) return 14;
2441
- return 15;
2442
- }
2443
- let fontSize = parseInt(
2444
- localStorage.getItem('termbeam-fontsize') || String(defaultFontSize()),
2445
- 10,
2446
- );
2447
-
2448
- function applyZoom(size) {
2449
- fontSize = Math.max(MIN_FONT, Math.min(MAX_FONT, size));
2450
- localStorage.setItem('termbeam-fontsize', fontSize);
2451
- for (const [, ms] of managed) {
2452
- ms.term.options.fontSize = fontSize;
2453
- if (ms.container.classList.contains('visible')) {
2454
- ms.fitAddon.fit();
2455
- sendResize(ms);
2456
- }
2457
- }
2458
- }
2459
-
2460
- function sendResize(ms) {
2461
- if (ms.ws && ms.ws.readyState === 1) {
2462
- const dims = ms.fitAddon.proposeDimensions();
2463
- if (dims && (dims.cols !== ms._lastCols || dims.rows !== ms._lastRows)) {
2464
- ms._lastCols = dims.cols;
2465
- ms._lastRows = dims.rows;
2466
- ms.ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
2467
- }
2468
- }
2469
- }
2470
-
2471
- // ===== Tab Order (persisted in localStorage) =====
2472
- function getTabOrder() {
2473
- let order = JSON.parse(localStorage.getItem('termbeam-tab-order') || '[]');
2474
- const allIds = [...managed.keys()];
2475
- for (const id of allIds) {
2476
- if (!order.includes(id)) order.push(id);
2477
- }
2478
- order = order.filter((id) => managed.has(id));
2479
- return order;
2480
- }
2481
- function saveTabOrder(order) {
2482
- localStorage.setItem('termbeam-tab-order', JSON.stringify(order));
2483
- }
2484
-
2485
- // ===== Init =====
2486
- async function init() {
2487
- let sessionList = [];
2488
- try {
2489
- const res = await fetch('/api/sessions');
2490
- if (!res.ok) throw new Error(`${res.status}`);
2491
- sessionList = await res.json();
2492
- } catch (err) {
2493
- console.error('Failed to load sessions:', err);
2494
- }
2495
- const initialId = new URLSearchParams(location.search).get('id');
2496
-
2497
- for (const s of sessionList) addSession(s);
2498
-
2499
- const startId =
2500
- initialId && managed.has(initialId)
2501
- ? initialId
2502
- : sessionList.length > 0
2503
- ? sessionList[0].id
2504
- : null;
2505
-
2506
- if (startId) activateSession(startId);
2507
- else {
2508
- window.location.replace('/');
2509
- return;
2510
- }
2511
-
2512
- renderTabs();
2513
- setupKeyBar();
2514
- setupPaste();
2515
- setupImagePaste();
2516
- setupUpload();
2517
- setupSelectMode();
2518
- setupNewSessionModal();
2519
- setupPreviewModal();
2520
- loadShellsForModal();
2521
- startPolling();
2522
- setTimeout(requestWakeLock, 0);
2523
-
2524
- // Pinch-to-zoom
2525
- (function setupPinchZoom() {
2526
- const wrapper = document.getElementById('terminals-wrapper');
2527
- let pinchStartDist = 0;
2528
- let pinchStartFont = 0;
2529
- let pinchActive = false;
2530
- let pinchZoomTimer = null;
2531
-
2532
- function touchDist(t) {
2533
- const dx = t[0].clientX - t[1].clientX;
2534
- const dy = t[0].clientY - t[1].clientY;
2535
- return Math.sqrt(dx * dx + dy * dy);
2536
- }
2537
-
2538
- wrapper.addEventListener(
2539
- 'touchstart',
2540
- function (e) {
2541
- if (e.touches.length === 2) {
2542
- pinchActive = true;
2543
- pinchStartDist = touchDist(e.touches);
2544
- pinchStartFont = fontSize;
2545
- wrapper.style.touchAction = 'none';
2546
- }
2547
- },
2548
- { passive: true },
2549
- );
2550
-
2551
- wrapper.addEventListener(
2552
- 'touchmove',
2553
- function (e) {
2554
- if (!pinchActive || e.touches.length !== 2) return;
2555
- e.preventDefault();
2556
- const dist = touchDist(e.touches);
2557
- const scale = dist / pinchStartDist;
2558
- const newSize = Math.round(pinchStartFont * scale);
2559
- if (newSize !== fontSize) {
2560
- if (pinchZoomTimer) clearTimeout(pinchZoomTimer);
2561
- pinchZoomTimer = setTimeout(function () {
2562
- applyZoom(newSize);
2563
- }, 50);
2564
- }
2565
- },
2566
- { passive: false },
2567
- );
2568
-
2569
- wrapper.addEventListener('touchend', function () {
2570
- pinchActive = false;
2571
- wrapper.style.touchAction = '';
2572
- });
2573
-
2574
- wrapper.addEventListener('touchcancel', function () {
2575
- pinchActive = false;
2576
- wrapper.style.touchAction = '';
2577
- });
2578
- })();
2579
-
2580
- // Resize
2581
- function doResize() {
2582
- for (const [, ms] of managed) {
2583
- if (ms.container.classList.contains('visible')) {
2584
- ms.fitAddon.fit();
2585
- sendResize(ms);
2586
- }
2587
- }
2588
- }
2589
- onFontReady = doResize;
2590
- window.addEventListener('resize', doResize);
2591
- screen.orientation?.addEventListener('change', () => setTimeout(doResize, 150));
2592
-
2593
- // ResizeObserver — catches container size changes that don't trigger window resize
2594
- if (typeof ResizeObserver !== 'undefined') {
2595
- let resizeTimer = null;
2596
- new ResizeObserver(() => {
2597
- clearTimeout(resizeTimer);
2598
- resizeTimer = setTimeout(doResize, 100);
2599
- }).observe(terminalsWrapper);
2600
- }
2601
-
2602
- // Refit after all fonts finish loading (catches bold variant, system fonts)
2603
- document.fonts.ready.then(() => doResize());
2604
-
2605
- // Mobile soft keyboard
2606
- if (window.visualViewport) {
2607
- const keyBar = document.getElementById('key-bar');
2608
- let vpResizeTimer = null;
2609
- function resetScroll() {
2610
- window.scrollTo(0, 0);
2611
- document.documentElement.scrollTop = 0;
2612
- document.body.scrollTop = 0;
2613
- }
2614
- let wasKeyboardOpen = false;
2615
- function onViewportResize() {
2616
- const vv = window.visualViewport;
2617
- const keyboardHeight = window.innerHeight - vv.height;
2618
- const keyboardOpen = keyboardHeight > 50;
2619
- if (keyboardOpen) {
2620
- keyBar.style.bottom = keyboardHeight + 'px';
2621
- keyBar.style.height = '80px';
2622
- keyBar.style.paddingBottom = '0px';
2623
- terminalsWrapper.style.bottom = 80 + keyboardHeight + 'px';
2624
- } else {
2625
- keyBar.style.bottom = '0px';
2626
- keyBar.style.height = '';
2627
- keyBar.style.paddingBottom = '';
2628
- terminalsWrapper.style.bottom = '';
2629
- }
2630
- const keyboardJustClosed = wasKeyboardOpen && !keyboardOpen;
2631
- wasKeyboardOpen = keyboardOpen;
2632
- resetScroll();
2633
- clearTimeout(vpResizeTimer);
2634
- vpResizeTimer = setTimeout(() => {
2635
- resetScroll();
2636
- doResize();
2637
- // When keyboard closes, the terminal grows taller — scroll to
2638
- // bottom so the cursor stays visible instead of appearing offset.
2639
- if (keyboardJustClosed) {
2640
- for (const [, ms] of managed) {
2641
- if (ms.container.classList.contains('visible')) {
2642
- ms.term.scrollToBottom();
2643
- }
2644
- }
2645
- }
2646
- }, 150);
2647
- }
2648
- function onViewportScroll() {
2649
- resetScroll();
2650
- }
2651
- window.visualViewport.addEventListener('resize', onViewportResize);
2652
- window.visualViewport.addEventListener('scroll', onViewportScroll);
2653
- // Page should never scroll — catch any browser-initiated scroll
2654
- // (e.g. iOS scrolling to show focused xterm textarea behind keyboard)
2655
- window.addEventListener(
2656
- 'scroll',
2657
- () => {
2658
- if (window.scrollY !== 0) resetScroll();
2659
- },
2660
- { passive: true },
2661
- );
2662
- }
2663
-
2664
- // Wake Lock — helps keep mobile browser tab alive
2665
- let wakeLock = null;
2666
-
2667
- async function requestWakeLock() {
2668
- if (!('wakeLock' in navigator)) return;
2669
- try {
2670
- wakeLock = await navigator.wakeLock.request('screen');
2671
- wakeLock.addEventListener('release', () => {
2672
- wakeLock = null;
2673
- });
2674
- } catch {
2675
- // Wake Lock request failed (e.g. low battery, not visible)
2676
- }
2677
- }
2678
-
2679
- function releaseWakeLock() {
2680
- if (wakeLock) {
2681
- wakeLock.release().catch(() => {});
2682
- wakeLock = null;
2683
- }
2684
- }
2685
-
2686
- // Reconnect & refit when returning from idle / tab switch
2687
- document.addEventListener('visibilitychange', () => {
2688
- if (!document.hidden) {
2689
- if (activeId) {
2690
- clearUnreadIndicator();
2691
- const ms = managed.get(activeId);
2692
- if (ms) {
2693
- // Reconnect immediately if WebSocket died in background
2694
- if (!ms.exited && (!ms.ws || ms.ws.readyState !== WebSocket.OPEN)) {
2695
- if (ms.reconnectTimer) {
2696
- clearTimeout(ms.reconnectTimer);
2697
- ms.reconnectTimer = null;
2698
- }
2699
- ms.reconnectDelay = 3000;
2700
- connectSession(ms);
2701
- } else {
2702
- ms.term.scrollToBottom();
2703
- ms.fitAddon.fit();
2704
- sendResize(ms);
2705
- }
2706
- }
2707
- if (splitMode && splitSecondId) {
2708
- const ms2 = managed.get(splitSecondId);
2709
- if (ms2) {
2710
- if (!ms2.exited && (!ms2.ws || ms2.ws.readyState !== WebSocket.OPEN)) {
2711
- if (ms2.reconnectTimer) {
2712
- clearTimeout(ms2.reconnectTimer);
2713
- ms2.reconnectTimer = null;
2714
- }
2715
- ms2.reconnectDelay = 3000;
2716
- connectSession(ms2);
2717
- } else {
2718
- ms2.term.scrollToBottom();
2719
- ms2.fitAddon.fit();
2720
- sendResize(ms2);
2721
- }
2722
- }
2723
- }
2724
- }
2725
- requestWakeLock();
2726
- } else {
2727
- releaseWakeLock();
2728
- }
2729
- });
2730
-
2731
- // Reconnect button
2732
- document.getElementById('reconnect-btn').addEventListener('click', () => {
2733
- const ms = managed.get(activeId);
2734
- if (ms) {
2735
- if (ms.reconnectTimer) {
2736
- clearTimeout(ms.reconnectTimer);
2737
- ms.reconnectTimer = null;
2738
- }
2739
- ms.exited = false;
2740
- ms.reconnectDelay = 3000;
2741
- ms.term.clear();
2742
- connectSession(ms);
2743
- }
2744
- });
2745
-
2746
- // Stop button
2747
- document.getElementById('stop-btn').addEventListener('click', async () => {
2748
- if (!activeId) return;
2749
- if (!confirm('Stop this session? The process will be killed.')) return;
2750
- await removeSession(activeId);
2751
- });
2752
-
2753
- // Version
2754
- fetch('/api/version')
2755
- .then((r) => {
2756
- if (!r.ok) throw new Error(`${r.status}`);
2757
- return r.json();
2758
- })
2759
- .then((d) => {
2760
- window._termbeamVersion = 'v' + d.version;
2761
- document.getElementById('side-panel-version').textContent = 'v' + d.version;
2762
- })
2763
- .catch(() => {});
2764
- }
2765
-
2766
- // ===== Session Management =====
2767
- function addSession(data) {
2768
- if (managed.has(data.id)) return;
2769
- managed.set(data.id, null); // reserve slot to prevent race condition
2770
-
2771
- const term = new window.Terminal({
2772
- cursorBlink: true,
2773
- fontSize: fontSize,
2774
- fontFamily:
2775
- "'NerdFont', 'JetBrains Mono', 'MesloLGS NF', 'Hack Nerd Font', 'Fira Code', Menlo, monospace",
2776
- fontWeight: 'normal',
2777
- fontWeightBold: 'bold',
2778
- letterSpacing: 0,
2779
- lineHeight: 1.0,
2780
- theme: TERM_THEMES[getTheme()] || darkTermTheme,
2781
- allowProposedApi: true,
2782
- scrollback: 10000,
2783
- scrollOnOutput: false,
2784
- });
2785
-
2786
- const fitAddon = new window.FitAddon.FitAddon();
2787
- const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
2788
- const searchAddon = new window.SearchAddon.SearchAddon();
2789
- term.loadAddon(fitAddon);
2790
- term.loadAddon(webLinksAddon);
2791
- term.loadAddon(searchAddon);
2792
-
2793
- const container = document.createElement('div');
2794
- container.className = 'terminal-pane';
2795
- container.style.position = 'relative';
2796
- terminalsWrapper.appendChild(container);
2797
- term.open(container);
2798
-
2799
- // Canvas renderer — sharper text and more accurate selection.
2800
- // Skip in automated browsers (navigator.webdriver) where canvas
2801
- // rendering makes .xterm-rows inaccessible to DOM text queries.
2802
- if (window.CanvasAddon && !navigator.webdriver) {
2803
- try {
2804
- term.loadAddon(new window.CanvasAddon.CanvasAddon());
2805
- } catch {
2806
- // Fall back to DOM renderer
2807
- }
2808
- }
2809
-
2810
- // Scroll-to-bottom button
2811
- const scrollBtn = document.createElement('button');
2812
- scrollBtn.className = 'scroll-bottom-btn';
2813
- scrollBtn.textContent = '↓';
2814
- scrollBtn.title = 'Scroll to bottom';
2815
- scrollBtn.addEventListener('click', (e) => {
2816
- e.stopPropagation();
2817
- term.scrollToBottom();
2818
- });
2819
- container.appendChild(scrollBtn);
2820
-
2821
- // Write coalescer — batch rapid term.write() calls to reduce flicker.
2822
- // Single RAF for interactive typing (low latency). During bursts
2823
- // (>512 bytes buffered, e.g. Ctrl+O expand), waits one extra frame
2824
- // so cursor-movement sequences arrive as one atomic write.
2825
- let writeBuf = '';
2826
- let writeRaf = null;
2827
- function flushWrite() {
2828
- writeRaf = null;
2829
- if (writeBuf) {
2830
- term.write(writeBuf);
2831
- writeBuf = '';
2832
- }
2833
- }
2834
- function coalescedWrite(data) {
2835
- writeBuf += data;
2836
- if (!writeRaf) {
2837
- writeRaf = requestAnimationFrame(() => {
2838
- if (writeBuf.length > 512) {
2839
- writeRaf = requestAnimationFrame(flushWrite);
2840
- } else {
2841
- flushWrite();
2842
- }
2843
- });
2844
- }
2845
- }
2846
-
2847
- // Track whether user has scrolled away from bottom
2848
- let userScrolledUp = false;
2849
- term.onScroll(() => {
2850
- const buf = term.buffer.active;
2851
- userScrolledUp = buf.viewportY < buf.baseY;
2852
- scrollBtn.classList.toggle('visible', userScrolledUp);
2853
- });
2854
-
2855
- // Pointer-event scroll handler — uses setPointerCapture so scrolling
2856
- // survives xterm DOM re-renders under the finger (touch on a letter).
2857
- (function () {
2858
- let startY = null,
2859
- scrolling = false,
2860
- accum = 0,
2861
- ptrId = null;
2862
-
2863
- container.addEventListener(
2864
- 'pointerdown',
2865
- (e) => {
2866
- if (e.pointerType !== 'touch' || ptrId !== null) return;
2867
- startY = e.clientY;
2868
- scrolling = false;
2869
- accum = 0;
2870
- ptrId = e.pointerId;
2871
- },
2872
- { capture: true },
2873
- );
2874
-
2875
- container.addEventListener(
2876
- 'pointermove',
2877
- (e) => {
2878
- if (e.pointerId !== ptrId) return;
2879
- const y = e.clientY;
2880
- const delta = startY - y;
2881
- if (!scrolling) {
2882
- if (Math.abs(delta) > 10) {
2883
- scrolling = true;
2884
- term.clearSelection();
2885
- // Lock all future pointer events to this element —
2886
- // immune to DOM mutations, xterm re-renders, etc.
2887
- try {
2888
- container.setPointerCapture(ptrId);
2889
- } catch (_) {}
2890
- } else {
2891
- return;
2892
- }
2893
- }
2894
- e.preventDefault();
2895
- e.stopPropagation();
2896
- startY = y;
2897
- const lineH = term.options.fontSize * (term.options.lineHeight || 1);
2898
- accum += delta;
2899
- const lines = Math.trunc(accum / lineH);
2900
- if (lines !== 0) {
2901
- term.scrollLines(lines);
2902
- accum -= lines * lineH;
2903
- }
2904
- },
2905
- { capture: true, passive: false },
2906
- );
2907
-
2908
- function endScroll(e) {
2909
- if (e.pointerId !== ptrId) return;
2910
- if (scrolling) {
2911
- e.stopPropagation();
2912
- try {
2913
- container.releasePointerCapture(ptrId);
2914
- } catch (_) {}
2915
- }
2916
- startY = null;
2917
- scrolling = false;
2918
- ptrId = null;
2919
- }
2920
- container.addEventListener('pointerup', endScroll, { capture: true });
2921
- container.addEventListener('pointercancel', endScroll, { capture: true });
2922
- })();
2923
-
2924
- // Copy on selection
2925
- term.onSelectionChange(() => {
2926
- const sel = term.getSelection();
2927
- if (!sel) return;
2928
- if (navigator.clipboard && navigator.clipboard.writeText) {
2929
- navigator.clipboard
2930
- .writeText(sel)
2931
- .then(() => showToast('Copied!'))
2932
- .catch(() => {
2933
- // Fallback for non-secure contexts (HTTP over LAN)
2934
- copyFallback(sel);
2935
- });
2936
- } else {
2937
- copyFallback(sel);
2938
- }
2939
- });
2940
-
2941
- // Click to focus
2942
- container.addEventListener('click', () => {
2943
- term.focus();
2944
- // In split mode, clicking a pane makes it active
2945
- if (splitMode && ms.id !== activeId) {
2946
- const oldSecond = splitSecondId;
2947
- splitSecondId = activeId;
2948
- activeId = ms.id;
2949
- updateStatusBar();
2950
- renderTabs();
2951
- }
2952
- });
2953
-
2954
- const ms = {
2955
- id: data.id,
2956
- name: data.name,
2957
- color: data.color || SESSION_COLORS[managed.size % SESSION_COLORS.length],
2958
- shell: data.shell,
2959
- cwd: data.cwd,
2960
- pid: data.pid,
2961
- git: data.git || null,
2962
- term,
2963
- fitAddon,
2964
- searchAddon,
2965
- container,
2966
- coalescedWrite,
2967
- scrollBtn,
2968
- ws: null,
2969
- exited: false,
2970
- reconnectTimer: null,
2971
- reconnectDelay: 3000,
2972
- lastActivity: data.lastActivity || Date.now(),
2973
- silenceTimer: null,
2974
- };
2975
-
2976
- managed.set(data.id, ms);
2977
-
2978
- // Terminal input → WebSocket
2979
- term.onData((input) => {
2980
- if (ms.ws && ms.ws.readyState === 1) {
2981
- // Auto-scroll to bottom on user input
2982
- if (userScrolledUp) {
2983
- ms.term.scrollToBottom();
2984
- }
2985
- let data = input;
2986
- if (ctrlActive && input.length === 1) {
2987
- const code = input.toLowerCase().charCodeAt(0);
2988
- // a-z → Ctrl+letter (0x01-0x1a)
2989
- if (code >= 97 && code <= 122) {
2990
- data = String.fromCharCode(code - 96);
2991
- }
2992
- clearModifiers();
2993
- } else if (shiftActive && !ctrlActive) {
2994
- clearModifiers();
2995
- }
2996
- ms.ws.send(JSON.stringify({ type: 'input', data }));
2997
- }
2998
- });
2999
-
3000
- connectSession(ms);
3001
- return ms;
3002
- }
3003
-
3004
- // Tab title activity indicator for unread output
3005
- const originalTitle = document.title;
3006
- let hasUnread = false;
3007
-
3008
- // Shared AudioContext — must be created/resumed on user gesture for mobile
3009
- let notifAudioCtx = null;
3010
- function ensureAudioContext() {
3011
- if (!notifAudioCtx) {
3012
- notifAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
3013
- }
3014
- if (notifAudioCtx.state === 'suspended') notifAudioCtx.resume();
3015
- return notifAudioCtx;
3016
- }
3017
- document.addEventListener('click', ensureAudioContext, { once: true });
3018
- document.addEventListener('touchstart', ensureAudioContext, { once: true });
3019
-
3020
- function playNotificationSound() {
3021
- try {
3022
- const ctx = ensureAudioContext();
3023
- const osc = ctx.createOscillator();
3024
- const gain = ctx.createGain();
3025
- osc.connect(gain);
3026
- gain.connect(ctx.destination);
3027
- osc.frequency.value = 440;
3028
- osc.type = 'sine';
3029
- gain.gain.setValueAtTime(0.15, ctx.currentTime);
3030
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
3031
- osc.start(ctx.currentTime);
3032
- osc.stop(ctx.currentTime + 0.15);
3033
- } catch {
3034
- // Audio not available
3035
- }
3036
- }
3037
-
3038
- function markUnreadOutput() {
3039
- if (!document.hidden || hasUnread) return;
3040
- hasUnread = true;
3041
- playNotificationSound();
3042
- document.title = '(\u25CF) ' + originalTitle;
3043
- }
3044
-
3045
- function clearUnreadIndicator() {
3046
- if (!hasUnread) return;
3047
- hasUnread = false;
3048
- document.title = originalTitle;
3049
- }
3050
-
3051
- function connectSession(ms) {
3052
- if (ms.reconnectTimer) {
3053
- clearTimeout(ms.reconnectTimer);
3054
- ms.reconnectTimer = null;
3055
- }
3056
- if (ms.ws) {
3057
- try {
3058
- ms.ws.close();
3059
- } catch {}
3060
- }
3061
-
3062
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
3063
- const ws = new WebSocket(`${proto}://${location.host}/ws`);
3064
- ms.ws = ws;
3065
-
3066
- ws.onopen = () => {
3067
- if (ms.ws !== ws) return;
3068
- ws.send(JSON.stringify({ type: 'attach', sessionId: ms.id }));
3069
- ms.reconnectDelay = 3000;
3070
- if (ms.id === activeId) {
3071
- statusDot.className = 'connected';
3072
- statusText.textContent = '';
3073
- reconnectOverlay.classList.remove('visible');
3074
- }
3075
- renderTabs();
3076
- };
3077
-
3078
- ws.onmessage = (event) => {
3079
- if (ms.ws !== ws) return;
3080
- try {
3081
- const msg = JSON.parse(event.data);
3082
- if (msg.type === 'output') {
3083
- ms.coalescedWrite(msg.data);
3084
- ms.lastActivity = Date.now();
3085
- resetSilenceTimer(ms);
3086
- markUnreadOutput();
3087
- if (ms.id !== activeId && !ms.hasUnread) {
3088
- ms.hasUnread = true;
3089
- playNotificationSound();
3090
- renderTabs();
3091
- }
3092
- } else if (msg.type === 'attached') {
3093
- if (ms.container.classList.contains('visible')) {
3094
- sendResize(ms);
3095
- }
3096
- if (ms.id === activeId) {
3097
- statusDot.className = 'connected';
3098
- statusText.textContent = '';
3099
- }
3100
- } else if (msg.type === 'exit') {
3101
- ms.exited = true;
3102
- renderTabs();
3103
- if (ms.id === activeId) {
3104
- statusText.textContent = 'Exited (code ' + msg.code + ')';
3105
- statusDot.className = '';
3106
- reconnectOverlay.querySelector('.msg').textContent =
3107
- 'Session exited (code ' + msg.code + ')';
3108
- reconnectOverlay.classList.add('visible');
3109
- }
3110
- } else if (msg.type === 'error') {
3111
- if (msg.message === 'Session not found') {
3112
- ms.exited = true;
3113
- if (ms.reconnectTimer) {
3114
- clearTimeout(ms.reconnectTimer);
3115
- ms.reconnectTimer = null;
3116
- }
3117
- renderTabs();
3118
- }
3119
- if (ms.id === activeId) {
3120
- statusText.textContent = msg.message;
3121
- reconnectOverlay.querySelector('.msg').textContent = msg.message;
3122
- reconnectOverlay.classList.add('visible');
3123
- }
3124
- }
3125
- } catch {
3126
- ms.coalescedWrite(event.data);
3127
- }
3128
- };
3129
-
3130
- ws.onclose = () => {
3131
- if (ms.ws !== ws) return;
3132
- if (ms.id === activeId) {
3133
- statusDot.className = '';
3134
- statusText.textContent = 'Disconnected';
3135
- reconnectOverlay.querySelector('.msg').textContent =
3136
- 'Disconnected. Attempting to reconnect\u2026';
3137
- reconnectOverlay.classList.add('visible');
3138
- }
3139
- if (!ms.exited) {
3140
- ms.reconnectTimer = setTimeout(() => {
3141
- ms.reconnectDelay = Math.min(ms.reconnectDelay * 1.5, 30000);
3142
- connectSession(ms);
3143
- }, ms.reconnectDelay);
3144
- }
3145
- };
3146
-
3147
- ws.onerror = () => {
3148
- if (ms.ws !== ws) return;
3149
- if (ms.id === activeId) statusText.textContent = 'Connection error';
3150
- };
3151
- }
3152
-
3153
- function activateSession(id) {
3154
- if (!managed.has(id)) return;
3155
- activeId = id;
3156
- const ms = managed.get(id);
3157
- if (ms) ms.hasUnread = false;
3158
- reconnectOverlay.classList.remove('visible');
3159
-
3160
- // Show/hide panes
3161
- for (const [sid, ms] of managed) {
3162
- const show = sid === id || (splitMode && sid === splitSecondId);
3163
- ms.container.classList.toggle('visible', show);
3164
- }
3165
-
3166
- updateStatusBar();
3167
-
3168
- // Fit visible terminals and scroll to bottom
3169
- requestAnimationFrame(() => {
3170
- for (const [, ms] of managed) {
3171
- if (ms.container.classList.contains('visible')) {
3172
- ms.fitAddon.fit();
3173
- sendResize(ms);
3174
- ms.term.scrollToBottom();
3175
- }
3176
- }
3177
- });
3178
-
3179
- // Update URL
3180
- const url = new URL(location);
3181
- url.searchParams.set('id', id);
3182
- history.replaceState(null, '', url);
3183
-
3184
- renderTabs();
3185
- }
3186
-
3187
- function updateStatusBar() {
3188
- const ms = managed.get(activeId);
3189
- const stopBtn = document.getElementById('stop-btn');
3190
- if (!ms) {
3191
- stopBtn.style.display = 'none';
3192
- return;
3193
- }
3194
- stopBtn.style.display = '';
3195
- sessionNameEl.textContent = ms.name;
3196
- statusDot.className = ms.ws && ms.ws.readyState === 1 ? 'connected' : '';
3197
- statusText.textContent =
3198
- ms.ws && ms.ws.readyState === 1 ? '' : ms.exited ? 'Exited' : 'Disconnected';
3199
- }
3200
-
3201
- async function removeSession(id) {
3202
- const ms = managed.get(id);
3203
- if (ms) {
3204
- ms.exited = true;
3205
- if (ms.reconnectTimer) {
3206
- clearTimeout(ms.reconnectTimer);
3207
- ms.reconnectTimer = null;
3208
- }
3209
- if (ms.silenceTimer) {
3210
- clearTimeout(ms.silenceTimer);
3211
- ms.silenceTimer = null;
3212
- }
3213
- if (ms.ws)
3214
- try {
3215
- ms.ws.close();
3216
- } catch {}
3217
- }
3218
-
3219
- try {
3220
- await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
3221
- } catch {}
3222
-
3223
- if (ms) {
3224
- ms.term.dispose();
3225
- ms.container.remove();
3226
- managed.delete(id);
3227
- }
3228
-
3229
- if (id === splitSecondId) splitSecondId = null;
3230
- if (id === activeId) {
3231
- const remaining = [...managed.keys()];
3232
- if (remaining.length > 0) activateSession(remaining[0]);
3233
- else {
3234
- window.location.replace('/');
3235
- return;
3236
- }
3237
- }
3238
- renderTabs();
3239
- }
3240
-
3241
- // ===== Tab Rendering =====
3242
- function renderTabs() {
3243
- const order = getTabOrder();
3244
-
3245
- tabListEl.innerHTML = order
3246
- .map((id) => {
3247
- const ms = managed.get(id);
3248
- if (!ms) return '';
3249
- const isActive = id === activeId;
3250
- const isSplit = splitMode && id === splitSecondId;
3251
- const statusColor = ms.exited
3252
- ? 'var(--danger)'
3253
- : ms.ws && ms.ws.readyState === 1
3254
- ? 'var(--success)'
3255
- : 'var(--text-muted)';
3256
- const activity = getActivityLabel(ms.lastActivity);
3257
- let cls = 'session-tab';
3258
- if (isActive) cls += ' active';
3259
- if (isSplit) cls += ' in-split';
3260
- return (
3261
- '<button class="' +
3262
- cls +
3263
- '" data-id="' +
3264
- escAttr(id) +
3265
- '" draggable="true">' +
3266
- '<span class="tab-dot" style="background:' +
3267
- safeColor(ms.color) +
3268
- '"></span>' +
3269
- '<span class="tab-name">' +
3270
- esc(ms.name) +
3271
- '</span>' +
3272
- (activity ? '<span class="tab-activity">' + activity + '</span>' : '') +
3273
- (ms.hasUnread && !isActive ? '<span class="tab-unread"></span>' : '') +
3274
- '<span class="tab-status" style="background:' +
3275
- safeColor(statusColor) +
3276
- '"></span>' +
3277
- '<span class="tab-close" data-close="' +
3278
- escAttr(id) +
3279
- '">×</span>' +
3280
- '</button>'
3281
- );
3282
- })
3283
- .join('');
3284
-
3285
- attachTabHandlers();
3286
- initTabDrag();
3287
- }
3288
-
3289
- // ===== Tab Preview =====
3290
- const previewEl = document.getElementById('tab-preview');
3291
- const previewDot = document.getElementById('preview-dot');
3292
- const previewName = document.getElementById('preview-name');
3293
- const previewBody = document.getElementById('preview-body');
3294
- let previewTimer = null;
3295
- let previewVisible = false;
3296
-
3297
- function getTerminalLines(ms, count) {
3298
- const buf = ms.term.buffer.active;
3299
- const totalRows = buf.length;
3300
- // Find the last non-empty line (buffer has empty padding rows)
3301
- let lastNonEmpty = -1;
3302
- for (let i = totalRows - 1; i >= 0; i--) {
3303
- const line = buf.getLine(i);
3304
- if (line && line.translateToString(true).trim() !== '') {
3305
- lastNonEmpty = i;
3306
- break;
3307
- }
3308
- }
3309
- if (lastNonEmpty < 0) return [];
3310
- const start = Math.max(0, lastNonEmpty - count + 1);
3311
- const lines = [];
3312
- for (let i = start; i <= lastNonEmpty; i++) {
3313
- const line = buf.getLine(i);
3314
- if (line) lines.push(line.translateToString(true));
3315
- }
3316
- return lines;
3317
- }
3318
-
3319
- function showPreview(tabEl, sessionId) {
3320
- const ms = managed.get(sessionId);
3321
- if (!ms) return;
3322
- const lines = getTerminalLines(ms, 12);
3323
- previewDot.style.background = ms.color;
3324
- previewName.textContent = ms.name;
3325
- if (lines.length === 0) {
3326
- previewBody.innerHTML = '<div class="preview-empty">No output yet</div>';
3327
- } else {
3328
- previewBody.textContent = lines.join('\n');
3329
- }
3330
-
3331
- previewEl.classList.add('visible');
3332
- previewVisible = true;
3333
-
3334
- // Position: above the tab, centered
3335
- const tabRect = tabEl.getBoundingClientRect();
3336
- const pw = previewEl.offsetWidth;
3337
- const ph = previewEl.offsetHeight;
3338
- let left = tabRect.left + tabRect.width / 2 - pw / 2;
3339
- left = Math.max(6, Math.min(left, window.innerWidth - pw - 6));
3340
- let top = tabRect.top - ph - 6;
3341
- if (top < 6) top = tabRect.bottom + 6; // Below if not enough room
3342
- previewEl.style.left = left + 'px';
3343
- previewEl.style.top = top + 'px';
3344
- }
3345
-
3346
- function hidePreview() {
3347
- previewEl.classList.remove('visible');
3348
- previewVisible = false;
3349
- if (previewTimer) {
3350
- clearTimeout(previewTimer);
3351
- previewTimer = null;
3352
- }
3353
- }
3354
-
3355
- function attachTabHandlers() {
3356
- tabListEl.querySelectorAll('.session-tab').forEach((tab) => {
3357
- tab.addEventListener('click', (e) => {
3358
- if (e.target.dataset.close) return;
3359
- hidePreview();
3360
- activateSession(tab.dataset.id);
3361
- });
3362
-
3363
- // Middle-click to close tab
3364
- tab.addEventListener('auxclick', (e) => {
3365
- if (e.button === 1) {
3366
- e.preventDefault();
3367
- if (confirm('Close this session?')) removeSession(tab.dataset.id);
3368
- }
3369
- });
3370
-
3371
- // Desktop: hover preview (non-active tabs only)
3372
- tab.addEventListener('mouseenter', () => {
3373
- if (tab.dataset.id === activeId) return;
3374
- previewTimer = setTimeout(() => showPreview(tab, tab.dataset.id), 400);
3375
- });
3376
- tab.addEventListener('mouseleave', () => hidePreview());
3377
- });
3378
- tabListEl.querySelectorAll('.tab-close').forEach((btn) => {
3379
- btn.addEventListener('click', (e) => {
3380
- e.stopPropagation();
3381
- if (confirm('Close this session?')) removeSession(btn.dataset.close);
3382
- });
3383
- });
3384
- }
3385
-
3386
- // ===== Sessions Side Panel =====
3387
- const sidePanel = document.getElementById('side-panel');
3388
- const sidePanelBackdrop = document.getElementById('side-panel-backdrop');
3389
- const sidePanelList = document.getElementById('side-panel-list');
3390
-
3391
- function openSidePanel() {
3392
- renderSidePanel();
3393
- sidePanel.classList.add('open');
3394
- sidePanelBackdrop.classList.add('visible');
3395
- }
3396
-
3397
- function closeSidePanel() {
3398
- sidePanel.classList.remove('open');
3399
- sidePanelBackdrop.classList.remove('visible');
3400
- }
3401
-
3402
- function renderSidePanel() {
3403
- const order = getTabOrder();
3404
- sidePanelList.innerHTML = order
3405
- .map((id) => {
3406
- const ms = managed.get(id);
3407
- if (!ms) return '';
3408
- const isActive = id === activeId;
3409
- const statusColor = ms.exited
3410
- ? 'var(--danger)'
3411
- : ms.ws && ms.ws.readyState === 1
3412
- ? 'var(--success)'
3413
- : 'var(--text-muted)';
3414
- const activity = getActivityLabel(ms.lastActivity);
3415
- const lines = getTerminalLines(ms, 6);
3416
- const previewContent =
3417
- lines.length > 0
3418
- ? '<div class="side-panel-card-preview">' + esc(lines.join('\n')) + '</div>'
3419
- : '<div class="side-panel-card-preview empty">No output yet</div>';
3420
- return (
3421
- '<div class="side-panel-card' +
3422
- (isActive ? ' active' : '') +
3423
- '" data-id="' +
3424
- escAttr(id) +
3425
- '">' +
3426
- '<div class="side-panel-card-header">' +
3427
- '<span class="side-panel-card-dot" style="background:' +
3428
- safeColor(ms.color) +
3429
- '"></span>' +
3430
- '<span class="side-panel-card-name">' +
3431
- esc(ms.name) +
3432
- '</span>' +
3433
- (ms.hasUnread && !isActive ? '<span class="tab-unread"></span>' : '') +
3434
- '<span class="side-panel-card-status" style="background:' +
3435
- safeColor(statusColor) +
3436
- '"></span>' +
3437
- '<button class="side-panel-card-close" data-close-id="' +
3438
- escAttr(id) +
3439
- '" title="Close session">×</button>' +
3440
- '</div>' +
3441
- (activity ? '<div class="side-panel-card-meta">' + activity + ' ago</div>' : '') +
3442
- (ms.git
3443
- ? '<div class="side-panel-card-git">' +
3444
- '<span class="git-badge"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg> ' +
3445
- esc(ms.git.branch || 'detached') +
3446
- '</span>' +
3447
- (ms.git.provider
3448
- ? '<span class="git-badge">' + esc(ms.git.provider) + '</span>'
3449
- : '') +
3450
- (ms.git.repoName
3451
- ? '<span class="git-badge">' + esc(ms.git.repoName) + '</span>'
3452
- : '') +
3453
- (ms.git.status
3454
- ? '<span class="git-badge ' +
3455
- (ms.git.status.clean ? 'git-status-clean' : 'git-status-dirty') +
3456
- '">' +
3457
- (ms.git.status.clean ? '✓ clean' : esc(ms.git.status.summary)) +
3458
- '</span>'
3459
- : '') +
3460
- '</div>'
3461
- : '') +
3462
- previewContent +
3463
- '</div>'
3464
- );
3465
- })
3466
- .join('');
3467
-
3468
- sidePanelList.querySelectorAll('.side-panel-card-close').forEach((btn) => {
3469
- btn.addEventListener('click', async (e) => {
3470
- e.stopPropagation();
3471
- const id = btn.dataset.closeId;
3472
- if (confirm('Close this session?')) {
3473
- await removeSession(id);
3474
- renderSidePanel();
3475
- }
3476
- });
3477
- });
3478
-
3479
- sidePanelList.querySelectorAll('.side-panel-card').forEach((card) => {
3480
- card.addEventListener('click', () => {
3481
- activateSession(card.dataset.id);
3482
- closeSidePanel();
3483
- });
3484
- });
3485
- }
3486
-
3487
- document.getElementById('panel-toggle').addEventListener('click', openSidePanel);
3488
- document.getElementById('side-panel-close').addEventListener('click', closeSidePanel);
3489
- sidePanelBackdrop.addEventListener('click', closeSidePanel);
3490
-
3491
- // ===== Drag to Reorder =====
3492
- function initTabDrag() {
3493
- let dragId = null;
3494
- let dragEl = null;
3495
-
3496
- tabListEl.querySelectorAll('.session-tab').forEach((tab) => {
3497
- // --- Touch drag (long press) + preview ---
3498
- let longPressTimer = null;
3499
- let previewHoldTimer = null;
3500
- let startX = 0,
3501
- startY = 0;
3502
- let isDragging = false;
3503
- let didPreview = false;
3504
-
3505
- tab.addEventListener(
3506
- 'touchstart',
3507
- (e) => {
3508
- startX = e.touches[0].clientX;
3509
- startY = e.touches[0].clientY;
3510
- didPreview = false;
3511
-
3512
- // 200ms: show preview (if not active tab)
3513
- if (tab.dataset.id !== activeId) {
3514
- previewHoldTimer = setTimeout(() => {
3515
- didPreview = true;
3516
- showPreview(tab, tab.dataset.id);
3517
- if (navigator.vibrate) navigator.vibrate(30);
3518
- }, 200);
3519
- }
3520
-
3521
- // 600ms: enter drag mode (dismiss preview)
3522
- longPressTimer = setTimeout(() => {
3523
- hidePreview();
3524
- isDragging = true;
3525
- dragId = tab.dataset.id;
3526
- dragEl = tab;
3527
- tab.classList.add('dragging');
3528
- if (navigator.vibrate) navigator.vibrate(50);
3529
- }, 600);
3530
- },
3531
- { passive: true },
3532
- );
3533
-
3534
- tab.addEventListener('touchmove', (e) => {
3535
- const dx = Math.abs(e.touches[0].clientX - startX);
3536
- const dy = Math.abs(e.touches[0].clientY - startY);
3537
- if (!isDragging && (dx > 10 || dy > 10)) {
3538
- clearTimeout(longPressTimer);
3539
- clearTimeout(previewHoldTimer);
3540
- hidePreview();
3541
- return;
3542
- }
3543
- if (isDragging) {
3544
- e.preventDefault();
3545
- const touch = e.touches[0];
3546
- const el = document.elementFromPoint(touch.clientX, touch.clientY);
3547
- const target = el ? el.closest('.session-tab') : null;
3548
- if (target && target !== dragEl && target.dataset.id) {
3549
- const rect = target.getBoundingClientRect();
3550
- if (touch.clientX < rect.left + rect.width / 2) {
3551
- target.parentNode.insertBefore(dragEl, target);
3552
- } else {
3553
- target.parentNode.insertBefore(dragEl, target.nextSibling);
3554
- }
3555
- }
3556
- }
3557
- });
3558
-
3559
- tab.addEventListener('touchend', () => {
3560
- clearTimeout(longPressTimer);
3561
- clearTimeout(previewHoldTimer);
3562
- hidePreview();
3563
- if (isDragging) {
3564
- isDragging = false;
3565
- dragEl.classList.remove('dragging');
3566
- // Save new order from DOM
3567
- const newOrder = [...tabListEl.querySelectorAll('.session-tab')].map(
3568
- (t) => t.dataset.id,
3569
- );
3570
- saveTabOrder(newOrder);
3571
- dragId = null;
3572
- dragEl = null;
3573
- }
3574
- });
3575
-
3576
- // --- Desktop drag ---
3577
- tab.addEventListener('dragstart', (e) => {
3578
- dragId = tab.dataset.id;
3579
- dragEl = tab;
3580
- tab.classList.add('dragging');
3581
- e.dataTransfer.effectAllowed = 'move';
3582
- });
3583
-
3584
- tab.addEventListener('dragover', (e) => {
3585
- e.preventDefault();
3586
- e.dataTransfer.dropEffect = 'move';
3587
- });
3588
-
3589
- tab.addEventListener('drop', (e) => {
3590
- e.preventDefault();
3591
- if (dragId && tab.dataset.id !== dragId) {
3592
- const rect = tab.getBoundingClientRect();
3593
- if (e.clientX < rect.left + rect.width / 2) {
3594
- tab.parentNode.insertBefore(dragEl, tab);
3595
- } else {
3596
- tab.parentNode.insertBefore(dragEl, tab.nextSibling);
3597
- }
3598
- const newOrder = [...tabListEl.querySelectorAll('.session-tab')].map(
3599
- (t) => t.dataset.id,
3600
- );
3601
- saveTabOrder(newOrder);
3602
- }
3603
- });
3604
-
3605
- tab.addEventListener('dragend', () => {
3606
- if (dragEl) dragEl.classList.remove('dragging');
3607
- dragId = null;
3608
- dragEl = null;
3609
- });
3610
- });
3611
- }
3612
-
3613
- // ===== Split View =====
3614
- function toggleSplit() {
3615
- splitMode = !splitMode;
3616
-
3617
- if (splitMode) {
3618
- // Find a second session to show
3619
- const order = getTabOrder();
3620
- const others = order.filter((id) => id !== activeId);
3621
- splitSecondId = others.length > 0 ? others[0] : null;
3622
-
3623
- if (splitSecondId) {
3624
- const isMobile = window.innerWidth < 640;
3625
- terminalsWrapper.classList.add(isMobile ? 'split-v' : 'split-h');
3626
- managed.get(splitSecondId).container.classList.add('visible');
3627
- }
3628
- } else {
3629
- terminalsWrapper.classList.remove('split-h', 'split-v');
3630
- splitSecondId = null;
3631
- // Hide all except active
3632
- for (const [id, ms] of managed) {
3633
- ms.container.classList.toggle('visible', id === activeId);
3634
- }
3635
- }
3636
-
3637
- requestAnimationFrame(() => {
3638
- for (const [, ms] of managed) {
3639
- if (ms.container.classList.contains('visible')) {
3640
- ms.fitAddon.fit();
3641
- sendResize(ms);
3642
- }
3643
- }
3644
- });
3645
-
3646
- renderTabs();
3647
- }
3648
-
3649
- // ===== Paste =====
3650
- function setupPaste() {
3651
- const pasteOverlay = document.getElementById('paste-overlay');
3652
- const pasteInput = document.getElementById('paste-input');
3653
- const pasteBtn = document.getElementById('paste-btn');
3654
-
3655
- function openPasteModal() {
3656
- pasteInput.value = '';
3657
- pasteOverlay.classList.add('visible');
3658
- pasteInput.focus();
3659
- }
3660
-
3661
- async function handlePaste() {
3662
- // Try image first via clipboard.read()
3663
- if (navigator.clipboard && navigator.clipboard.read) {
3664
- try {
3665
- const items = await navigator.clipboard.read();
3666
- for (const item of items) {
3667
- const imageType = item.types.find((t) => t.startsWith('image/'));
3668
- if (imageType) {
3669
- const blob = await item.getType(imageType);
3670
- const res = await fetch('/api/upload', {
3671
- method: 'POST',
3672
- headers: { 'Content-Type': imageType },
3673
- body: blob,
3674
- credentials: 'same-origin',
3675
- });
3676
- if (!res.ok) throw new Error('Upload failed');
3677
- const data = await res.json();
3678
- const ms = managed.get(activeId);
3679
- if (ms && ms.ws && ms.ws.readyState === 1) {
3680
- ms.ws.send(
3681
- JSON.stringify({ type: 'input', data: (data.path || data.url) + ' ' }),
3682
- );
3683
- }
3684
- return;
3685
- }
3686
- }
3687
- } catch (err) {
3688
- console.warn('clipboard.read failed:', err.message);
3689
- }
3690
- }
3691
- // Text paste
3692
- if (navigator.clipboard && navigator.clipboard.readText) {
3693
- try {
3694
- const text = await navigator.clipboard.readText();
3695
- if (text) {
3696
- const ms = managed.get(activeId);
3697
- if (ms && ms.ws && ms.ws.readyState === 1) {
3698
- ms.ws.send(JSON.stringify({ type: 'input', data: text }));
3699
- showToast('Pasted!');
3700
- }
3701
- return;
3702
- }
3703
- } catch (err) {
3704
- console.warn('clipboard.readText failed:', err.message);
3705
- }
3706
- }
3707
- openPasteModal();
3708
- }
3709
-
3710
- // Use touchend directly for iOS Safari (click may not fire reliably)
3711
- let pasteTouched = false;
3712
- pasteBtn.addEventListener('touchend', (e) => {
3713
- e.preventDefault();
3714
- pasteTouched = true;
3715
- handlePaste();
3716
- });
3717
- pasteBtn.addEventListener('mousedown', (e) => e.preventDefault());
3718
- pasteBtn.addEventListener('click', () => {
3719
- if (pasteTouched) {
3720
- pasteTouched = false;
3721
- return;
3722
- }
3723
- handlePaste();
3724
- });
3725
-
3726
- document.getElementById('paste-send').addEventListener('click', () => {
3727
- const text = pasteInput.value;
3728
- const ms = managed.get(activeId);
3729
- if (text && ms && ms.ws && ms.ws.readyState === 1) {
3730
- ms.ws.send(JSON.stringify({ type: 'input', data: text }));
3731
- }
3732
- pasteOverlay.classList.remove('visible');
3733
- pasteInput.value = '';
3734
- });
3735
-
3736
- document.getElementById('paste-cancel').addEventListener('click', () => {
3737
- pasteOverlay.classList.remove('visible');
3738
- pasteInput.value = '';
3739
- });
3740
- }
3741
-
3742
- // ===== Select Text Overlay =====
3743
- function setupSelectMode() {
3744
- const selectBtn = document.getElementById('select-btn');
3745
- const selectOverlay = document.getElementById('select-overlay');
3746
- const selectContent = document.getElementById('select-content');
3747
- const PAGE_SIZE = 200;
3748
- let allLines = [];
3749
- let loadedFrom = 0;
3750
-
3751
- function renderLines(from, to) {
3752
- return allLines.slice(from, to).join('\n');
3753
- }
3754
-
3755
- function openSelectOverlay() {
3756
- const ms = managed.get(activeId);
3757
- if (!ms) return;
3758
- const buf = ms.term.buffer.active;
3759
- allLines = [];
3760
- for (let i = 0; i < buf.length; i++) {
3761
- const line = buf.getLine(i);
3762
- if (line) allLines.push(line.translateToString(true));
3763
- }
3764
- // Trim trailing empty lines
3765
- while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') allLines.pop();
3766
-
3767
- // Show last PAGE_SIZE lines
3768
- loadedFrom = Math.max(0, allLines.length - PAGE_SIZE);
3769
- selectContent.textContent = renderLines(loadedFrom, allLines.length);
3770
-
3771
- // Show/hide "Load more" button
3772
- const loadMoreBtn = document.getElementById('select-load-more');
3773
- loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
3774
- loadMoreBtn.textContent = `▲ Load more (${loadedFrom} lines above)`;
3775
-
3776
- // Show line count in title
3777
- const title = document.getElementById('select-title');
3778
- const shown = allLines.length - loadedFrom;
3779
- title.textContent =
3780
- allLines.length <= PAGE_SIZE
3781
- ? `Copy Text (${allLines.length} lines)`
3782
- : `Copy Text (${shown}/${allLines.length} lines)`;
3783
-
3784
- selectBtn.style.display = 'none';
3785
- selectOverlay.classList.add('visible');
3786
- selectContent.scrollTop = selectContent.scrollHeight;
3787
- }
3788
-
3789
- document.getElementById('select-load-more').addEventListener('click', () => {
3790
- const prevHeight = selectContent.scrollHeight;
3791
- const newFrom = Math.max(0, loadedFrom - PAGE_SIZE);
3792
- const chunk = renderLines(newFrom, loadedFrom);
3793
- selectContent.textContent = chunk + '\n' + selectContent.textContent;
3794
- loadedFrom = newFrom;
3795
- // Keep scroll position stable
3796
- selectContent.scrollTop = selectContent.scrollHeight - prevHeight;
3797
- const loadMoreBtn = document.getElementById('select-load-more');
3798
- loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
3799
- loadMoreBtn.textContent = loadedFrom > 0 ? `▲ Load more (${loadedFrom} lines above)` : '';
3800
- // Update title
3801
- const title = document.getElementById('select-title');
3802
- const shown = allLines.length - loadedFrom;
3803
- title.textContent = `Copy Text (${shown}/${allLines.length} lines)`;
3804
- });
3805
-
3806
- selectBtn.addEventListener('mousedown', (e) => e.preventDefault());
3807
- selectBtn.addEventListener(
3808
- 'touchend',
3809
- (e) => {
3810
- e.preventDefault();
3811
- const ms = managed.get(activeId);
3812
- if (ms) ms.term.blur();
3813
- openSelectOverlay();
3814
- },
3815
- { passive: false },
3816
- );
3817
- selectBtn.addEventListener('click', () => {
3818
- const ms = managed.get(activeId);
3819
- if (ms) ms.term.blur();
3820
- openSelectOverlay();
3821
- });
3822
-
3823
- document.getElementById('select-copy').addEventListener('click', () => {
3824
- // Copy finger selection if any, otherwise copy all loaded text
3825
- const sel = window.getSelection();
3826
- const text = sel && sel.toString() ? sel.toString() : selectContent.textContent;
3827
- if (!text) return;
3828
- if (navigator.clipboard && navigator.clipboard.writeText) {
3829
- navigator.clipboard
3830
- .writeText(text)
3831
- .then(() => showToast('Copied!'))
3832
- .catch(() => {
3833
- copyFallback(text);
3834
- });
3835
- } else {
3836
- copyFallback(text);
3837
- }
3838
- });
3839
-
3840
- document.getElementById('select-close').addEventListener('click', () => {
3841
- selectOverlay.classList.remove('visible');
3842
- selectContent.textContent = '';
3843
- allLines = [];
3844
- selectBtn.style.display = '';
3845
- const ms = managed.get(activeId);
3846
- if (ms) ms.term.focus();
3847
- });
3848
- }
3849
-
3850
- // ===== Image Paste =====
3851
- function setupImagePaste() {
3852
- // Use capture phase so we intercept before xterm.js pastes a file path as text
3853
- document.addEventListener(
3854
- 'paste',
3855
- async (e) => {
3856
- const items = e.clipboardData && e.clipboardData.items;
3857
- if (!items) return;
3858
-
3859
- for (const item of items) {
3860
- if (item.type.startsWith('image/')) {
3861
- e.preventDefault();
3862
- e.stopPropagation();
3863
- const blob = item.getAsFile();
3864
- if (!blob) return;
3865
-
3866
- try {
3867
- const res = await fetch('/api/upload', {
3868
- method: 'POST',
3869
- headers: { 'Content-Type': item.type },
3870
- body: blob,
3871
- credentials: 'same-origin',
3872
- });
3873
- if (!res.ok) throw new Error('Upload failed');
3874
- const data = await res.json();
3875
- const ms = managed.get(activeId);
3876
- if (ms && ms.ws && ms.ws.readyState === 1) {
3877
- ms.ws.send(
3878
- JSON.stringify({ type: 'input', data: (data.path || data.url) + ' ' }),
3879
- );
3880
- }
3881
- } catch (err) {
3882
- showToast('Image paste failed');
3883
- }
3884
- return;
3885
- }
3886
- }
3887
- },
3888
- true,
3889
- );
3890
- }
3891
-
3892
- // ===== File Upload =====
3893
- function setupUpload() {
3894
- const uploadInput = document.getElementById('upload-input');
3895
- const uploadModal = document.getElementById('upload-modal');
3896
- const uploadDirInput = document.getElementById('upload-dir');
3897
- const uploadFileList = document.getElementById('upload-file-list');
3898
- const uploadConfirmBtn = document.getElementById('upload-confirm');
3899
- const uploadCancelBtn = document.getElementById('upload-cancel');
3900
- const uploadBrowseBtn = document.getElementById('upload-browse-btn');
3901
-
3902
- let pendingFiles = null;
3903
-
3904
- const MAX_FILE_SIZE = 10 * 1024 * 1024;
3905
-
3906
- function openUploadModal(files) {
3907
- pendingFiles = files;
3908
- const ms = managed.get(activeId);
3909
- const cwd = (ms && ms.cwd) || '';
3910
- uploadDirInput.value = cwd;
3911
- let hasOversized = false;
3912
- uploadFileList.innerHTML = '';
3913
- Array.from(files).forEach((f) => {
3914
- const oversized = f.size > MAX_FILE_SIZE;
3915
- if (oversized) hasOversized = true;
3916
- const row = document.createElement('div');
3917
- if (oversized) row.style.color = '#f87171';
3918
- row.textContent =
3919
- '📄 ' +
3920
- f.name +
3921
- ' (' +
3922
- formatSize(f.size) +
3923
- ')' +
3924
- (oversized ? ' exceeds 10 MB limit' : '');
3925
- uploadFileList.appendChild(row);
3926
- });
3927
- uploadConfirmBtn.disabled = hasOversized;
3928
- uploadConfirmBtn.style.opacity = hasOversized ? '0.5' : '1';
3929
- uploadModal.classList.add('visible');
3930
- }
3931
-
3932
- function closeUploadModal() {
3933
- uploadModal.classList.remove('visible');
3934
- closeBrowseDropdown();
3935
- pendingFiles = null;
3936
- }
3937
-
3938
- function formatSize(bytes) {
3939
- if (bytes < 1024) return bytes + ' B';
3940
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
3941
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
3942
- }
3943
-
3944
- uploadInput.addEventListener('change', () => {
3945
- const files = uploadInput.files;
3946
- if (!files || !files.length || !activeId) return;
3947
- openUploadModal(files);
3948
- });
3949
-
3950
- uploadCancelBtn.addEventListener('click', closeUploadModal);
3951
- uploadModal.addEventListener('click', (e) => {
3952
- if (e.target === uploadModal) closeUploadModal();
3953
- });
3954
-
3955
- // Directory browsing (reuse /api/dirs)
3956
- let browseDropdown = null;
3957
-
3958
- function closeBrowseDropdown() {
3959
- if (browseDropdown) {
3960
- browseDropdown.remove();
3961
- browseDropdown = null;
3962
- }
3963
- }
3964
-
3965
- // Close dropdown when clicking outside
3966
- document.addEventListener('click', (e) => {
3967
- if (
3968
- browseDropdown &&
3969
- !browseDropdown.contains(e.target) &&
3970
- e.target !== uploadBrowseBtn
3971
- ) {
3972
- closeBrowseDropdown();
3973
- }
3974
- });
3975
-
3976
- uploadBrowseBtn.addEventListener('click', async () => {
3977
- if (browseDropdown) {
3978
- closeBrowseDropdown();
3979
- return;
3980
- }
3981
- const q = uploadDirInput.value || '';
3982
- try {
3983
- const res = await fetch('/api/dirs?q=' + encodeURIComponent(q ? q + '/' : ''), {
3984
- credentials: 'same-origin',
3985
- });
3986
- if (!res.ok) throw new Error(`Failed to browse directories: ${res.status}`);
3987
- const data = await res.json();
3988
- if (!data.dirs || !data.dirs.length) return;
3989
- browseDropdown = document.createElement('div');
3990
- browseDropdown.style.cssText =
3991
- 'position:absolute;left:0;right:0;bottom:100%;margin-bottom:4px;max-height:150px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:6px;z-index:10;box-shadow:0 -4px 12px rgba(0,0,0,0.2);';
3992
- data.dirs.forEach((d) => {
3993
- const opt = document.createElement('div');
3994
- opt.textContent = d;
3995
- opt.style.cssText =
3996
- 'padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);';
3997
- opt.addEventListener('mouseenter', () => (opt.style.background = 'var(--hover)'));
3998
- opt.addEventListener('mouseleave', () => (opt.style.background = 'none'));
3999
- opt.addEventListener('click', () => {
4000
- uploadDirInput.value = d;
4001
- closeBrowseDropdown();
4002
- });
4003
- browseDropdown.appendChild(opt);
4004
- });
4005
- uploadBrowseBtn.parentElement.style.position = 'relative';
4006
- uploadBrowseBtn.parentElement.appendChild(browseDropdown);
4007
- } catch {}
4008
- });
4009
-
4010
- uploadConfirmBtn.addEventListener('click', async () => {
4011
- if (!pendingFiles || !pendingFiles.length || !activeId) return;
4012
- const targetDir = uploadDirInput.value.trim();
4013
- const filesToUpload = Array.from(pendingFiles);
4014
- closeUploadModal();
4015
-
4016
- let uploaded = 0;
4017
- let failed = 0;
4018
-
4019
- for (const file of filesToUpload) {
4020
- try {
4021
- const headers = {
4022
- 'Content-Type': file.type || 'application/octet-stream',
4023
- 'X-Filename': file.name,
4024
- };
4025
- if (targetDir) headers['X-Target-Dir'] = targetDir;
4026
- const res = await fetch(`/api/sessions/${activeId}/upload`, {
4027
- method: 'POST',
4028
- headers,
4029
- body: file,
4030
- credentials: 'same-origin',
4031
- });
4032
- if (!res.ok) {
4033
- const err = await res.json().catch(() => ({}));
4034
- throw new Error(err.error || 'Upload failed');
4035
- }
4036
- await res.json();
4037
- uploaded++;
4038
- } catch (err) {
4039
- failed++;
4040
- console.error('Upload error:', file.name, err);
4041
- }
4042
- }
4043
-
4044
- if (uploaded > 0) {
4045
- const dir = targetDir || 'session directory';
4046
- showToast(`${uploaded} file${uploaded > 1 ? 's' : ''} uploaded to ${dir}`);
4047
- }
4048
- if (failed > 0) {
4049
- showToast(`${failed} file${failed > 1 ? 's' : ''} failed to upload`);
4050
- }
4051
- });
4052
- }
4053
-
4054
- // ===== New Session Modal =====
4055
- let shellsLoaded = false;
4056
-
4057
- function openNewSessionModal() {
4058
- loadShellsForModal();
4059
- document.getElementById('new-session-modal').classList.add('visible');
4060
- }
4061
-
4062
- function setupNewSessionModal() {
4063
- document.getElementById('tab-new-btn').addEventListener('click', openNewSessionModal);
4064
- document.getElementById('side-panel-new-btn').addEventListener('click', () => {
4065
- closeSidePanel();
4066
- openNewSessionModal();
4067
- });
4068
- document.getElementById('ns-cancel').addEventListener('click', () => {
4069
- document.getElementById('new-session-modal').classList.remove('visible');
4070
- });
4071
- document.getElementById('new-session-modal').addEventListener('click', (e) => {
4072
- if (e.target.id === 'new-session-modal')
4073
- document.getElementById('new-session-modal').classList.remove('visible');
4074
- });
4075
-
4076
- // Color picker
4077
- document.getElementById('ns-color-picker').addEventListener('click', (e) => {
4078
- const swatch = e.target.closest('.color-swatch');
4079
- if (!swatch) return;
4080
- document
4081
- .querySelectorAll('#ns-color-picker .color-swatch')
4082
- .forEach((s) => s.classList.remove('selected'));
4083
- swatch.classList.add('selected');
4084
- });
4085
-
4086
- document.getElementById('ns-create').addEventListener('click', createNewSession);
4087
-
4088
- // Folder browser
4089
- const nsCwdInput = document.getElementById('ns-cwd');
4090
- const nsBrowserOverlay = document.getElementById('ns-browser-overlay');
4091
- const nsBrowserList = document.getElementById('ns-browser-list');
4092
- const nsBrowserBreadcrumb = document.getElementById('ns-browser-breadcrumb');
4093
- const nsBrowserPath = document.getElementById('ns-browser-path');
4094
- let nsBrowsePath = '/';
4095
- let serverCwd = '/';
4096
-
4097
- document.getElementById('ns-browse-btn').addEventListener('click', async () => {
4098
- if (serverCwd === '/') {
4099
- try {
4100
- const data = await fetch('/api/shells').then((r) => {
4101
- if (!r.ok) throw new Error(`${r.status}`);
4102
- return r.json();
4103
- });
4104
- if (data.cwd) serverCwd = data.cwd;
4105
- } catch {}
4106
- }
4107
- const initial = nsCwdInput.value.trim() || serverCwd;
4108
- nsBrowseNavigate(initial);
4109
- nsBrowserOverlay.classList.add('visible');
4110
- });
4111
-
4112
- document.getElementById('ns-browser-close').addEventListener('click', () => {
4113
- nsBrowserOverlay.classList.remove('visible');
4114
- });
4115
- nsBrowserOverlay.addEventListener('click', (e) => {
4116
- if (e.target === nsBrowserOverlay) nsBrowserOverlay.classList.remove('visible');
4117
- });
4118
-
4119
- document.getElementById('ns-browser-select').addEventListener('click', () => {
4120
- nsCwdInput.value = nsBrowsePath;
4121
- nsBrowserOverlay.classList.remove('visible');
4122
- });
4123
-
4124
- async function nsBrowseNavigate(dir) {
4125
- nsBrowsePath = dir;
4126
- nsBrowserPath.textContent = dir;
4127
- nsBrowseRenderBreadcrumb(dir);
4128
- nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
4129
- try {
4130
- const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
4131
- if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
4132
- const data = await res.json();
4133
- let items = '';
4134
- // Add parent (..) entry unless at root
4135
- const parent =
4136
- dir.replace(/[/\\][^/\\]+$/, '') ||
4137
- (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
4138
- if (parent && parent !== dir) {
4139
- items += `<div class="folder-item" data-path="${escAttr(parent)}">
4140
- <span class="folder-icon">📁</span>
4141
- <span class="folder-name">..</span>
4142
- <span class="folder-arrow">›</span>
4143
- </div>`;
4144
- }
4145
- items += data.dirs
4146
- .map((d) => {
4147
- const name = d.split(/[/\\]/).pop();
4148
- return `<div class="folder-item" data-path="${escAttr(d)}">
4149
- <span class="folder-icon">📁</span>
4150
- <span class="folder-name">${esc(name)}</span>
4151
- <span class="folder-arrow">›</span>
4152
- </div>`;
4153
- })
4154
- .join('');
4155
- nsBrowserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
4156
- nsBrowserList.querySelectorAll('.folder-item').forEach((el) => {
4157
- el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
4158
- });
4159
- nsBrowserList.scrollTop = 0;
4160
- } catch {
4161
- nsBrowserList.innerHTML = '<div class="browser-empty">Error loading folders</div>';
4162
- }
4163
- }
4164
-
4165
- function nsBrowseRenderBreadcrumb(dir) {
4166
- const sep = dir.includes('\\') ? '\\' : '/';
4167
- const parts = dir.split(/[/\\]/).filter(Boolean);
4168
- const isWindows = /^[A-Za-z]:/.test(dir);
4169
- let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
4170
- let accumulated = isWindows ? '' : '';
4171
- parts.forEach((part, i) => {
4172
- accumulated += (i === 0 && isWindows ? '' : sep) + part;
4173
- const isCurrent = i === parts.length - 1;
4174
- if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
4175
- html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${escAttr(accumulated)}">${esc(part)}</button>`;
4176
- });
4177
- nsBrowserBreadcrumb.innerHTML = html;
4178
- nsBrowserBreadcrumb.querySelectorAll('.crumb').forEach((el) => {
4179
- el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
4180
- });
4181
- nsBrowserBreadcrumb.scrollLeft = nsBrowserBreadcrumb.scrollWidth;
4182
- }
4183
- }
4184
-
4185
- async function loadShellsForModal() {
4186
- if (shellsLoaded) return;
4187
- const sel = document.getElementById('ns-shell');
4188
- try {
4189
- const data = await fetch('/api/shells').then((r) => {
4190
- if (!r.ok) throw new Error(`${r.status}`);
4191
- return r.json();
4192
- });
4193
- if (data.cwd) {
4194
- serverCwd = data.cwd;
4195
- document.getElementById('ns-cwd').placeholder = data.cwd;
4196
- }
4197
- sel.innerHTML = data.shells
4198
- .map(
4199
- (s) =>
4200
- '<option value="' +
4201
- escAttr(s.cmd) +
4202
- '"' +
4203
- (s.cmd === data.default ? ' selected' : '') +
4204
- '>' +
4205
- esc(s.name) +
4206
- ' (' +
4207
- esc(s.cmd) +
4208
- ')</option>',
4209
- )
4210
- .join('');
4211
- shellsLoaded = true;
4212
- } catch {
4213
- sel.innerHTML = '<option value="">Could not detect shells</option>';
4214
- }
4215
- }
4216
-
4217
- async function createNewSession() {
4218
- const name = document.getElementById('ns-name').value.trim();
4219
- const shell = document.getElementById('ns-shell').value;
4220
- const cwd = document.getElementById('ns-cwd').value.trim();
4221
- const cmd = document.getElementById('ns-cmd').value.trim();
4222
- const colorEl = document.querySelector('#ns-color-picker .color-swatch.selected');
4223
- const color = colorEl ? colorEl.dataset.color : null;
4224
-
4225
- const body = {};
4226
- if (name) body.name = name;
4227
- if (shell) body.shell = shell;
4228
- if (cwd) body.cwd = cwd;
4229
- if (cmd) body.initialCommand = cmd;
4230
- if (color) body.color = color;
4231
-
4232
- // Include current terminal dimensions so the PTY spawns at the right
4233
- // size — prevents oh-my-posh and other slow prompts from rendering
4234
- // at the default 120×30 size and triggering a duplicate on SIGWINCH.
4235
- const activeMs = managed.get(activeId);
4236
- if (activeMs && activeMs.fitAddon) {
4237
- const dims = activeMs.fitAddon.proposeDimensions();
4238
- if (dims) {
4239
- body.cols = dims.cols;
4240
- body.rows = dims.rows;
4241
- }
4242
- }
4243
-
4244
- try {
4245
- const res = await fetch('/api/sessions', {
4246
- method: 'POST',
4247
- headers: { 'Content-Type': 'application/json' },
4248
- body: JSON.stringify(body),
4249
- });
4250
- if (!res.ok) {
4251
- console.error(`Failed to create session: ${res.status}`);
4252
- return;
4253
- }
4254
- const data = await res.json();
4255
-
4256
- // Fetch full session list to get the new session data
4257
- const listRes = await fetch('/api/sessions');
4258
- if (!listRes.ok) throw new Error(`Failed to list sessions: ${listRes.status}`);
4259
- const list = await listRes.json();
4260
- const newSession = list.find((s) => s.id === data.id);
4261
- if (newSession) {
4262
- addSession(newSession);
4263
- activateSession(data.id);
4264
- }
4265
-
4266
- document.getElementById('new-session-modal').classList.remove('visible');
4267
- document.getElementById('ns-name').value = '';
4268
- document.getElementById('ns-cmd').value = '';
4269
- document.getElementById('ns-cwd').value = '';
4270
- } catch (err) {
4271
- console.error('Failed to create session:', err);
4272
- }
4273
- }
4274
-
4275
- // ===== Polling =====
4276
- function startPolling() {
4277
- setInterval(async () => {
4278
- try {
4279
- const pollRes = await fetch('/api/sessions');
4280
- if (!pollRes.ok) throw new Error(`${pollRes.status}`);
4281
- const list = await pollRes.json();
4282
- const serverIds = new Set(list.map((s) => s.id));
4283
-
4284
- // Add new sessions created elsewhere
4285
- for (const s of list) {
4286
- if (!managed.has(s.id)) {
4287
- addSession(s);
4288
- } else {
4289
- // Update metadata
4290
- const ms = managed.get(s.id);
4291
- ms.name = s.name;
4292
- ms.color = s.color;
4293
- ms.cwd = s.cwd;
4294
- ms.lastActivity = s.lastActivity;
4295
- ms.git = s.git || null;
4296
- }
4297
- }
4298
-
4299
- // Remove sessions deleted elsewhere
4300
- for (const id of [...managed.keys()]) {
4301
- if (!serverIds.has(id)) {
4302
- const ms = managed.get(id);
4303
- ms.exited = true;
4304
- if (ms.reconnectTimer) {
4305
- clearTimeout(ms.reconnectTimer);
4306
- ms.reconnectTimer = null;
4307
- }
4308
- if (ms.silenceTimer) {
4309
- clearTimeout(ms.silenceTimer);
4310
- ms.silenceTimer = null;
4311
- }
4312
- if (ms.ws)
4313
- try {
4314
- ms.ws.close();
4315
- } catch {}
4316
- ms.term.dispose();
4317
- ms.container.remove();
4318
- managed.delete(id);
4319
- if (id === splitSecondId) splitSecondId = null;
4320
- if (id === activeId) {
4321
- const remaining = [...managed.keys()];
4322
- if (remaining.length > 0) activateSession(remaining[0]);
4323
- else {
4324
- window.location.replace('/');
4325
- return;
4326
- }
4327
- }
4328
- }
4329
- }
4330
-
4331
- renderTabs();
4332
- } catch {}
4333
- }, 3000);
4334
- }
4335
-
4336
- // ===== Share Button =====
4337
- function copyToClipboardFallback(text) {
4338
- const ta = document.createElement('textarea');
4339
- ta.value = text;
4340
- ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
4341
- document.body.appendChild(ta);
4342
- ta.focus();
4343
- ta.select();
4344
- let ok = false;
4345
- try {
4346
- ok = document.execCommand('copy');
4347
- } catch {}
4348
- document.body.removeChild(ta);
4349
- return ok;
4350
- }
4351
-
4352
- function showShareUrlPrompt(url) {
4353
- const overlay = document.createElement('div');
4354
- overlay.style.cssText =
4355
- 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:300;display:flex;align-items:center;justify-content:center;';
4356
- const box = document.createElement('div');
4357
- box.style.cssText =
4358
- 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:90vw;width:360px;text-align:center;';
4359
- box.innerHTML =
4360
- '<div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:12px;">Copy this link</div>';
4361
- const input = document.createElement('input');
4362
- input.type = 'text';
4363
- input.readOnly = true;
4364
- input.value = url;
4365
- input.style.cssText =
4366
- 'width:100%;box-sizing:border-box;padding:8px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-bottom:12px;';
4367
- box.appendChild(input);
4368
- const btn = document.createElement('button');
4369
- btn.textContent = 'Close';
4370
- btn.style.cssText =
4371
- 'padding:6px 20px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;';
4372
- btn.onclick = () => overlay.remove();
4373
- box.appendChild(btn);
4374
- overlay.appendChild(box);
4375
- overlay.addEventListener('click', (e) => {
4376
- if (e.target === overlay) overlay.remove();
4377
- });
4378
- document.body.appendChild(overlay);
4379
- input.focus();
4380
- input.select();
4381
- }
4382
-
4383
- function openPreviewModal() {
4384
- const modal = document.getElementById('preview-modal');
4385
- const input = document.getElementById('preview-port-input');
4386
- const status = document.getElementById('preview-detect-status');
4387
- const hint = document.getElementById('preview-hint');
4388
- const hintText = document.getElementById('preview-hint-text');
4389
- input.value = '';
4390
- hint.style.display = 'none';
4391
- status.textContent = '';
4392
- modal.classList.add('visible');
4393
- input.focus();
4394
-
4395
- if (activeId) {
4396
- status.textContent = 'detecting…';
4397
- fetch('/api/sessions/' + activeId + '/detect-port')
4398
- .then((r) => (r.ok ? r.json() : null))
4399
- .then((data) => {
4400
- status.textContent = '';
4401
- if (data && data.detected) {
4402
- input.value = data.port;
4403
- input.select();
4404
- hintText.textContent = 'Detected port ' + data.port + ' from terminal output';
4405
- hint.style.display = 'flex';
4406
- }
4407
- })
4408
- .catch(() => {
4409
- status.textContent = '';
4410
- });
4411
- }
4412
- }
4413
-
4414
- function submitPreview() {
4415
- const input = document.getElementById('preview-port-input');
4416
- const port = parseInt(input.value, 10);
4417
- if (isNaN(port) || port < 1 || port > 65535) {
4418
- input.style.borderColor = '#f87171';
4419
- input.focus();
4420
- setTimeout(() => (input.style.borderColor = ''), 1500);
4421
- return;
4422
- }
4423
- window.open('/preview/' + port + '/', '_blank');
4424
- document.getElementById('preview-modal').classList.remove('visible');
4425
- }
4426
-
4427
- function setupPreviewModal() {
4428
- document.getElementById('preview-cancel').addEventListener('click', () => {
4429
- document.getElementById('preview-modal').classList.remove('visible');
4430
- });
4431
- document.getElementById('preview-open').addEventListener('click', submitPreview);
4432
- document.getElementById('preview-port-input').addEventListener('keydown', (e) => {
4433
- if (e.key === 'Enter') submitPreview();
4434
- });
4435
- document.getElementById('preview-modal').addEventListener('click', (e) => {
4436
- if (e.target.id === 'preview-modal')
4437
- document.getElementById('preview-modal').classList.remove('visible');
4438
- });
4439
- }
4440
-
4441
- async function shareLink() {
4442
- const urlPromise = fetch('/api/share-token')
4443
- .then((r) => (r.ok ? r.json() : null))
4444
- .then((data) => (data && data.url) || location.href)
4445
- .catch(() => location.href);
4446
- if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
4447
- try {
4448
- const blobPromise = urlPromise.then((u) => new Blob([u], { type: 'text/plain' }));
4449
- await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blobPromise })]);
4450
- showToast('Link copied!');
4451
- return;
4452
- } catch {}
4453
- }
4454
- const url = await urlPromise;
4455
- if (navigator.clipboard && navigator.clipboard.writeText) {
4456
- try {
4457
- await navigator.clipboard.writeText(url);
4458
- showToast('Link copied!');
4459
- return;
4460
- } catch {}
4461
- }
4462
- if (copyToClipboardFallback(url)) {
4463
- showToast('Link copied!');
4464
- } else {
4465
- showShareUrlPrompt(url);
4466
- }
4467
- }
4468
-
4469
- async function refreshApp() {
4470
- if ('caches' in window) {
4471
- const keys = await caches.keys();
4472
- await Promise.all(keys.map((k) => caches.delete(k)));
4473
- }
4474
- if (navigator.serviceWorker) {
4475
- const reg = await navigator.serviceWorker.getRegistration();
4476
- if (reg) await reg.update();
4477
- }
4478
- location.reload();
4479
- }
4480
-
4481
- // ===== Command Palette =====
4482
- (function setupPalette() {
4483
- const paletteActions = [
4484
- {
4485
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
4486
- label: 'New tab',
4487
- category: 'Session',
4488
- action: () => openNewSessionModal(),
4489
- },
4490
- {
4491
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
4492
- label: 'Upload files',
4493
- category: 'Session',
4494
- action: () => {
4495
- if (!activeId) {
4496
- showToast('No active session');
4497
- return;
4498
- }
4499
- document.getElementById('upload-input').value = '';
4500
- document.getElementById('upload-input').click();
4501
- },
4502
- },
4503
- {
4504
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
4505
- label: 'Close tab',
4506
- category: 'Session',
4507
- action: () => {
4508
- if (!activeId) return;
4509
- const ms = managed.get(activeId);
4510
- const name = (ms && ms.name) || activeId.slice(0, 8);
4511
- if (confirm('Close session "' + name + '"?')) {
4512
- removeSession(activeId);
4513
- }
4514
- },
4515
- },
4516
- {
4517
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>',
4518
- label: 'Rename session',
4519
- category: 'Session',
4520
- action: () => {
4521
- if (!activeId) return;
4522
- const ms = managed.get(activeId);
4523
- if (!ms) return;
4524
- const name = prompt('Rename session:', ms.name || '');
4525
- if (name !== null && name.trim()) {
4526
- ms.name = name.trim();
4527
- renderTabs();
4528
- updateStatusBar();
4529
- }
4530
- },
4531
- },
4532
- {
4533
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>',
4534
- label: 'Split view',
4535
- category: 'Session',
4536
- action: () => toggleSplit(),
4537
- },
4538
- {
4539
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
4540
- label: 'Stop session',
4541
- category: 'Session',
4542
- action: () => {
4543
- if (!activeId) return;
4544
- if (!confirm('Stop this session? The process will be killed.')) return;
4545
- removeSession(activeId);
4546
- },
4547
- },
4548
- {
4549
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
4550
- label: 'Find in terminal',
4551
- category: 'Search',
4552
- action: () => openSearchBar(),
4553
- },
4554
- {
4555
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>',
4556
- label: 'Increase font size',
4557
- category: 'View',
4558
- action: () => applyZoom(fontSize + 1),
4559
- },
4560
- {
4561
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg>',
4562
- label: 'Decrease font size',
4563
- category: 'View',
4564
- action: () => applyZoom(fontSize - 1),
4565
- },
4566
- {
4567
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>',
4568
- get label() {
4569
- const current = THEMES.find((x) => x.id === getTheme()) || THEMES[0];
4570
- return 'Theme (' + current.name + ')';
4571
- },
4572
- category: 'View',
4573
- action: () => {
4574
- openThemeSubpanel();
4575
- },
4576
- },
4577
- {
4578
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
4579
- label: 'Preview port',
4580
- category: 'View',
4581
- action: () => openPreviewModal(),
4582
- },
4583
- {
4584
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
4585
- label: 'Copy link',
4586
- category: 'Share',
4587
- action: shareLink,
4588
- },
4589
- {
4590
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>',
4591
- get label() {
4592
- return notificationsEnabled ? 'Notifications (on)' : 'Notifications (off)';
4593
- },
4594
- category: 'Notifications',
4595
- keepOpen: true,
4596
- action: () => {
4597
- notificationsEnabled = !notificationsEnabled;
4598
- localStorage.setItem('termbeam-notifications', notificationsEnabled);
4599
- if (
4600
- notificationsEnabled &&
4601
- 'Notification' in window &&
4602
- Notification.permission === 'default'
4603
- ) {
4604
- Notification.requestPermission();
4605
- }
4606
- showToast(notificationsEnabled ? 'Notifications on' : 'Notifications off');
4607
- renderPalette();
4608
- },
4609
- },
4610
- {
4611
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>',
4612
- label: 'Refresh',
4613
- category: 'System',
4614
- action: () => refreshApp(),
4615
- },
4616
- {
4617
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><line x1="18" y1="9" x2="12" y2="15"/><line x1="12" y1="9" x2="18" y2="15"/></svg>',
4618
- label: 'Clear terminal',
4619
- category: 'System',
4620
- action: () => {
4621
- if (!activeId) return;
4622
- const ms = managed.get(activeId);
4623
- if (ms) ms.term.clear();
4624
- },
4625
- },
4626
- {
4627
- icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
4628
- label: 'About',
4629
- category: 'System',
4630
- action: () => {
4631
- const ver = window._termbeamVersion || 'TermBeam';
4632
- const overlay = document.createElement('div');
4633
- overlay.style.cssText =
4634
- 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:300;display:flex;align-items:center;justify-content:center;';
4635
- const box = document.createElement('div');
4636
- box.style.cssText =
4637
- 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:90vw;width:320px;text-align:center;';
4638
- box.innerHTML =
4639
- '<div style="font-size:24px;margin-bottom:8px;">⚡</div>' +
4640
- '<div style="font-size:16px;font-weight:600;color:var(--text);margin-bottom:4px;">TermBeam</div>' +
4641
- '<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">' +
4642
- esc(ver) +
4643
- '</div>' +
4644
- '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:16px;">Terminal in your browser, optimized for mobile.</div>' +
4645
- '<div style="display:flex;gap:16px;justify-content:center;margin-bottom:16px;">' +
4646
- '<a href="https://github.com/dorlugasigal/TermBeam" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">GitHub</a>' +
4647
- '<a href="https://dorlugasigal.github.io/TermBeam/" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Docs</a>' +
4648
- '<a href="https://termbeam.pages.dev" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Website</a>' +
4649
- '</div>' +
4650
- '<div id="about-update-area" style="margin-bottom:12px;"></div>';
4651
- const updateArea = box.querySelector('#about-update-area');
4652
- const updateBtn = document.createElement('button');
4653
- updateBtn.textContent = 'Check for updates';
4654
- updateBtn.style.cssText =
4655
- 'padding:6px 16px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);font-size:12px;cursor:pointer;';
4656
- updateBtn.onclick = async () => {
4657
- updateBtn.textContent = 'Checking...';
4658
- updateBtn.disabled = true;
4659
- updateBtn.style.cursor = 'default';
4660
- try {
4661
- const res = await fetch('/api/update-check?force=true');
4662
- if (!res.ok) throw new Error();
4663
- const info = await res.json();
4664
- if (info.updateAvailable && info.latest) {
4665
- const cmd = info.command || 'npm install -g termbeam@latest';
4666
- updateArea.innerHTML = '';
4667
- const status = document.createElement('div');
4668
- status.style.cssText = 'font-size:12px;color:var(--accent);margin-bottom:8px;';
4669
- status.textContent = 'v' + info.latest + ' available';
4670
- updateArea.appendChild(status);
4671
- const cmdRow = document.createElement('div');
4672
- cmdRow.style.cssText =
4673
- 'display:flex;align-items:center;justify-content:center;gap:6px;';
4674
- const cmdText = document.createElement('code');
4675
- cmdText.textContent = cmd;
4676
- cmdText.style.cssText =
4677
- 'font-size:11px;color:var(--accent);background:var(--bg);padding:4px 8px;border-radius:4px;border:1px solid var(--border);';
4678
- const copyBtn = document.createElement('button');
4679
- copyBtn.textContent = 'Copy';
4680
- copyBtn.style.cssText =
4681
- 'padding:4px 10px;border-radius:4px;border:1px solid var(--accent);background:transparent;color:var(--accent);font-size:11px;cursor:pointer;';
4682
- copyBtn.onclick = () => {
4683
- const onSuccess = () => {
4684
- copyBtn.textContent = 'Copied!';
4685
- setTimeout(() => {
4686
- copyBtn.textContent = 'Copy';
4687
- }, 2000);
4688
- };
4689
- if (navigator.clipboard && navigator.clipboard.writeText) {
4690
- navigator.clipboard
4691
- .writeText(cmd)
4692
- .then(onSuccess)
4693
- .catch(() => {
4694
- copyFallback(cmd);
4695
- onSuccess();
4696
- });
4697
- } else {
4698
- copyFallback(cmd);
4699
- onSuccess();
4700
- }
4701
- };
4702
- cmdRow.appendChild(cmdText);
4703
- cmdRow.appendChild(copyBtn);
4704
- updateArea.appendChild(cmdRow);
4705
- } else {
4706
- updateBtn.textContent = 'Up to date';
4707
- updateBtn.style.color = '#4ec9b0';
4708
- updateBtn.style.borderColor = '#4ec9b0';
4709
- }
4710
- } catch {
4711
- updateBtn.textContent = 'Check failed — try again';
4712
- updateBtn.disabled = false;
4713
- updateBtn.style.cursor = 'pointer';
4714
- }
4715
- };
4716
- updateArea.appendChild(updateBtn);
4717
- const btn = document.createElement('button');
4718
- btn.textContent = 'Close';
4719
- btn.style.cssText =
4720
- 'padding:6px 20px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;';
4721
- btn.onclick = () => overlay.remove();
4722
- box.appendChild(btn);
4723
- overlay.appendChild(box);
4724
- overlay.addEventListener('click', (e) => {
4725
- if (e.target === overlay) overlay.remove();
4726
- });
4727
- document.body.appendChild(overlay);
4728
- },
4729
- },
4730
- ];
4731
-
4732
- const backdrop = document.getElementById('palette-backdrop');
4733
- const panel = document.getElementById('palette-panel');
4734
- const body = document.getElementById('palette-body');
4735
-
4736
- function renderPalette() {
4737
- const grouped = {};
4738
- paletteActions.forEach((a) => {
4739
- if (!grouped[a.category]) grouped[a.category] = [];
4740
- grouped[a.category].push(a);
4741
- });
4742
- body.innerHTML = '';
4743
- Object.keys(grouped).forEach((cat) => {
4744
- const header = document.createElement('div');
4745
- header.className = 'palette-category';
4746
- header.textContent = cat;
4747
- body.appendChild(header);
4748
- grouped[cat].forEach((a) => {
4749
- const btn = document.createElement('button');
4750
- btn.className = 'palette-action';
4751
- btn.innerHTML =
4752
- '<span class="palette-action-icon">' + a.icon + '</span>' + esc(a.label);
4753
- btn.addEventListener('click', () => {
4754
- a.action();
4755
- if (!a.keepOpen) closePalette();
4756
- });
4757
- body.appendChild(btn);
4758
- });
4759
- });
4760
- }
4761
-
4762
- function openPalette() {
4763
- backdrop.classList.add('open');
4764
- panel.classList.add('open');
4765
- }
4766
-
4767
- function closePalette() {
4768
- backdrop.classList.remove('open');
4769
- panel.classList.remove('open');
4770
- }
4771
-
4772
- function togglePalette() {
4773
- if (panel.classList.contains('open')) closePalette();
4774
- else openPalette();
4775
- }
4776
-
4777
- backdrop.addEventListener('click', closePalette);
4778
- document.getElementById('palette-close').addEventListener('click', closePalette);
4779
- document.getElementById('palette-trigger').addEventListener('click', togglePalette);
4780
-
4781
- document.addEventListener('keydown', (e) => {
4782
- if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
4783
- e.preventDefault();
4784
- togglePalette();
4785
- }
4786
- if (e.key === 'Escape' && panel.classList.contains('open')) {
4787
- closePalette();
4788
- }
4789
- });
4790
-
4791
- renderPalette();
4792
- })();
4793
-
4794
- // ===== Theme Sub-Panel =====
4795
- (function setupThemeSubpanel() {
4796
- const subpanel = document.getElementById('theme-subpanel');
4797
- const list = document.getElementById('theme-subpanel-list');
4798
- document.getElementById('theme-subpanel-close').addEventListener('click', () => {
4799
- subpanel.classList.remove('open');
4800
- });
4801
- function renderThemeList() {
4802
- const cur = getTheme();
4803
- list.innerHTML = THEMES.map(
4804
- (t) =>
4805
- '<button class="theme-subpanel-item' +
4806
- (t.id === cur ? ' active' : '') +
4807
- '" data-tid="' +
4808
- t.id +
4809
- '"><span class="theme-subpanel-swatch" style="background:' +
4810
- t.bg +
4811
- '"></span>' +
4812
- esc(t.name) +
4813
- '</button>',
4814
- ).join('');
4815
- list.querySelectorAll('.theme-subpanel-item').forEach((btn) => {
4816
- btn.addEventListener('click', () => {
4817
- applyTheme(btn.dataset.tid);
4818
- renderThemeList();
4819
- });
4820
- });
4821
- }
4822
- window.openThemeSubpanel = function () {
4823
- renderThemeList();
4824
- subpanel.classList.add('open');
4825
- };
4826
- document.addEventListener('keydown', (e) => {
4827
- if (e.key === 'Escape' && subpanel.classList.contains('open')) {
4828
- subpanel.classList.remove('open');
4829
- }
4830
- });
4831
- })();
4832
-
4833
- // ===== Service Worker =====
4834
- registerServiceWorker();
4835
- </script>
4836
- </body>
4837
- </html>