@mjasano/devtunnel 1.1.0 → 1.4.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,633 +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
- .editor-tabs {
462
- display: flex;
463
- background-color: #161b22;
464
- border-bottom: 1px solid #30363d;
465
- overflow-x: auto;
466
- min-height: 35px;
467
- }
468
-
469
- .editor-tab {
470
- display: flex;
471
- align-items: center;
472
- gap: 8px;
473
- padding: 8px 12px;
474
- font-size: 12px;
475
- color: #8b949e;
476
- background-color: #0d1117;
477
- border-right: 1px solid #30363d;
478
- cursor: pointer;
479
- white-space: nowrap;
480
- }
481
-
482
- .editor-tab:hover {
483
- background-color: #161b22;
484
- }
485
-
486
- .editor-tab.active {
487
- color: #c9d1d9;
488
- background-color: #161b22;
489
- }
490
-
491
- .editor-tab.modified::after {
492
- content: '';
493
- width: 6px;
494
- height: 6px;
495
- background-color: #d29922;
496
- border-radius: 50%;
497
- }
498
-
499
- .editor-tab-close {
500
- width: 16px;
501
- height: 16px;
502
- display: flex;
503
- align-items: center;
504
- justify-content: center;
505
- border-radius: 4px;
506
- font-size: 14px;
507
- line-height: 1;
508
- }
509
-
510
- .editor-tab-close:hover {
511
- background-color: #30363d;
512
- }
513
-
514
- #monaco-editor {
515
- flex: 1;
516
- min-height: 0;
517
- }
518
-
519
- .editor-empty {
520
- flex: 1;
521
- display: flex;
522
- flex-direction: column;
523
- align-items: center;
524
- justify-content: center;
525
- color: #6e7681;
526
- font-size: 14px;
527
- }
528
-
529
- .editor-empty-icon {
530
- font-size: 48px;
531
- margin-bottom: 16px;
532
- opacity: 0.5;
533
- }
534
-
535
- /* File Tree */
536
- .file-tree {
537
- font-size: 12px;
538
- }
539
-
540
- .file-item {
541
- display: flex;
542
- align-items: center;
543
- gap: 6px;
544
- padding: 4px 8px;
545
- cursor: pointer;
546
- border-radius: 4px;
547
- }
548
-
549
- .file-item:hover {
550
- background-color: #21262d;
551
- }
552
-
553
- .file-item.active {
554
- background-color: rgba(56, 139, 253, 0.15);
555
- }
556
-
557
- .file-item-icon {
558
- width: 16px;
559
- text-align: center;
560
- flex-shrink: 0;
561
- }
562
-
563
- .file-item-name {
564
- overflow: hidden;
565
- text-overflow: ellipsis;
566
- white-space: nowrap;
567
- }
568
-
569
- .file-item.directory > .file-item-icon {
570
- color: #58a6ff;
571
- }
572
-
573
- .file-item.file > .file-item-icon {
574
- color: #8b949e;
575
- }
576
-
577
- .file-children {
578
- margin-left: 12px;
579
- }
580
-
581
- .breadcrumb {
582
- display: flex;
583
- align-items: center;
584
- gap: 4px;
585
- padding: 8px 12px;
586
- font-size: 11px;
587
- color: #8b949e;
588
- border-bottom: 1px solid #30363d;
589
- overflow-x: auto;
590
- }
591
-
592
- .breadcrumb-item {
593
- cursor: pointer;
594
- padding: 2px 4px;
595
- border-radius: 4px;
596
- }
597
-
598
- .breadcrumb-item:hover {
599
- background-color: #21262d;
600
- color: #c9d1d9;
601
- }
602
-
603
- .breadcrumb-sep {
604
- color: #484f58;
605
- }
606
-
607
- @keyframes slideIn {
608
- from {
609
- transform: translateY(20px);
610
- opacity: 0;
611
- }
612
- to {
613
- transform: translateY(0);
614
- opacity: 1;
615
- }
616
- }
617
-
618
- @media (max-width: 768px) {
619
- .main-container {
620
- flex-direction: column;
621
- }
622
-
623
- .sidebar {
624
- width: 100%;
625
- max-height: 250px;
626
- border-left: none;
627
- border-top: 1px solid #30363d;
628
- }
629
-
630
- .toast {
631
- right: 20px;
632
- }
633
- }
634
- </style>
8
+ <link rel="stylesheet" href="/styles.css">
635
9
  </head>
636
10
  <body>
637
11
  <div class="header">
@@ -640,6 +14,10 @@
640
14
  DevTunnel
641
15
  </h1>
642
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>
643
21
  <div class="session-indicator" id="session-indicator" style="display: none;">
644
22
  <span class="dot"></span>
645
23
  <span>Session: <span id="session-id-display">-</span></span>
@@ -648,6 +26,7 @@
648
26
  <div class="status-dot" id="status-dot"></div>
649
27
  <span id="status-text">Connecting...</span>
650
28
  </div>
29
+ <button class="btn-logout" id="logout-btn" style="display: none;" onclick="logout()">Logout</button>
651
30
  </div>
652
31
  </div>
653
32
 
@@ -665,6 +44,9 @@
665
44
 
666
45
  <div class="main-container">
667
46
  <div id="terminal-container">
47
+ <div class="terminal-tabs" id="terminal-tabs">
48
+ <button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>
49
+ </div>
668
50
  <div id="terminal"></div>
669
51
  </div>
670
52
 
@@ -686,6 +68,9 @@
686
68
  <div class="sidebar-header" onclick="toggleSection('files')">
687
69
  <h2>Files</h2>
688
70
  </div>
71
+ <div class="file-search">
72
+ <input type="text" id="file-search-input" placeholder="Search files..." oninput="handleFileSearch(this.value)">
73
+ </div>
689
74
  <div class="breadcrumb" id="file-breadcrumb">
690
75
  <span class="breadcrumb-item" onclick="navigateToPath('')">~</span>
691
76
  </div>
@@ -696,20 +81,6 @@
696
81
  </div>
697
82
  </div>
698
83
 
699
- <!-- Sessions Section -->
700
- <div class="sidebar-section">
701
- <div class="sidebar-header" onclick="toggleSection('sessions')">
702
- <h2>Sessions</h2>
703
- <span class="count" id="session-count">0</span>
704
- </div>
705
- <div class="sidebar-content" id="sessions-content">
706
- <button class="btn btn-primary btn-sm" style="width: 100%; margin-bottom: 8px;" onclick="createNewSession()">+ New Session</button>
707
- <div id="session-list">
708
- <div class="empty-state">No sessions</div>
709
- </div>
710
- </div>
711
- </div>
712
-
713
84
  <!-- Tunnels Section -->
714
85
  <div class="sidebar-section">
715
86
  <div class="sidebar-header" onclick="toggleSection('tunnels')">
@@ -745,611 +116,6 @@
745
116
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
746
117
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
747
118
  <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
748
- <script>
749
- const terminalContainer = document.getElementById('terminal');
750
- const statusDot = document.getElementById('status-dot');
751
- const statusText = document.getElementById('status-text');
752
- const reconnectOverlay = document.getElementById('reconnect-overlay');
753
- const portInput = document.getElementById('port-input');
754
- const createTunnelBtn = document.getElementById('create-tunnel-btn');
755
- const sessionIndicator = document.getElementById('session-indicator');
756
- const sessionIdDisplay = document.getElementById('session-id-display');
757
- const toast = document.getElementById('toast');
758
- const toastMessage = document.getElementById('toast-message');
759
-
760
- let tunnels = [];
761
- let sessions = [];
762
- let currentSessionId = localStorage.getItem('devtunnel-session-id');
763
-
764
- // Editor state
765
- let monacoEditor = null;
766
- let openFiles = new Map(); // path -> { content, originalContent, model }
767
- let activeFilePath = null;
768
- let currentBrowsePath = '';
769
-
770
- // Initialize Monaco Editor
771
- require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' }});
772
- require(['vs/editor/editor.main'], function() {
773
- monaco.editor.defineTheme('github-dark', {
774
- base: 'vs-dark',
775
- inherit: true,
776
- rules: [],
777
- colors: {
778
- 'editor.background': '#0d1117',
779
- 'editor.foreground': '#c9d1d9',
780
- 'editorCursor.foreground': '#58a6ff',
781
- 'editor.lineHighlightBackground': '#161b22',
782
- 'editorLineNumber.foreground': '#6e7681',
783
- 'editor.selectionBackground': '#264f78',
784
- 'editorIndentGuide.background': '#21262d',
785
- }
786
- });
787
-
788
- monacoEditor = monaco.editor.create(document.getElementById('monaco-editor'), {
789
- value: '',
790
- language: 'plaintext',
791
- theme: 'github-dark',
792
- fontSize: 14,
793
- fontFamily: 'Menlo, Monaco, "Courier New", monospace',
794
- minimap: { enabled: false },
795
- automaticLayout: true,
796
- scrollBeyondLastLine: false,
797
- wordWrap: 'on',
798
- tabSize: 2,
799
- });
800
-
801
- // Ctrl+S to save
802
- monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
803
- if (activeFilePath) saveFile(activeFilePath);
804
- });
805
-
806
- // Track changes
807
- monacoEditor.onDidChangeModelContent(() => {
808
- if (activeFilePath && openFiles.has(activeFilePath)) {
809
- const file = openFiles.get(activeFilePath);
810
- file.content = monacoEditor.getValue();
811
- renderEditorTabs();
812
- }
813
- });
814
-
815
- // Hide editor initially
816
- document.getElementById('monaco-editor').style.display = 'none';
817
- });
818
-
819
- // Tab switching
820
- function switchTab(tab) {
821
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
822
- document.querySelector(`.tab[data-tab="${tab}"]`).classList.add('active');
823
-
824
- const terminalContainer = document.getElementById('terminal-container');
825
- const editorContainer = document.getElementById('editor-container');
826
-
827
- if (tab === 'terminal') {
828
- terminalContainer.classList.remove('hidden');
829
- editorContainer.classList.remove('active');
830
- setTimeout(() => fitAddon.fit(), 0);
831
- term.focus();
832
- } else {
833
- terminalContainer.classList.add('hidden');
834
- editorContainer.classList.add('active');
835
- if (monacoEditor) monacoEditor.focus();
836
- }
837
- }
838
-
839
- // File browser functions
840
- async function loadFiles(path = '') {
841
- try {
842
- const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
843
- const data = await res.json();
844
-
845
- if (data.error) {
846
- showToast(data.error, 'error');
847
- return;
848
- }
849
-
850
- currentBrowsePath = path;
851
- renderBreadcrumb(path);
852
- renderFileTree(data.items || []);
853
- } catch (err) {
854
- showToast('Failed to load files', 'error');
855
- }
856
- }
857
-
858
- function renderBreadcrumb(path) {
859
- const container = document.getElementById('file-breadcrumb');
860
- const parts = path ? path.split('/').filter(Boolean) : [];
861
-
862
- let html = '<span class="breadcrumb-item" onclick="navigateToPath(\'\')">~</span>';
863
-
864
- let currentPath = '';
865
- parts.forEach((part, i) => {
866
- currentPath += (currentPath ? '/' : '') + part;
867
- const p = currentPath;
868
- html += `<span class="breadcrumb-sep">/</span>`;
869
- html += `<span class="breadcrumb-item" onclick="navigateToPath('${p}')">${part}</span>`;
870
- });
871
-
872
- container.innerHTML = html;
873
- }
874
-
875
- let currentFileItems = []; // Store current items for click handling
876
-
877
- function renderFileTree(items) {
878
- const container = document.getElementById('file-tree');
879
- currentFileItems = items;
880
-
881
- if (items.length === 0) {
882
- container.innerHTML = '<div class="empty-state">Empty directory</div>';
883
- return;
884
- }
885
-
886
- container.innerHTML = items.map((item, index) => `
887
- <div class="file-item ${item.isDirectory ? 'directory' : 'file'} ${activeFilePath === item.path ? 'active' : ''}"
888
- data-index="${index}">
889
- <span class="file-item-icon">${item.isDirectory ? '&#128193;' : '&#128196;'}</span>
890
- <span class="file-item-name">${item.name}</span>
891
- </div>
892
- `).join('');
893
- }
894
-
895
- // Event delegation for file tree clicks
896
- document.getElementById('file-tree').addEventListener('click', (e) => {
897
- const fileItem = e.target.closest('.file-item');
898
- if (!fileItem) return;
899
-
900
- const index = parseInt(fileItem.dataset.index);
901
- const item = currentFileItems[index];
902
- if (!item) return;
903
-
904
- if (item.isDirectory) {
905
- navigateToPath(item.path);
906
- } else {
907
- openFile(item.path);
908
- }
909
- });
910
-
911
- function navigateToPath(path) {
912
- loadFiles(path);
913
- }
914
-
915
- // File operations
916
- async function openFile(path) {
917
- // Switch to editor tab
918
- switchTab('editor');
919
-
920
- // Check if already open
921
- if (openFiles.has(path)) {
922
- activateFile(path);
923
- return;
924
- }
925
-
926
- try {
927
- const res = await fetch(`/api/files/read?path=${encodeURIComponent(path)}`);
928
- const data = await res.json();
929
-
930
- if (data.error) {
931
- showToast(data.error, 'error');
932
- return;
933
- }
934
-
935
- // Detect language
936
- const ext = path.split('.').pop().toLowerCase();
937
- const langMap = {
938
- js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
939
- py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
940
- c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
941
- html: 'html', css: 'css', scss: 'scss', less: 'less',
942
- json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml',
943
- md: 'markdown', sql: 'sql', sh: 'shell', bash: 'shell',
944
- dockerfile: 'dockerfile', makefile: 'makefile'
945
- };
946
- const language = langMap[ext] || 'plaintext';
947
-
948
- // Create model
949
- const model = monaco.editor.createModel(data.content, language);
950
-
951
- openFiles.set(path, {
952
- content: data.content,
953
- originalContent: data.content,
954
- model,
955
- language
956
- });
957
-
958
- activateFile(path);
959
- showToast(`Opened ${path.split('/').pop()}`);
960
- } catch (err) {
961
- showToast('Failed to open file', 'error');
962
- }
963
- }
964
-
965
- function activateFile(path) {
966
- activeFilePath = path;
967
- const file = openFiles.get(path);
968
-
969
- if (file && monacoEditor) {
970
- monacoEditor.setModel(file.model);
971
- document.getElementById('monaco-editor').style.display = 'block';
972
- document.getElementById('editor-empty').style.display = 'none';
973
- }
974
-
975
- renderEditorTabs();
976
- // Re-render file tree to update active state
977
- if (currentFileItems.length > 0) {
978
- renderFileTree(currentFileItems);
979
- }
980
- }
981
-
982
- function renderEditorTabs() {
983
- const container = document.getElementById('editor-tabs');
984
-
985
- if (openFiles.size === 0) {
986
- container.innerHTML = '';
987
- document.getElementById('monaco-editor').style.display = 'none';
988
- document.getElementById('editor-empty').style.display = 'flex';
989
- return;
990
- }
991
-
992
- container.innerHTML = Array.from(openFiles.entries()).map(([path, file]) => {
993
- const name = path.split('/').pop();
994
- const isModified = file.content !== file.originalContent;
995
- const isActive = path === activeFilePath;
996
-
997
- return `
998
- <div class="editor-tab ${isActive ? 'active' : ''} ${isModified ? 'modified' : ''}" onclick="activateFile('${path}')">
999
- <span>${name}</span>
1000
- <span class="editor-tab-close" onclick="event.stopPropagation(); closeFile('${path}')">x</span>
1001
- </div>
1002
- `;
1003
- }).join('');
1004
- }
1005
-
1006
- async function saveFile(path) {
1007
- const file = openFiles.get(path);
1008
- if (!file) return;
1009
-
1010
- try {
1011
- const res = await fetch('/api/files/write', {
1012
- method: 'POST',
1013
- headers: { 'Content-Type': 'application/json' },
1014
- body: JSON.stringify({ path, content: file.content })
1015
- });
1016
-
1017
- const data = await res.json();
1018
-
1019
- if (data.error) {
1020
- showToast(data.error, 'error');
1021
- return;
1022
- }
1023
-
1024
- file.originalContent = file.content;
1025
- renderEditorTabs();
1026
- showToast(`Saved ${path.split('/').pop()}`);
1027
- } catch (err) {
1028
- showToast('Failed to save file', 'error');
1029
- }
1030
- }
1031
-
1032
- function closeFile(path) {
1033
- const file = openFiles.get(path);
1034
- if (!file) return;
1035
-
1036
- // Check for unsaved changes
1037
- if (file.content !== file.originalContent) {
1038
- if (!confirm(`${path.split('/').pop()} has unsaved changes. Close anyway?`)) {
1039
- return;
1040
- }
1041
- }
1042
-
1043
- file.model.dispose();
1044
- openFiles.delete(path);
1045
-
1046
- if (activeFilePath === path) {
1047
- const remaining = Array.from(openFiles.keys());
1048
- if (remaining.length > 0) {
1049
- activateFile(remaining[remaining.length - 1]);
1050
- } else {
1051
- activeFilePath = null;
1052
- renderEditorTabs();
1053
- }
1054
- } else {
1055
- renderEditorTabs();
1056
- }
1057
- }
1058
-
1059
- // Export functions
1060
- window.switchTab = switchTab;
1061
- window.navigateToPath = navigateToPath;
1062
- window.openFile = openFile;
1063
- window.activateFile = activateFile;
1064
- window.closeFile = closeFile;
1065
- window.saveFile = saveFile;
1066
-
1067
- // Initialize xterm.js
1068
- const term = new Terminal({
1069
- cursorBlink: true,
1070
- fontSize: 14,
1071
- fontFamily: 'Menlo, Monaco, "Courier New", monospace',
1072
- theme: {
1073
- background: '#0d1117',
1074
- foreground: '#c9d1d9',
1075
- cursor: '#58a6ff',
1076
- cursorAccent: '#0d1117',
1077
- selection: 'rgba(56, 139, 253, 0.3)',
1078
- black: '#484f58',
1079
- red: '#ff7b72',
1080
- green: '#3fb950',
1081
- yellow: '#d29922',
1082
- blue: '#58a6ff',
1083
- magenta: '#bc8cff',
1084
- cyan: '#39c5cf',
1085
- white: '#b1bac4',
1086
- brightBlack: '#6e7681',
1087
- brightRed: '#ffa198',
1088
- brightGreen: '#56d364',
1089
- brightYellow: '#e3b341',
1090
- brightBlue: '#79c0ff',
1091
- brightMagenta: '#d2a8ff',
1092
- brightCyan: '#56d4dd',
1093
- brightWhite: '#f0f6fc'
1094
- }
1095
- });
1096
-
1097
- const fitAddon = new FitAddon.FitAddon();
1098
- term.loadAddon(fitAddon);
1099
-
1100
- const webLinksAddon = new WebLinksAddon.WebLinksAddon();
1101
- term.loadAddon(webLinksAddon);
1102
-
1103
- term.open(terminalContainer);
1104
- fitAddon.fit();
1105
-
1106
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1107
- const wsUrl = `${protocol}//${window.location.host}`;
1108
- let ws;
1109
-
1110
- function showToast(message, type = 'success') {
1111
- toastMessage.textContent = message;
1112
- toast.className = `toast show ${type}`;
1113
- setTimeout(() => {
1114
- toast.classList.remove('show');
1115
- }, 3000);
1116
- }
1117
-
1118
- function formatTime(timestamp) {
1119
- const date = new Date(timestamp);
1120
- return date.toLocaleTimeString();
1121
- }
1122
-
1123
- function renderSessions() {
1124
- const list = document.getElementById('session-list');
1125
- document.getElementById('session-count').textContent = sessions.length;
1126
-
1127
- if (sessions.length === 0) {
1128
- list.innerHTML = '<div class="empty-state">No sessions</div>';
1129
- return;
1130
- }
1131
-
1132
- list.innerHTML = sessions.map(session => `
1133
- <div class="item-card ${session.id === currentSessionId ? 'active' : ''}" onclick="attachToSession('${session.id}')">
1134
- <div class="item-card-header">
1135
- <span class="item-title">
1136
- ${session.id.slice(0, 8)}...
1137
- </span>
1138
- <div style="display: flex; gap: 4px; align-items: center;">
1139
- <span class="item-status ${session.alive ? 'active' : 'stopped'}">${session.alive ? 'alive' : 'ended'}</span>
1140
- <button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); killSession('${session.id}')">x</button>
1141
- </div>
1142
- </div>
1143
- <div class="item-meta">
1144
- ${session.clients} client(s) connected | Last: ${formatTime(session.lastAccess)}
1145
- </div>
1146
- </div>
1147
- `).join('');
1148
- }
1149
-
1150
- function renderTunnels() {
1151
- const list = document.getElementById('tunnel-list');
1152
- document.getElementById('tunnel-count').textContent = tunnels.length;
1153
-
1154
- if (tunnels.length === 0) {
1155
- list.innerHTML = '<div class="empty-state">No active tunnels</div>';
1156
- return;
1157
- }
1158
-
1159
- list.innerHTML = tunnels.map(tunnel => `
1160
- <div class="item-card">
1161
- <div class="item-card-header">
1162
- <span class="item-title">Port ${tunnel.port}</span>
1163
- <div style="display: flex; gap: 4px; align-items: center;">
1164
- <span class="item-status ${tunnel.status}">${tunnel.status}</span>
1165
- <button class="btn btn-danger btn-sm" onclick="stopTunnel('${tunnel.id}')" ${tunnel.status !== 'active' ? 'disabled' : ''}>x</button>
1166
- </div>
1167
- </div>
1168
- ${tunnel.url ? `
1169
- <div class="item-url">
1170
- <input type="text" value="${tunnel.url}" readonly onclick="this.select()">
1171
- <button class="btn-copy" onclick="copyUrl('${tunnel.url}', this)">Copy</button>
1172
- </div>
1173
- ` : '<div class="item-meta">Connecting...</div>'}
1174
- </div>
1175
- `).join('');
1176
- }
1177
-
1178
- function copyUrl(url, button) {
1179
- navigator.clipboard.writeText(url).then(() => {
1180
- button.textContent = 'Copied!';
1181
- button.classList.add('copied');
1182
- setTimeout(() => {
1183
- button.textContent = 'Copy';
1184
- button.classList.remove('copied');
1185
- }, 2000);
1186
- });
1187
- }
1188
-
1189
- function createTunnel() {
1190
- const port = parseInt(portInput.value);
1191
- if (!port || port < 1 || port > 65535) {
1192
- showToast('Please enter a valid port (1-65535)', 'error');
1193
- return;
1194
- }
1195
-
1196
- createTunnelBtn.disabled = true;
1197
- createTunnelBtn.textContent = 'Creating...';
1198
-
1199
- ws.send(JSON.stringify({ type: 'create-tunnel', port }));
1200
- portInput.value = '';
1201
- }
1202
-
1203
- function stopTunnel(id) {
1204
- ws.send(JSON.stringify({ type: 'stop-tunnel', id }));
1205
- }
1206
-
1207
- function createNewSession() {
1208
- currentSessionId = null;
1209
- localStorage.removeItem('devtunnel-session-id');
1210
- term.clear();
1211
- ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
1212
- }
1213
-
1214
- function attachToSession(sessionId) {
1215
- if (sessionId === currentSessionId) return;
1216
- currentSessionId = sessionId;
1217
- localStorage.setItem('devtunnel-session-id', sessionId);
1218
- term.clear();
1219
- ws.send(JSON.stringify({ type: 'attach', sessionId }));
1220
- }
1221
-
1222
- function killSession(sessionId) {
1223
- ws.send(JSON.stringify({ type: 'kill-session', sessionId }));
1224
- }
1225
-
1226
- function toggleSection(section) {
1227
- const content = document.getElementById(`${section}-content`);
1228
- content.style.display = content.style.display === 'none' ? 'block' : 'none';
1229
- }
1230
-
1231
- // Make functions globally available
1232
- window.copyUrl = copyUrl;
1233
- window.stopTunnel = stopTunnel;
1234
- window.createNewSession = createNewSession;
1235
- window.attachToSession = attachToSession;
1236
- window.killSession = killSession;
1237
- window.toggleSection = toggleSection;
1238
-
1239
- function connect() {
1240
- ws = new WebSocket(wsUrl);
1241
-
1242
- ws.onopen = () => {
1243
- console.log('WebSocket connected');
1244
- statusDot.classList.add('connected');
1245
- statusText.textContent = 'Connected';
1246
- reconnectOverlay.classList.remove('show');
1247
-
1248
- // Attach to existing or new session
1249
- ws.send(JSON.stringify({ type: 'attach', sessionId: currentSessionId }));
1250
- };
1251
-
1252
- ws.onmessage = (event) => {
1253
- try {
1254
- const msg = JSON.parse(event.data);
1255
-
1256
- switch (msg.type) {
1257
- case 'attached':
1258
- currentSessionId = msg.sessionId;
1259
- localStorage.setItem('devtunnel-session-id', msg.sessionId);
1260
- sessionIndicator.style.display = 'flex';
1261
- sessionIdDisplay.textContent = msg.sessionId.slice(0, 8);
1262
-
1263
- // Send initial size
1264
- ws.send(JSON.stringify({
1265
- type: 'resize',
1266
- cols: term.cols,
1267
- rows: term.rows
1268
- }));
1269
-
1270
- showToast('Session attached');
1271
- break;
1272
-
1273
- case 'output':
1274
- term.write(msg.data);
1275
- break;
1276
-
1277
- case 'exit':
1278
- term.write('\r\n\x1b[31mSession ended.\x1b[0m\r\n');
1279
- break;
1280
-
1281
- case 'sessions':
1282
- sessions = msg.data;
1283
- renderSessions();
1284
- break;
1285
-
1286
- case 'tunnels':
1287
- tunnels = msg.data;
1288
- renderTunnels();
1289
- break;
1290
-
1291
- case 'tunnel-created':
1292
- showToast(`Tunnel created for port ${msg.data.port}`);
1293
- createTunnelBtn.disabled = false;
1294
- createTunnelBtn.textContent = 'Expose';
1295
- break;
1296
-
1297
- case 'tunnel-error':
1298
- showToast(msg.error, 'error');
1299
- createTunnelBtn.disabled = false;
1300
- createTunnelBtn.textContent = 'Expose';
1301
- break;
1302
-
1303
- case 'error':
1304
- showToast(msg.message, 'error');
1305
- break;
1306
- }
1307
- } catch (err) {
1308
- console.error('Error parsing message:', err);
1309
- }
1310
- };
1311
-
1312
- ws.onclose = () => {
1313
- console.log('WebSocket disconnected');
1314
- statusDot.classList.remove('connected');
1315
- statusText.textContent = 'Disconnected';
1316
- reconnectOverlay.classList.add('show');
1317
- };
1318
-
1319
- ws.onerror = (error) => {
1320
- console.error('WebSocket error:', error);
1321
- };
1322
- }
1323
-
1324
- term.onData((data) => {
1325
- if (ws && ws.readyState === WebSocket.OPEN) {
1326
- ws.send(JSON.stringify({ type: 'input', data }));
1327
- }
1328
- });
1329
-
1330
- function handleResize() {
1331
- fitAddon.fit();
1332
- if (ws && ws.readyState === WebSocket.OPEN) {
1333
- ws.send(JSON.stringify({
1334
- type: 'resize',
1335
- cols: term.cols,
1336
- rows: term.rows
1337
- }));
1338
- }
1339
- }
1340
-
1341
- window.addEventListener('resize', handleResize);
1342
-
1343
- createTunnelBtn.addEventListener('click', createTunnel);
1344
- portInput.addEventListener('keypress', (e) => {
1345
- if (e.key === 'Enter') {
1346
- createTunnel();
1347
- }
1348
- });
1349
-
1350
- connect();
1351
- term.focus();
1352
- loadFiles(); // Load initial file list
1353
- </script>
119
+ <script src="/app.js"></script>
1354
120
  </body>
1355
121
  </html>