@mjasano/devtunnel 1.2.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -5,766 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>DevTunnel</title>
7
7
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
8
- <style>
9
- * {
10
- margin: 0;
11
- padding: 0;
12
- box-sizing: border-box;
13
- }
14
-
15
- body {
16
- background-color: #0d1117;
17
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
- height: 100vh;
19
- display: flex;
20
- flex-direction: column;
21
- color: #c9d1d9;
22
- }
23
-
24
- .header {
25
- background-color: #161b22;
26
- padding: 12px 20px;
27
- display: flex;
28
- align-items: center;
29
- justify-content: space-between;
30
- border-bottom: 1px solid #30363d;
31
- }
32
-
33
- .header h1 {
34
- color: #fff;
35
- font-size: 18px;
36
- font-weight: 600;
37
- display: flex;
38
- align-items: center;
39
- gap: 10px;
40
- }
41
-
42
- .header h1 .logo {
43
- width: 24px;
44
- height: 24px;
45
- background: linear-gradient(135deg, #58a6ff, #8b5cf6);
46
- border-radius: 6px;
47
- }
48
-
49
- .header-right {
50
- display: flex;
51
- align-items: center;
52
- gap: 16px;
53
- }
54
-
55
- .session-indicator {
56
- display: flex;
57
- align-items: center;
58
- gap: 8px;
59
- padding: 6px 12px;
60
- background-color: #21262d;
61
- border-radius: 6px;
62
- font-size: 12px;
63
- color: #8b949e;
64
- }
65
-
66
- .session-indicator .dot {
67
- width: 8px;
68
- height: 8px;
69
- border-radius: 50%;
70
- background-color: #3fb950;
71
- }
72
-
73
- .status {
74
- display: flex;
75
- align-items: center;
76
- gap: 8px;
77
- color: #8b949e;
78
- font-size: 13px;
79
- }
80
-
81
- .status-dot {
82
- width: 8px;
83
- height: 8px;
84
- border-radius: 50%;
85
- background-color: #f85149;
86
- }
87
-
88
- .status-dot.connected {
89
- background-color: #3fb950;
90
- }
91
-
92
- .main-container {
93
- flex: 1;
94
- display: flex;
95
- overflow: hidden;
96
- }
97
-
98
- #terminal-container {
99
- flex: 1;
100
- padding: 10px;
101
- background-color: #0d1117;
102
- overflow: hidden;
103
- }
104
-
105
- #terminal {
106
- height: 100%;
107
- }
108
-
109
- .sidebar {
110
- width: 320px;
111
- background-color: #161b22;
112
- border-left: 1px solid #30363d;
113
- display: flex;
114
- flex-direction: column;
115
- }
116
-
117
- .sidebar-section {
118
- border-bottom: 1px solid #30363d;
119
- }
120
-
121
- .sidebar-header {
122
- padding: 12px 16px;
123
- display: flex;
124
- align-items: center;
125
- justify-content: space-between;
126
- cursor: pointer;
127
- }
128
-
129
- .sidebar-header:hover {
130
- background-color: #21262d;
131
- }
132
-
133
- .sidebar-header h2 {
134
- font-size: 13px;
135
- font-weight: 600;
136
- color: #c9d1d9;
137
- text-transform: uppercase;
138
- letter-spacing: 0.5px;
139
- }
140
-
141
- .sidebar-header .count {
142
- background-color: #30363d;
143
- padding: 2px 8px;
144
- border-radius: 10px;
145
- font-size: 11px;
146
- color: #8b949e;
147
- }
148
-
149
- .sidebar-content {
150
- padding: 8px;
151
- max-height: 200px;
152
- overflow-y: auto;
153
- }
154
-
155
- .tunnel-form {
156
- padding: 12px;
157
- display: flex;
158
- gap: 8px;
159
- }
160
-
161
- .tunnel-form input {
162
- flex: 1;
163
- padding: 8px 12px;
164
- background-color: #0d1117;
165
- border: 1px solid #30363d;
166
- border-radius: 6px;
167
- color: #c9d1d9;
168
- font-size: 13px;
169
- }
170
-
171
- .tunnel-form input:focus {
172
- outline: none;
173
- border-color: #58a6ff;
174
- }
175
-
176
- .tunnel-form input::placeholder {
177
- color: #6e7681;
178
- }
179
-
180
- .btn {
181
- padding: 8px 16px;
182
- border-radius: 6px;
183
- border: none;
184
- font-size: 13px;
185
- font-weight: 500;
186
- cursor: pointer;
187
- transition: all 0.15s ease;
188
- }
189
-
190
- .btn-primary {
191
- background-color: #238636;
192
- color: #fff;
193
- }
194
-
195
- .btn-primary:hover {
196
- background-color: #2ea043;
197
- }
198
-
199
- .btn-primary:disabled {
200
- background-color: #21262d;
201
- color: #484f58;
202
- cursor: not-allowed;
203
- }
204
-
205
- .btn-sm {
206
- padding: 4px 8px;
207
- font-size: 12px;
208
- }
209
-
210
- .btn-danger {
211
- background-color: transparent;
212
- color: #f85149;
213
- }
214
-
215
- .btn-danger:hover {
216
- background-color: rgba(248, 81, 73, 0.1);
217
- }
218
-
219
- .btn-ghost {
220
- background-color: transparent;
221
- color: #8b949e;
222
- }
223
-
224
- .btn-ghost:hover {
225
- background-color: #21262d;
226
- color: #c9d1d9;
227
- }
228
-
229
- .item-card {
230
- background-color: #0d1117;
231
- border: 1px solid #30363d;
232
- border-radius: 8px;
233
- padding: 10px 12px;
234
- margin-bottom: 8px;
235
- }
236
-
237
- .item-card.active {
238
- border-color: #58a6ff;
239
- }
240
-
241
- .item-card-header {
242
- display: flex;
243
- align-items: center;
244
- justify-content: space-between;
245
- margin-bottom: 6px;
246
- }
247
-
248
- .item-title {
249
- font-weight: 600;
250
- font-size: 13px;
251
- display: flex;
252
- align-items: center;
253
- gap: 8px;
254
- }
255
-
256
- .item-status {
257
- font-size: 10px;
258
- padding: 2px 6px;
259
- border-radius: 10px;
260
- font-weight: 500;
261
- }
262
-
263
- .item-status.active {
264
- background-color: rgba(46, 160, 67, 0.2);
265
- color: #3fb950;
266
- }
267
-
268
- .item-status.connecting {
269
- background-color: rgba(187, 128, 9, 0.2);
270
- color: #d29922;
271
- }
272
-
273
- .item-status.error, .item-status.stopped {
274
- background-color: rgba(110, 118, 129, 0.2);
275
- color: #8b949e;
276
- }
277
-
278
- .item-meta {
279
- font-size: 11px;
280
- color: #6e7681;
281
- }
282
-
283
- .item-url {
284
- display: flex;
285
- align-items: center;
286
- gap: 6px;
287
- margin-top: 8px;
288
- }
289
-
290
- .item-url input {
291
- flex: 1;
292
- padding: 5px 8px;
293
- background-color: #161b22;
294
- border: 1px solid #30363d;
295
- border-radius: 4px;
296
- color: #58a6ff;
297
- font-size: 11px;
298
- font-family: monospace;
299
- }
300
-
301
- .item-url input:focus {
302
- outline: none;
303
- }
304
-
305
- .btn-copy {
306
- padding: 5px 8px;
307
- background-color: #21262d;
308
- color: #c9d1d9;
309
- border: 1px solid #30363d;
310
- border-radius: 4px;
311
- font-size: 11px;
312
- cursor: pointer;
313
- }
314
-
315
- .btn-copy:hover {
316
- background-color: #30363d;
317
- }
318
-
319
- .btn-copy.copied {
320
- background-color: #238636;
321
- border-color: #238636;
322
- }
323
-
324
- .empty-state {
325
- padding: 24px 16px;
326
- text-align: center;
327
- color: #6e7681;
328
- font-size: 12px;
329
- }
330
-
331
- .reconnect-overlay {
332
- position: fixed;
333
- top: 0;
334
- left: 0;
335
- right: 0;
336
- bottom: 0;
337
- background-color: rgba(0, 0, 0, 0.8);
338
- display: none;
339
- justify-content: center;
340
- align-items: center;
341
- z-index: 1000;
342
- }
343
-
344
- .reconnect-overlay.show {
345
- display: flex;
346
- }
347
-
348
- .reconnect-box {
349
- background-color: #161b22;
350
- padding: 30px 40px;
351
- border-radius: 12px;
352
- text-align: center;
353
- border: 1px solid #30363d;
354
- }
355
-
356
- .reconnect-box h2 {
357
- color: #fff;
358
- margin-bottom: 12px;
359
- font-size: 18px;
360
- }
361
-
362
- .reconnect-box p {
363
- color: #8b949e;
364
- margin-bottom: 20px;
365
- font-size: 14px;
366
- }
367
-
368
- .reconnect-btn {
369
- background-color: #238636;
370
- color: #fff;
371
- border: none;
372
- padding: 10px 24px;
373
- border-radius: 6px;
374
- cursor: pointer;
375
- font-size: 14px;
376
- font-weight: 500;
377
- }
378
-
379
- .reconnect-btn:hover {
380
- background-color: #2ea043;
381
- }
382
-
383
- .toast {
384
- position: fixed;
385
- bottom: 20px;
386
- right: 340px;
387
- background-color: #161b22;
388
- border: 1px solid #30363d;
389
- padding: 12px 16px;
390
- border-radius: 8px;
391
- display: none;
392
- align-items: center;
393
- gap: 10px;
394
- z-index: 1001;
395
- animation: slideIn 0.3s ease;
396
- }
397
-
398
- .toast.show {
399
- display: flex;
400
- }
401
-
402
- .toast.success {
403
- border-color: #238636;
404
- }
405
-
406
- .toast.error {
407
- border-color: #f85149;
408
- }
409
-
410
- /* Tab Bar */
411
- .tab-bar {
412
- display: flex;
413
- background-color: #161b22;
414
- border-bottom: 1px solid #30363d;
415
- padding: 0 12px;
416
- }
417
-
418
- .tab {
419
- padding: 10px 20px;
420
- font-size: 13px;
421
- color: #8b949e;
422
- cursor: pointer;
423
- border-bottom: 2px solid transparent;
424
- transition: all 0.15s ease;
425
- display: flex;
426
- align-items: center;
427
- gap: 8px;
428
- }
429
-
430
- .tab:hover {
431
- color: #c9d1d9;
432
- background-color: #21262d;
433
- }
434
-
435
- .tab.active {
436
- color: #c9d1d9;
437
- border-bottom-color: #58a6ff;
438
- }
439
-
440
- .tab-icon {
441
- font-size: 14px;
442
- }
443
-
444
- /* Editor Container */
445
- #editor-container {
446
- flex: 1;
447
- display: none;
448
- flex-direction: column;
449
- background-color: #0d1117;
450
- overflow: hidden;
451
- }
452
-
453
- #editor-container.active {
454
- display: flex;
455
- }
456
-
457
- #terminal-container.hidden {
458
- display: none;
459
- }
460
-
461
- /* Terminal Tabs */
462
- .terminal-tabs {
463
- display: flex;
464
- background-color: #161b22;
465
- border-bottom: 1px solid #30363d;
466
- overflow-x: auto;
467
- min-height: 35px;
468
- align-items: center;
469
- }
470
-
471
- .terminal-tab {
472
- display: flex;
473
- align-items: center;
474
- gap: 8px;
475
- padding: 8px 12px;
476
- font-size: 12px;
477
- color: #8b949e;
478
- background-color: #0d1117;
479
- border-right: 1px solid #30363d;
480
- cursor: pointer;
481
- white-space: nowrap;
482
- }
483
-
484
- .terminal-tab:hover {
485
- background-color: #161b22;
486
- }
487
-
488
- .terminal-tab.active {
489
- color: #c9d1d9;
490
- background-color: #161b22;
491
- }
492
-
493
- .terminal-tab-close {
494
- width: 16px;
495
- height: 16px;
496
- display: flex;
497
- align-items: center;
498
- justify-content: center;
499
- border-radius: 4px;
500
- font-size: 14px;
501
- line-height: 1;
502
- }
503
-
504
- .terminal-tab-close:hover {
505
- background-color: #30363d;
506
- }
507
-
508
- .terminal-tab-new {
509
- width: 28px;
510
- height: 28px;
511
- margin: 0 4px;
512
- background-color: transparent;
513
- border: 1px solid #30363d;
514
- border-radius: 4px;
515
- color: #8b949e;
516
- font-size: 16px;
517
- cursor: pointer;
518
- display: flex;
519
- align-items: center;
520
- justify-content: center;
521
- }
522
-
523
- .terminal-tab-new:hover {
524
- background-color: #21262d;
525
- color: #c9d1d9;
526
- }
527
-
528
- .terminal-tab .tmux-badge {
529
- color: #3fb950;
530
- font-size: 10px;
531
- }
532
-
533
- .editor-tabs {
534
- display: flex;
535
- background-color: #161b22;
536
- border-bottom: 1px solid #30363d;
537
- overflow-x: auto;
538
- min-height: 35px;
539
- }
540
-
541
- .editor-tab {
542
- display: flex;
543
- align-items: center;
544
- gap: 8px;
545
- padding: 8px 12px;
546
- font-size: 12px;
547
- color: #8b949e;
548
- background-color: #0d1117;
549
- border-right: 1px solid #30363d;
550
- cursor: pointer;
551
- white-space: nowrap;
552
- }
553
-
554
- .editor-tab:hover {
555
- background-color: #161b22;
556
- }
557
-
558
- .editor-tab.active {
559
- color: #c9d1d9;
560
- background-color: #161b22;
561
- }
562
-
563
- .editor-tab.modified::after {
564
- content: '';
565
- width: 6px;
566
- height: 6px;
567
- background-color: #d29922;
568
- border-radius: 50%;
569
- }
570
-
571
- .editor-tab-close {
572
- width: 16px;
573
- height: 16px;
574
- display: flex;
575
- align-items: center;
576
- justify-content: center;
577
- border-radius: 4px;
578
- font-size: 14px;
579
- line-height: 1;
580
- }
581
-
582
- .editor-tab-close:hover {
583
- background-color: #30363d;
584
- }
585
-
586
- #monaco-editor {
587
- flex: 1;
588
- min-height: 0;
589
- }
590
-
591
- .editor-empty {
592
- flex: 1;
593
- display: flex;
594
- flex-direction: column;
595
- align-items: center;
596
- justify-content: center;
597
- color: #6e7681;
598
- font-size: 14px;
599
- }
600
-
601
- .editor-empty-icon {
602
- font-size: 48px;
603
- margin-bottom: 16px;
604
- opacity: 0.5;
605
- }
606
-
607
- /* File Tree */
608
- .file-tree {
609
- font-size: 12px;
610
- }
611
-
612
- .file-item {
613
- display: flex;
614
- align-items: center;
615
- gap: 6px;
616
- padding: 4px 8px;
617
- cursor: pointer;
618
- border-radius: 4px;
619
- }
620
-
621
- .file-item:hover {
622
- background-color: #21262d;
623
- }
624
-
625
- .file-item.active {
626
- background-color: rgba(56, 139, 253, 0.15);
627
- }
628
-
629
- .file-item-icon {
630
- width: 16px;
631
- text-align: center;
632
- flex-shrink: 0;
633
- }
634
-
635
- .file-item-name {
636
- overflow: hidden;
637
- text-overflow: ellipsis;
638
- white-space: nowrap;
639
- }
640
-
641
- .file-item.directory > .file-item-icon {
642
- color: #58a6ff;
643
- }
644
-
645
- .file-item.file > .file-item-icon {
646
- color: #8b949e;
647
- }
648
-
649
- .file-children {
650
- margin-left: 12px;
651
- }
652
-
653
- .breadcrumb {
654
- display: flex;
655
- align-items: center;
656
- gap: 4px;
657
- padding: 8px 12px;
658
- font-size: 11px;
659
- color: #8b949e;
660
- border-bottom: 1px solid #30363d;
661
- overflow-x: auto;
662
- }
663
-
664
- .breadcrumb-item {
665
- cursor: pointer;
666
- padding: 2px 4px;
667
- border-radius: 4px;
668
- }
669
-
670
- .breadcrumb-item:hover {
671
- background-color: #21262d;
672
- color: #c9d1d9;
673
- }
674
-
675
- .breadcrumb-sep {
676
- color: #484f58;
677
- }
678
-
679
- /* System Monitor */
680
- .system-stats {
681
- padding: 8px 12px;
682
- }
683
-
684
- .stat-row {
685
- display: flex;
686
- justify-content: space-between;
687
- align-items: center;
688
- padding: 6px 0;
689
- font-size: 12px;
690
- border-bottom: 1px solid #21262d;
691
- }
692
-
693
- .stat-row:last-child {
694
- border-bottom: none;
695
- }
696
-
697
- .stat-label {
698
- color: #8b949e;
699
- }
700
-
701
- .stat-value {
702
- color: #c9d1d9;
703
- font-family: monospace;
704
- }
705
-
706
- .stat-bar {
707
- width: 100%;
708
- height: 6px;
709
- background-color: #21262d;
710
- border-radius: 3px;
711
- margin-top: 4px;
712
- overflow: hidden;
713
- }
714
-
715
- .stat-bar-fill {
716
- height: 100%;
717
- border-radius: 3px;
718
- transition: width 0.3s ease;
719
- }
720
-
721
- .stat-bar-fill.cpu {
722
- background: linear-gradient(90deg, #58a6ff, #8b5cf6);
723
- }
724
-
725
- .stat-bar-fill.memory {
726
- background: linear-gradient(90deg, #3fb950, #58a6ff);
727
- }
728
-
729
- .stat-bar-fill.high {
730
- background: linear-gradient(90deg, #d29922, #f85149);
731
- }
732
-
733
- .system-hostname {
734
- font-size: 11px;
735
- color: #6e7681;
736
- padding: 4px 12px 8px;
737
- border-bottom: 1px solid #21262d;
738
- }
739
-
740
- @keyframes slideIn {
741
- from {
742
- transform: translateY(20px);
743
- opacity: 0;
744
- }
745
- to {
746
- transform: translateY(0);
747
- opacity: 1;
748
- }
749
- }
750
-
751
- @media (max-width: 768px) {
752
- .main-container {
753
- flex-direction: column;
754
- }
755
-
756
- .sidebar {
757
- width: 100%;
758
- max-height: 250px;
759
- border-left: none;
760
- border-top: 1px solid #30363d;
761
- }
762
-
763
- .toast {
764
- right: 20px;
765
- }
766
- }
767
- </style>
8
+ <link rel="stylesheet" href="/styles.css">
768
9
  </head>
769
10
  <body>
770
11
  <div class="header">
@@ -773,6 +14,10 @@
773
14
  DevTunnel
774
15
  </h1>
775
16
  <div class="header-right">
17
+ <div class="system-info" id="system-info">
18
+ <span class="system-stat"><span class="stat-icon">CPU</span> <span id="cpu-value">--%</span></span>
19
+ <span class="system-stat"><span class="stat-icon">MEM</span> <span id="mem-value">--%</span></span>
20
+ </div>
776
21
  <div class="session-indicator" id="session-indicator" style="display: none;">
777
22
  <span class="dot"></span>
778
23
  <span>Session: <span id="session-id-display">-</span></span>
@@ -781,6 +26,8 @@
781
26
  <div class="status-dot" id="status-dot"></div>
782
27
  <span id="status-text">Connecting...</span>
783
28
  </div>
29
+ <button class="btn-logout" id="logout-btn" style="display: none;" onclick="logout()">Logout</button>
30
+ <button class="mobile-sidebar-toggle" id="sidebar-toggle" onclick="toggleMobileSidebar()">&#9776;</button>
784
31
  </div>
785
32
  </div>
786
33
 
@@ -796,6 +43,7 @@
796
43
  </div>
797
44
  </div>
798
45
 
46
+ <div class="sidebar-overlay" id="sidebar-overlay" onclick="toggleMobileSidebar()"></div>
799
47
  <div class="main-container">
800
48
  <div id="terminal-container">
801
49
  <div class="terminal-tabs" id="terminal-tabs">
@@ -817,41 +65,15 @@
817
65
  </div>
818
66
 
819
67
  <div class="sidebar">
820
- <!-- System Monitor Section -->
821
- <div class="sidebar-section">
822
- <div class="sidebar-header" onclick="toggleSection('system')">
823
- <h2>System</h2>
824
- </div>
825
- <div id="system-content">
826
- <div class="system-hostname" id="system-hostname">Loading...</div>
827
- <div class="system-stats" id="system-stats">
828
- <div class="stat-row">
829
- <span class="stat-label">CPU</span>
830
- <span class="stat-value" id="cpu-value">--%</span>
831
- </div>
832
- <div class="stat-bar"><div class="stat-bar-fill cpu" id="cpu-bar" style="width: 0%"></div></div>
833
- <div class="stat-row" style="margin-top: 8px;">
834
- <span class="stat-label">Memory</span>
835
- <span class="stat-value" id="mem-value">--%</span>
836
- </div>
837
- <div class="stat-bar"><div class="stat-bar-fill memory" id="mem-bar" style="width: 0%"></div></div>
838
- <div class="stat-row" style="margin-top: 8px;">
839
- <span class="stat-label">Uptime</span>
840
- <span class="stat-value" id="uptime-value">--</span>
841
- </div>
842
- <div class="stat-row">
843
- <span class="stat-label">Load</span>
844
- <span class="stat-value" id="load-value">--</span>
845
- </div>
846
- </div>
847
- </div>
848
- </div>
849
-
68
+ <button class="sidebar-close" onclick="toggleMobileSidebar()">&times;</button>
850
69
  <!-- Files Section -->
851
70
  <div class="sidebar-section">
852
71
  <div class="sidebar-header" onclick="toggleSection('files')">
853
72
  <h2>Files</h2>
854
73
  </div>
74
+ <div class="file-search">
75
+ <input type="text" id="file-search-input" placeholder="Search files..." oninput="handleFileSearch(this.value)">
76
+ </div>
855
77
  <div class="breadcrumb" id="file-breadcrumb">
856
78
  <span class="breadcrumb-item" onclick="navigateToPath('')">~</span>
857
79
  </div>
@@ -897,716 +119,6 @@
897
119
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
898
120
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
899
121
  <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
900
- <script>
901
- const terminalContainer = document.getElementById('terminal');
902
- const statusDot = document.getElementById('status-dot');
903
- const statusText = document.getElementById('status-text');
904
- const reconnectOverlay = document.getElementById('reconnect-overlay');
905
- const portInput = document.getElementById('port-input');
906
- const createTunnelBtn = document.getElementById('create-tunnel-btn');
907
- const sessionIndicator = document.getElementById('session-indicator');
908
- const sessionIdDisplay = document.getElementById('session-id-display');
909
- const toast = document.getElementById('toast');
910
- const toastMessage = document.getElementById('toast-message');
911
-
912
- let tunnels = [];
913
- let sessions = [];
914
- let tmuxSessions = [];
915
- let currentSessionId = localStorage.getItem('devtunnel-session-id');
916
-
917
- // Editor state
918
- let monacoEditor = null;
919
- let openFiles = new Map(); // path -> { content, originalContent, model }
920
- let activeFilePath = null;
921
- let currentBrowsePath = '';
922
-
923
- // Initialize Monaco Editor
924
- require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' }});
925
- require(['vs/editor/editor.main'], function() {
926
- monaco.editor.defineTheme('github-dark', {
927
- base: 'vs-dark',
928
- inherit: true,
929
- rules: [],
930
- colors: {
931
- 'editor.background': '#0d1117',
932
- 'editor.foreground': '#c9d1d9',
933
- 'editorCursor.foreground': '#58a6ff',
934
- 'editor.lineHighlightBackground': '#161b22',
935
- 'editorLineNumber.foreground': '#6e7681',
936
- 'editor.selectionBackground': '#264f78',
937
- 'editorIndentGuide.background': '#21262d',
938
- }
939
- });
940
-
941
- monacoEditor = monaco.editor.create(document.getElementById('monaco-editor'), {
942
- value: '',
943
- language: 'plaintext',
944
- theme: 'github-dark',
945
- fontSize: 14,
946
- fontFamily: 'Menlo, Monaco, "Courier New", monospace',
947
- minimap: { enabled: false },
948
- automaticLayout: true,
949
- scrollBeyondLastLine: false,
950
- wordWrap: 'on',
951
- tabSize: 2,
952
- });
953
-
954
- // Ctrl+S to save
955
- monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
956
- if (activeFilePath) saveFile(activeFilePath);
957
- });
958
-
959
- // Track changes
960
- monacoEditor.onDidChangeModelContent(() => {
961
- if (activeFilePath && openFiles.has(activeFilePath)) {
962
- const file = openFiles.get(activeFilePath);
963
- file.content = monacoEditor.getValue();
964
- renderEditorTabs();
965
- }
966
- });
967
-
968
- // Hide editor initially
969
- document.getElementById('monaco-editor').style.display = 'none';
970
- });
971
-
972
- // Tab switching
973
- function switchTab(tab) {
974
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
975
- document.querySelector(`.tab[data-tab="${tab}"]`).classList.add('active');
976
-
977
- const terminalContainer = document.getElementById('terminal-container');
978
- const editorContainer = document.getElementById('editor-container');
979
-
980
- if (tab === 'terminal') {
981
- terminalContainer.classList.remove('hidden');
982
- editorContainer.classList.remove('active');
983
- setTimeout(() => fitAddon.fit(), 0);
984
- term.focus();
985
- } else {
986
- terminalContainer.classList.add('hidden');
987
- editorContainer.classList.add('active');
988
- if (monacoEditor) monacoEditor.focus();
989
- }
990
- }
991
-
992
- // File browser functions
993
- async function loadFiles(path = '') {
994
- try {
995
- const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
996
- const data = await res.json();
997
-
998
- if (data.error) {
999
- showToast(data.error, 'error');
1000
- return;
1001
- }
1002
-
1003
- currentBrowsePath = path;
1004
- renderBreadcrumb(path);
1005
- renderFileTree(data.items || []);
1006
- } catch (err) {
1007
- showToast('Failed to load files', 'error');
1008
- }
1009
- }
1010
-
1011
- function renderBreadcrumb(path) {
1012
- const container = document.getElementById('file-breadcrumb');
1013
- const parts = path ? path.split('/').filter(Boolean) : [];
1014
-
1015
- let html = '<span class="breadcrumb-item" onclick="navigateToPath(\'\')">~</span>';
1016
-
1017
- let currentPath = '';
1018
- parts.forEach((part, i) => {
1019
- currentPath += (currentPath ? '/' : '') + part;
1020
- const p = currentPath;
1021
- html += `<span class="breadcrumb-sep">/</span>`;
1022
- html += `<span class="breadcrumb-item" onclick="navigateToPath('${p}')">${part}</span>`;
1023
- });
1024
-
1025
- container.innerHTML = html;
1026
- }
1027
-
1028
- let currentFileItems = []; // Store current items for click handling
1029
-
1030
- function renderFileTree(items) {
1031
- const container = document.getElementById('file-tree');
1032
- currentFileItems = items;
1033
-
1034
- if (items.length === 0) {
1035
- container.innerHTML = '<div class="empty-state">Empty directory</div>';
1036
- return;
1037
- }
1038
-
1039
- container.innerHTML = items.map((item, index) => `
1040
- <div class="file-item ${item.isDirectory ? 'directory' : 'file'} ${activeFilePath === item.path ? 'active' : ''}"
1041
- data-index="${index}">
1042
- <span class="file-item-icon">${item.isDirectory ? '&#128193;' : '&#128196;'}</span>
1043
- <span class="file-item-name">${item.name}</span>
1044
- </div>
1045
- `).join('');
1046
- }
1047
-
1048
- // Event delegation for file tree clicks
1049
- document.getElementById('file-tree').addEventListener('click', (e) => {
1050
- const fileItem = e.target.closest('.file-item');
1051
- if (!fileItem) return;
1052
-
1053
- const index = parseInt(fileItem.dataset.index);
1054
- const item = currentFileItems[index];
1055
- if (!item) return;
1056
-
1057
- if (item.isDirectory) {
1058
- navigateToPath(item.path);
1059
- } else {
1060
- openFile(item.path);
1061
- }
1062
- });
1063
-
1064
- function navigateToPath(path) {
1065
- loadFiles(path);
1066
- }
1067
-
1068
- // File operations
1069
- async function openFile(path) {
1070
- // Switch to editor tab
1071
- switchTab('editor');
1072
-
1073
- // Check if already open
1074
- if (openFiles.has(path)) {
1075
- activateFile(path);
1076
- return;
1077
- }
1078
-
1079
- try {
1080
- const res = await fetch(`/api/files/read?path=${encodeURIComponent(path)}`);
1081
- const data = await res.json();
1082
-
1083
- if (data.error) {
1084
- showToast(data.error, 'error');
1085
- return;
1086
- }
1087
-
1088
- // Detect language
1089
- const ext = path.split('.').pop().toLowerCase();
1090
- const langMap = {
1091
- js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
1092
- py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
1093
- c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
1094
- html: 'html', css: 'css', scss: 'scss', less: 'less',
1095
- json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml',
1096
- md: 'markdown', sql: 'sql', sh: 'shell', bash: 'shell',
1097
- dockerfile: 'dockerfile', makefile: 'makefile'
1098
- };
1099
- const language = langMap[ext] || 'plaintext';
1100
-
1101
- // Create model
1102
- const model = monaco.editor.createModel(data.content, language);
1103
-
1104
- openFiles.set(path, {
1105
- content: data.content,
1106
- originalContent: data.content,
1107
- model,
1108
- language
1109
- });
1110
-
1111
- activateFile(path);
1112
- showToast(`Opened ${path.split('/').pop()}`);
1113
- } catch (err) {
1114
- showToast('Failed to open file', 'error');
1115
- }
1116
- }
1117
-
1118
- function activateFile(path) {
1119
- activeFilePath = path;
1120
- const file = openFiles.get(path);
1121
-
1122
- if (file && monacoEditor) {
1123
- monacoEditor.setModel(file.model);
1124
- document.getElementById('monaco-editor').style.display = 'block';
1125
- document.getElementById('editor-empty').style.display = 'none';
1126
- }
1127
-
1128
- renderEditorTabs();
1129
- // Re-render file tree to update active state
1130
- if (currentFileItems.length > 0) {
1131
- renderFileTree(currentFileItems);
1132
- }
1133
- }
1134
-
1135
- function renderEditorTabs() {
1136
- const container = document.getElementById('editor-tabs');
1137
-
1138
- if (openFiles.size === 0) {
1139
- container.innerHTML = '';
1140
- document.getElementById('monaco-editor').style.display = 'none';
1141
- document.getElementById('editor-empty').style.display = 'flex';
1142
- return;
1143
- }
1144
-
1145
- container.innerHTML = Array.from(openFiles.entries()).map(([path, file]) => {
1146
- const name = path.split('/').pop();
1147
- const isModified = file.content !== file.originalContent;
1148
- const isActive = path === activeFilePath;
1149
-
1150
- return `
1151
- <div class="editor-tab ${isActive ? 'active' : ''} ${isModified ? 'modified' : ''}" onclick="activateFile('${path}')">
1152
- <span>${name}</span>
1153
- <span class="editor-tab-close" onclick="event.stopPropagation(); closeFile('${path}')">x</span>
1154
- </div>
1155
- `;
1156
- }).join('');
1157
- }
1158
-
1159
- async function saveFile(path) {
1160
- const file = openFiles.get(path);
1161
- if (!file) return;
1162
-
1163
- try {
1164
- const res = await fetch('/api/files/write', {
1165
- method: 'POST',
1166
- headers: { 'Content-Type': 'application/json' },
1167
- body: JSON.stringify({ path, content: file.content })
1168
- });
1169
-
1170
- const data = await res.json();
1171
-
1172
- if (data.error) {
1173
- showToast(data.error, 'error');
1174
- return;
1175
- }
1176
-
1177
- file.originalContent = file.content;
1178
- renderEditorTabs();
1179
- showToast(`Saved ${path.split('/').pop()}`);
1180
- } catch (err) {
1181
- showToast('Failed to save file', 'error');
1182
- }
1183
- }
1184
-
1185
- function closeFile(path) {
1186
- const file = openFiles.get(path);
1187
- if (!file) return;
1188
-
1189
- // Check for unsaved changes
1190
- if (file.content !== file.originalContent) {
1191
- if (!confirm(`${path.split('/').pop()} has unsaved changes. Close anyway?`)) {
1192
- return;
1193
- }
1194
- }
1195
-
1196
- file.model.dispose();
1197
- openFiles.delete(path);
1198
-
1199
- if (activeFilePath === path) {
1200
- const remaining = Array.from(openFiles.keys());
1201
- if (remaining.length > 0) {
1202
- activateFile(remaining[remaining.length - 1]);
1203
- } else {
1204
- activeFilePath = null;
1205
- renderEditorTabs();
1206
- }
1207
- } else {
1208
- renderEditorTabs();
1209
- }
1210
- }
1211
-
1212
- // Export functions
1213
- window.switchTab = switchTab;
1214
- window.navigateToPath = navigateToPath;
1215
- window.openFile = openFile;
1216
- window.activateFile = activateFile;
1217
- window.closeFile = closeFile;
1218
- window.saveFile = saveFile;
1219
-
1220
- // Initialize xterm.js
1221
- const term = new Terminal({
1222
- cursorBlink: true,
1223
- fontSize: 14,
1224
- fontFamily: 'Menlo, Monaco, "Courier New", monospace',
1225
- theme: {
1226
- background: '#0d1117',
1227
- foreground: '#c9d1d9',
1228
- cursor: '#58a6ff',
1229
- cursorAccent: '#0d1117',
1230
- selection: 'rgba(56, 139, 253, 0.3)',
1231
- black: '#484f58',
1232
- red: '#ff7b72',
1233
- green: '#3fb950',
1234
- yellow: '#d29922',
1235
- blue: '#58a6ff',
1236
- magenta: '#bc8cff',
1237
- cyan: '#39c5cf',
1238
- white: '#b1bac4',
1239
- brightBlack: '#6e7681',
1240
- brightRed: '#ffa198',
1241
- brightGreen: '#56d364',
1242
- brightYellow: '#e3b341',
1243
- brightBlue: '#79c0ff',
1244
- brightMagenta: '#d2a8ff',
1245
- brightCyan: '#56d4dd',
1246
- brightWhite: '#f0f6fc'
1247
- }
1248
- });
1249
-
1250
- const fitAddon = new FitAddon.FitAddon();
1251
- term.loadAddon(fitAddon);
1252
-
1253
- const webLinksAddon = new WebLinksAddon.WebLinksAddon();
1254
- term.loadAddon(webLinksAddon);
1255
-
1256
- term.open(terminalContainer);
1257
- fitAddon.fit();
1258
-
1259
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1260
- const wsUrl = `${protocol}//${window.location.host}`;
1261
- let ws;
1262
-
1263
- function showToast(message, type = 'success') {
1264
- toastMessage.textContent = message;
1265
- toast.className = `toast show ${type}`;
1266
- setTimeout(() => {
1267
- toast.classList.remove('show');
1268
- }, 3000);
1269
- }
1270
-
1271
- function formatTime(timestamp) {
1272
- const date = new Date(timestamp);
1273
- return date.toLocaleTimeString();
1274
- }
1275
-
1276
- function formatUptime(seconds) {
1277
- const days = Math.floor(seconds / 86400);
1278
- const hours = Math.floor((seconds % 86400) / 3600);
1279
- const mins = Math.floor((seconds % 3600) / 60);
1280
- if (days > 0) return `${days}d ${hours}h`;
1281
- if (hours > 0) return `${hours}h ${mins}m`;
1282
- return `${mins}m`;
1283
- }
1284
-
1285
- function formatBytes(bytes) {
1286
- const gb = bytes / (1024 * 1024 * 1024);
1287
- return gb.toFixed(1) + ' GB';
1288
- }
1289
-
1290
- async function updateSystemInfo() {
1291
- try {
1292
- const res = await fetch('/api/system');
1293
- const data = await res.json();
1294
-
1295
- document.getElementById('system-hostname').textContent =
1296
- `${data.hostname} (${data.platform}/${data.arch})`;
1297
-
1298
- // CPU
1299
- const cpuValue = document.getElementById('cpu-value');
1300
- const cpuBar = document.getElementById('cpu-bar');
1301
- cpuValue.textContent = `${data.cpu.usage}%`;
1302
- cpuBar.style.width = `${data.cpu.usage}%`;
1303
- cpuBar.className = `stat-bar-fill ${data.cpu.usage > 80 ? 'high' : 'cpu'}`;
1304
-
1305
- // Memory
1306
- const memValue = document.getElementById('mem-value');
1307
- const memBar = document.getElementById('mem-bar');
1308
- memValue.textContent = `${data.memory.usage}% (${formatBytes(data.memory.used)}/${formatBytes(data.memory.total)})`;
1309
- memBar.style.width = `${data.memory.usage}%`;
1310
- memBar.className = `stat-bar-fill ${data.memory.usage > 80 ? 'high' : 'memory'}`;
1311
-
1312
- // Uptime
1313
- document.getElementById('uptime-value').textContent = formatUptime(data.uptime);
1314
-
1315
- // Load average
1316
- document.getElementById('load-value').textContent =
1317
- data.loadavg.map(l => l.toFixed(2)).join(' ');
1318
- } catch (err) {
1319
- console.error('Failed to fetch system info:', err);
1320
- }
1321
- }
1322
-
1323
- // Update system info every 3 seconds
1324
- updateSystemInfo();
1325
- setInterval(updateSystemInfo, 3000);
1326
-
1327
- function renderTerminalTabs() {
1328
- const container = document.getElementById('terminal-tabs');
1329
-
1330
- // Build tabs HTML
1331
- let tabsHtml = '';
1332
-
1333
- // Tmux sessions first
1334
- tmuxSessions.forEach(session => {
1335
- const isActive = currentSessionId && sessionIdDisplay.textContent === `tmux:${session.name}`;
1336
- tabsHtml += `
1337
- <div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToTmux('${session.name}')">
1338
- <span class="tmux-badge">tmux</span>
1339
- <span>${session.name}</span>
1340
- </div>
1341
- `;
1342
- });
1343
-
1344
- // Shell sessions
1345
- sessions.forEach(session => {
1346
- const isActive = session.id === currentSessionId;
1347
- tabsHtml += `
1348
- <div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToSession('${session.id}')">
1349
- <span>${session.id.slice(0, 8)}</span>
1350
- <span class="terminal-tab-close" onclick="event.stopPropagation(); killSession('${session.id}')">×</span>
1351
- </div>
1352
- `;
1353
- });
1354
-
1355
- // Add new button at the end
1356
- tabsHtml += `<button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>`;
1357
- tabsHtml += `<button class="terminal-tab-new" onclick="refreshTmuxSessions()" title="Refresh tmux" style="font-size: 12px;">↻</button>`;
1358
-
1359
- container.innerHTML = tabsHtml;
1360
- }
1361
-
1362
- function renderSessions() {
1363
- renderTerminalTabs();
1364
- }
1365
-
1366
- function renderTmuxSessions() {
1367
- renderTerminalTabs();
1368
- }
1369
-
1370
- function attachToTmux(sessionName) {
1371
- // Detach from current session first
1372
- if (currentSessionId) {
1373
- ws.send(JSON.stringify({ type: 'detach' }));
1374
- }
1375
- currentSessionId = null;
1376
- localStorage.removeItem('devtunnel-session-id');
1377
- term.clear();
1378
- ws.send(JSON.stringify({ type: 'attach-tmux', tmuxSession: sessionName }));
1379
- switchTab('terminal');
1380
- }
1381
-
1382
- function refreshTmuxSessions() {
1383
- ws.send(JSON.stringify({ type: 'refresh-tmux' }));
1384
- }
1385
-
1386
- window.attachToTmux = attachToTmux;
1387
- window.refreshTmuxSessions = refreshTmuxSessions;
1388
-
1389
- function renderTunnels() {
1390
- const list = document.getElementById('tunnel-list');
1391
- document.getElementById('tunnel-count').textContent = tunnels.length;
1392
-
1393
- if (tunnels.length === 0) {
1394
- list.innerHTML = '<div class="empty-state">No active tunnels</div>';
1395
- return;
1396
- }
1397
-
1398
- list.innerHTML = tunnels.map(tunnel => `
1399
- <div class="item-card">
1400
- <div class="item-card-header">
1401
- <span class="item-title">Port ${tunnel.port}</span>
1402
- <div style="display: flex; gap: 4px; align-items: center;">
1403
- <span class="item-status ${tunnel.status}">${tunnel.status}</span>
1404
- <button class="btn btn-danger btn-sm" onclick="stopTunnel('${tunnel.id}')" ${tunnel.status !== 'active' ? 'disabled' : ''}>x</button>
1405
- </div>
1406
- </div>
1407
- ${tunnel.url ? `
1408
- <div class="item-url">
1409
- <input type="text" value="${tunnel.url}" readonly onclick="this.select()">
1410
- <button class="btn-copy" onclick="copyUrl('${tunnel.url}', this)">Copy</button>
1411
- </div>
1412
- ` : '<div class="item-meta">Connecting...</div>'}
1413
- </div>
1414
- `).join('');
1415
- }
1416
-
1417
- function copyUrl(url, button) {
1418
- navigator.clipboard.writeText(url).then(() => {
1419
- button.textContent = 'Copied!';
1420
- button.classList.add('copied');
1421
- setTimeout(() => {
1422
- button.textContent = 'Copy';
1423
- button.classList.remove('copied');
1424
- }, 2000);
1425
- });
1426
- }
1427
-
1428
- function createTunnel() {
1429
- const port = parseInt(portInput.value);
1430
- if (!port || port < 1 || port > 65535) {
1431
- showToast('Please enter a valid port (1-65535)', 'error');
1432
- return;
1433
- }
1434
-
1435
- createTunnelBtn.disabled = true;
1436
- createTunnelBtn.textContent = 'Creating...';
1437
-
1438
- ws.send(JSON.stringify({ type: 'create-tunnel', port }));
1439
- portInput.value = '';
1440
- }
1441
-
1442
- function stopTunnel(id) {
1443
- ws.send(JSON.stringify({ type: 'stop-tunnel', id }));
1444
- }
1445
-
1446
- function createNewSession() {
1447
- // Detach from current session first
1448
- if (currentSessionId) {
1449
- ws.send(JSON.stringify({ type: 'detach' }));
1450
- }
1451
- currentSessionId = null;
1452
- localStorage.removeItem('devtunnel-session-id');
1453
- term.clear();
1454
- ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
1455
- switchTab('terminal');
1456
- }
1457
-
1458
- function attachToSession(sessionId) {
1459
- if (sessionId === currentSessionId) return;
1460
- // Detach from current session first
1461
- if (currentSessionId) {
1462
- ws.send(JSON.stringify({ type: 'detach' }));
1463
- }
1464
- currentSessionId = sessionId;
1465
- localStorage.setItem('devtunnel-session-id', sessionId);
1466
- term.clear();
1467
- ws.send(JSON.stringify({ type: 'attach', sessionId }));
1468
- switchTab('terminal');
1469
- }
1470
-
1471
- function killSession(sessionId) {
1472
- ws.send(JSON.stringify({ type: 'kill-session', sessionId }));
1473
- }
1474
-
1475
- function toggleSection(section) {
1476
- const content = document.getElementById(`${section}-content`);
1477
- content.style.display = content.style.display === 'none' ? 'block' : 'none';
1478
- }
1479
-
1480
- // Make functions globally available
1481
- window.copyUrl = copyUrl;
1482
- window.stopTunnel = stopTunnel;
1483
- window.createNewSession = createNewSession;
1484
- window.attachToSession = attachToSession;
1485
- window.killSession = killSession;
1486
- window.toggleSection = toggleSection;
1487
-
1488
- function connect() {
1489
- ws = new WebSocket(wsUrl);
1490
-
1491
- ws.onopen = () => {
1492
- console.log('WebSocket connected');
1493
- statusDot.classList.add('connected');
1494
- statusText.textContent = 'Connected';
1495
- reconnectOverlay.classList.remove('show');
1496
-
1497
- // Don't auto-attach - let user choose session manually
1498
- term.write('\x1b[90mClick + to create a new session.\x1b[0m\r\n');
1499
- };
1500
-
1501
- ws.onmessage = (event) => {
1502
- try {
1503
- const msg = JSON.parse(event.data);
1504
-
1505
- switch (msg.type) {
1506
- case 'attached':
1507
- currentSessionId = msg.sessionId;
1508
- localStorage.setItem('devtunnel-session-id', msg.sessionId);
1509
- sessionIndicator.style.display = 'flex';
1510
- sessionIdDisplay.textContent = msg.tmuxSession ? `tmux:${msg.tmuxSession}` : msg.sessionId.slice(0, 8);
1511
-
1512
- // Send initial size
1513
- ws.send(JSON.stringify({
1514
- type: 'resize',
1515
- cols: term.cols,
1516
- rows: term.rows
1517
- }));
1518
-
1519
- // Update terminal tabs to show active state
1520
- renderTerminalTabs();
1521
-
1522
- showToast(msg.tmuxSession ? `Attached to tmux: ${msg.tmuxSession}` : 'Session attached');
1523
- break;
1524
-
1525
- case 'output':
1526
- term.write(msg.data);
1527
- break;
1528
-
1529
- case 'exit':
1530
- term.write('\r\n\x1b[31mSession ended.\x1b[0m\r\n');
1531
- break;
1532
-
1533
- case 'sessions':
1534
- sessions = msg.data;
1535
- renderSessions();
1536
- break;
1537
-
1538
- case 'tunnels':
1539
- tunnels = msg.data;
1540
- renderTunnels();
1541
- break;
1542
-
1543
- case 'tmux-sessions':
1544
- tmuxSessions = msg.data;
1545
- renderTmuxSessions();
1546
- break;
1547
-
1548
- case 'tunnel-created':
1549
- showToast(`Tunnel created for port ${msg.data.port}`);
1550
- createTunnelBtn.disabled = false;
1551
- createTunnelBtn.textContent = 'Expose';
1552
- break;
1553
-
1554
- case 'tunnel-error':
1555
- showToast(msg.error, 'error');
1556
- createTunnelBtn.disabled = false;
1557
- createTunnelBtn.textContent = 'Expose';
1558
- break;
1559
-
1560
- case 'error':
1561
- showToast(msg.message, 'error');
1562
- break;
1563
- }
1564
- } catch (err) {
1565
- console.error('Error parsing message:', err);
1566
- }
1567
- };
1568
-
1569
- ws.onclose = () => {
1570
- console.log('WebSocket disconnected');
1571
- statusDot.classList.remove('connected');
1572
- statusText.textContent = 'Disconnected';
1573
- reconnectOverlay.classList.add('show');
1574
- };
1575
-
1576
- ws.onerror = (error) => {
1577
- console.error('WebSocket error:', error);
1578
- };
1579
- }
1580
-
1581
- term.onData((data) => {
1582
- if (ws && ws.readyState === WebSocket.OPEN) {
1583
- ws.send(JSON.stringify({ type: 'input', data }));
1584
- }
1585
- });
1586
-
1587
- function handleResize() {
1588
- fitAddon.fit();
1589
- if (ws && ws.readyState === WebSocket.OPEN) {
1590
- ws.send(JSON.stringify({
1591
- type: 'resize',
1592
- cols: term.cols,
1593
- rows: term.rows
1594
- }));
1595
- }
1596
- }
1597
-
1598
- window.addEventListener('resize', handleResize);
1599
-
1600
- createTunnelBtn.addEventListener('click', createTunnel);
1601
- portInput.addEventListener('keypress', (e) => {
1602
- if (e.key === 'Enter') {
1603
- createTunnel();
1604
- }
1605
- });
1606
-
1607
- connect();
1608
- term.focus();
1609
- loadFiles(); // Load initial file list
1610
- </script>
122
+ <script src="/app.js"></script>
1611
123
  </body>
1612
124
  </html>