@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/app.js ADDED
@@ -0,0 +1,849 @@
1
+ // DOM Elements
2
+ const terminalContainer = document.getElementById('terminal');
3
+ const statusDot = document.getElementById('status-dot');
4
+ const statusText = document.getElementById('status-text');
5
+ const reconnectOverlay = document.getElementById('reconnect-overlay');
6
+ const portInput = document.getElementById('port-input');
7
+ const createTunnelBtn = document.getElementById('create-tunnel-btn');
8
+ const sessionIndicator = document.getElementById('session-indicator');
9
+ const sessionIdDisplay = document.getElementById('session-id-display');
10
+ const toast = document.getElementById('toast');
11
+ const toastMessage = document.getElementById('toast-message');
12
+ const logoutBtn = document.getElementById('logout-btn');
13
+
14
+ // Check authentication status
15
+ async function checkAuth() {
16
+ try {
17
+ const res = await fetch('/api/auth/status');
18
+ const data = await res.json();
19
+
20
+ if (data.requiresAuth) {
21
+ logoutBtn.style.display = 'block';
22
+
23
+ if (!data.authenticated) {
24
+ window.location.href = '/login';
25
+ return false;
26
+ }
27
+ }
28
+ return true;
29
+ } catch (err) {
30
+ console.error('Auth check failed:', err);
31
+ return true;
32
+ }
33
+ }
34
+
35
+ // Logout function
36
+ async function logout() {
37
+ try {
38
+ await fetch('/api/auth/logout', { method: 'POST' });
39
+ window.location.href = '/login';
40
+ } catch (err) {
41
+ console.error('Logout failed:', err);
42
+ window.location.href = '/login';
43
+ }
44
+ }
45
+
46
+ window.logout = logout;
47
+
48
+ // Initialize auth check
49
+ checkAuth();
50
+
51
+ // State
52
+ let tunnels = [];
53
+ let sessions = [];
54
+ let tmuxSessions = [];
55
+ let currentSessionId = localStorage.getItem('devtunnel-session-id');
56
+
57
+ // Editor state
58
+ let monacoEditor = null;
59
+ let openFiles = new Map();
60
+ let activeFilePath = null;
61
+ let currentBrowsePath = '';
62
+ let currentFileItems = [];
63
+
64
+ // File search state
65
+ let fileSearchQuery = '';
66
+
67
+ // Initialize Monaco Editor
68
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' }});
69
+ require(['vs/editor/editor.main'], function() {
70
+ monaco.editor.defineTheme('github-dark', {
71
+ base: 'vs-dark',
72
+ inherit: true,
73
+ rules: [],
74
+ colors: {
75
+ 'editor.background': '#0d1117',
76
+ 'editor.foreground': '#c9d1d9',
77
+ 'editorCursor.foreground': '#58a6ff',
78
+ 'editor.lineHighlightBackground': '#161b22',
79
+ 'editorLineNumber.foreground': '#6e7681',
80
+ 'editor.selectionBackground': '#264f78',
81
+ 'editorIndentGuide.background': '#21262d',
82
+ }
83
+ });
84
+
85
+ monacoEditor = monaco.editor.create(document.getElementById('monaco-editor'), {
86
+ value: '',
87
+ language: 'plaintext',
88
+ theme: 'github-dark',
89
+ fontSize: 14,
90
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
91
+ minimap: { enabled: false },
92
+ automaticLayout: true,
93
+ scrollBeyondLastLine: false,
94
+ wordWrap: 'on',
95
+ tabSize: 2,
96
+ });
97
+
98
+ monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
99
+ if (activeFilePath) saveFile(activeFilePath);
100
+ });
101
+
102
+ monacoEditor.onDidChangeModelContent(() => {
103
+ if (activeFilePath && openFiles.has(activeFilePath)) {
104
+ const file = openFiles.get(activeFilePath);
105
+ file.content = monacoEditor.getValue();
106
+ renderEditorTabs();
107
+ }
108
+ });
109
+
110
+ document.getElementById('monaco-editor').style.display = 'none';
111
+ });
112
+
113
+ // Tab switching
114
+ function switchTab(tab) {
115
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
116
+ document.querySelector(`.tab[data-tab="${tab}"]`).classList.add('active');
117
+
118
+ const terminalContainer = document.getElementById('terminal-container');
119
+ const editorContainer = document.getElementById('editor-container');
120
+
121
+ if (tab === 'terminal') {
122
+ terminalContainer.classList.remove('hidden');
123
+ editorContainer.classList.remove('active');
124
+ setTimeout(() => fitAddon.fit(), 0);
125
+ term.focus();
126
+ } else {
127
+ terminalContainer.classList.add('hidden');
128
+ editorContainer.classList.add('active');
129
+ if (monacoEditor) monacoEditor.focus();
130
+ }
131
+ }
132
+
133
+ // File browser functions
134
+ async function loadFiles(path = '') {
135
+ try {
136
+ const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
137
+ const data = await res.json();
138
+
139
+ if (data.error) {
140
+ showToast(data.error, 'error');
141
+ return;
142
+ }
143
+
144
+ currentBrowsePath = path;
145
+ renderBreadcrumb(path);
146
+ renderFileTree(data.items || []);
147
+ } catch (err) {
148
+ showToast('Failed to load files', 'error');
149
+ }
150
+ }
151
+
152
+ function renderBreadcrumb(path) {
153
+ const container = document.getElementById('file-breadcrumb');
154
+ const parts = path ? path.split('/').filter(Boolean) : [];
155
+
156
+ let html = '<span class="breadcrumb-item" onclick="navigateToPath(\'\')">~</span>';
157
+
158
+ let currentPath = '';
159
+ parts.forEach((part, i) => {
160
+ currentPath += (currentPath ? '/' : '') + part;
161
+ const p = currentPath;
162
+ html += `<span class="breadcrumb-sep">/</span>`;
163
+ html += `<span class="breadcrumb-item" onclick="navigateToPath('${p}')">${part}</span>`;
164
+ });
165
+
166
+ container.innerHTML = html;
167
+ }
168
+
169
+ function renderFileTree(items) {
170
+ const container = document.getElementById('file-tree');
171
+ currentFileItems = items;
172
+
173
+ // Filter by search query
174
+ let filteredItems = items;
175
+ if (fileSearchQuery) {
176
+ const query = fileSearchQuery.toLowerCase();
177
+ filteredItems = items.filter(item => item.name.toLowerCase().includes(query));
178
+ }
179
+
180
+ if (filteredItems.length === 0) {
181
+ container.innerHTML = `<div class="empty-state">${fileSearchQuery ? 'No matching files' : 'Empty directory'}</div>`;
182
+ return;
183
+ }
184
+
185
+ container.innerHTML = filteredItems.map((item, index) => {
186
+ const originalIndex = items.indexOf(item);
187
+ return `
188
+ <div class="file-item ${item.isDirectory ? 'directory' : 'file'} ${activeFilePath === item.path ? 'active' : ''}"
189
+ data-index="${originalIndex}"
190
+ data-path="${item.path}"
191
+ oncontextmenu="showFileContextMenu(event, '${item.path}', ${item.isDirectory})">
192
+ <span class="file-item-icon">${item.isDirectory ? '&#128193;' : '&#128196;'}</span>
193
+ <span class="file-item-name">${item.name}</span>
194
+ </div>
195
+ `;
196
+ }).join('');
197
+ }
198
+
199
+ // File search
200
+ function handleFileSearch(query) {
201
+ fileSearchQuery = query;
202
+ renderFileTree(currentFileItems);
203
+ }
204
+
205
+ // Event delegation for file tree clicks
206
+ document.getElementById('file-tree').addEventListener('click', (e) => {
207
+ const fileItem = e.target.closest('.file-item');
208
+ if (!fileItem) return;
209
+
210
+ const index = parseInt(fileItem.dataset.index);
211
+ const item = currentFileItems[index];
212
+ if (!item) return;
213
+
214
+ if (item.isDirectory) {
215
+ navigateToPath(item.path);
216
+ } else {
217
+ openFile(item.path);
218
+ }
219
+ });
220
+
221
+ function navigateToPath(path) {
222
+ fileSearchQuery = '';
223
+ const searchInput = document.getElementById('file-search-input');
224
+ if (searchInput) searchInput.value = '';
225
+ loadFiles(path);
226
+ }
227
+
228
+ // File operations
229
+ async function openFile(path) {
230
+ switchTab('editor');
231
+
232
+ if (openFiles.has(path)) {
233
+ activateFile(path);
234
+ return;
235
+ }
236
+
237
+ try {
238
+ const res = await fetch(`/api/files/read?path=${encodeURIComponent(path)}`);
239
+ const data = await res.json();
240
+
241
+ if (data.error) {
242
+ showToast(data.error, 'error');
243
+ return;
244
+ }
245
+
246
+ const ext = path.split('.').pop().toLowerCase();
247
+ const langMap = {
248
+ js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
249
+ py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
250
+ c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
251
+ html: 'html', css: 'css', scss: 'scss', less: 'less',
252
+ json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml',
253
+ md: 'markdown', sql: 'sql', sh: 'shell', bash: 'shell',
254
+ dockerfile: 'dockerfile', makefile: 'makefile'
255
+ };
256
+ const language = langMap[ext] || 'plaintext';
257
+
258
+ const model = monaco.editor.createModel(data.content, language);
259
+
260
+ openFiles.set(path, {
261
+ content: data.content,
262
+ originalContent: data.content,
263
+ model,
264
+ language
265
+ });
266
+
267
+ activateFile(path);
268
+ showToast(`Opened ${path.split('/').pop()}`);
269
+ } catch (err) {
270
+ showToast('Failed to open file', 'error');
271
+ }
272
+ }
273
+
274
+ function activateFile(path) {
275
+ activeFilePath = path;
276
+ const file = openFiles.get(path);
277
+
278
+ if (file && monacoEditor) {
279
+ monacoEditor.setModel(file.model);
280
+ document.getElementById('monaco-editor').style.display = 'block';
281
+ document.getElementById('editor-empty').style.display = 'none';
282
+ }
283
+
284
+ renderEditorTabs();
285
+ if (currentFileItems.length > 0) {
286
+ renderFileTree(currentFileItems);
287
+ }
288
+ }
289
+
290
+ function renderEditorTabs() {
291
+ const container = document.getElementById('editor-tabs');
292
+
293
+ if (openFiles.size === 0) {
294
+ container.innerHTML = '';
295
+ document.getElementById('monaco-editor').style.display = 'none';
296
+ document.getElementById('editor-empty').style.display = 'flex';
297
+ return;
298
+ }
299
+
300
+ container.innerHTML = Array.from(openFiles.entries()).map(([path, file]) => {
301
+ const name = path.split('/').pop();
302
+ const isModified = file.content !== file.originalContent;
303
+ const isActive = path === activeFilePath;
304
+
305
+ return `
306
+ <div class="editor-tab ${isActive ? 'active' : ''} ${isModified ? 'modified' : ''}" onclick="activateFile('${path}')">
307
+ <span>${name}</span>
308
+ <span class="editor-tab-close" onclick="event.stopPropagation(); closeFile('${path}')">x</span>
309
+ </div>
310
+ `;
311
+ }).join('');
312
+ }
313
+
314
+ async function saveFile(path) {
315
+ const file = openFiles.get(path);
316
+ if (!file) return;
317
+
318
+ try {
319
+ const res = await fetch('/api/files/write', {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json' },
322
+ body: JSON.stringify({ path, content: file.content })
323
+ });
324
+
325
+ const data = await res.json();
326
+
327
+ if (data.error) {
328
+ showToast(data.error, 'error');
329
+ return;
330
+ }
331
+
332
+ file.originalContent = file.content;
333
+ renderEditorTabs();
334
+ showToast(`Saved ${path.split('/').pop()}`);
335
+ } catch (err) {
336
+ showToast('Failed to save file', 'error');
337
+ }
338
+ }
339
+
340
+ function closeFile(path) {
341
+ const file = openFiles.get(path);
342
+ if (!file) return;
343
+
344
+ if (file.content !== file.originalContent) {
345
+ if (!confirm(`${path.split('/').pop()} has unsaved changes. Close anyway?`)) {
346
+ return;
347
+ }
348
+ }
349
+
350
+ file.model.dispose();
351
+ openFiles.delete(path);
352
+
353
+ if (activeFilePath === path) {
354
+ const remaining = Array.from(openFiles.keys());
355
+ if (remaining.length > 0) {
356
+ activateFile(remaining[remaining.length - 1]);
357
+ } else {
358
+ activeFilePath = null;
359
+ renderEditorTabs();
360
+ }
361
+ } else {
362
+ renderEditorTabs();
363
+ }
364
+ }
365
+
366
+ // File management (delete/rename)
367
+ async function deleteFile(path) {
368
+ if (!confirm(`Are you sure you want to delete "${path.split('/').pop()}"?`)) {
369
+ return;
370
+ }
371
+
372
+ try {
373
+ const res = await fetch(`/api/files/delete`, {
374
+ method: 'POST',
375
+ headers: { 'Content-Type': 'application/json' },
376
+ body: JSON.stringify({ path })
377
+ });
378
+
379
+ const data = await res.json();
380
+
381
+ if (data.error) {
382
+ showToast(data.error, 'error');
383
+ return;
384
+ }
385
+
386
+ // Close file if open
387
+ if (openFiles.has(path)) {
388
+ const file = openFiles.get(path);
389
+ file.model.dispose();
390
+ openFiles.delete(path);
391
+ if (activeFilePath === path) {
392
+ activeFilePath = null;
393
+ renderEditorTabs();
394
+ }
395
+ }
396
+
397
+ showToast(`Deleted ${path.split('/').pop()}`);
398
+ loadFiles(currentBrowsePath);
399
+ } catch (err) {
400
+ showToast('Failed to delete file', 'error');
401
+ }
402
+ }
403
+
404
+ async function renameFile(oldPath, newName) {
405
+ const dir = oldPath.substring(0, oldPath.lastIndexOf('/'));
406
+ const newPath = dir ? `${dir}/${newName}` : newName;
407
+
408
+ try {
409
+ const res = await fetch(`/api/files/rename`, {
410
+ method: 'POST',
411
+ headers: { 'Content-Type': 'application/json' },
412
+ body: JSON.stringify({ oldPath, newPath })
413
+ });
414
+
415
+ const data = await res.json();
416
+
417
+ if (data.error) {
418
+ showToast(data.error, 'error');
419
+ return;
420
+ }
421
+
422
+ // Update open file reference
423
+ if (openFiles.has(oldPath)) {
424
+ const file = openFiles.get(oldPath);
425
+ openFiles.delete(oldPath);
426
+ openFiles.set(newPath, file);
427
+ if (activeFilePath === oldPath) {
428
+ activeFilePath = newPath;
429
+ }
430
+ renderEditorTabs();
431
+ }
432
+
433
+ showToast(`Renamed to ${newName}`);
434
+ loadFiles(currentBrowsePath);
435
+ } catch (err) {
436
+ showToast('Failed to rename file', 'error');
437
+ }
438
+ }
439
+
440
+ // Context Menu
441
+ let contextMenu = null;
442
+
443
+ function showFileContextMenu(event, path, isDirectory) {
444
+ event.preventDefault();
445
+ event.stopPropagation();
446
+
447
+ hideContextMenu();
448
+
449
+ contextMenu = document.createElement('div');
450
+ contextMenu.className = 'context-menu';
451
+ contextMenu.innerHTML = `
452
+ ${!isDirectory ? `<div class="context-menu-item" onclick="openFile('${path}'); hideContextMenu();">Open</div>` : ''}
453
+ <div class="context-menu-item" onclick="promptRename('${path}'); hideContextMenu();">Rename</div>
454
+ <div class="context-menu-divider"></div>
455
+ <div class="context-menu-item danger" onclick="deleteFile('${path}'); hideContextMenu();">Delete</div>
456
+ `;
457
+
458
+ contextMenu.style.left = `${event.clientX}px`;
459
+ contextMenu.style.top = `${event.clientY}px`;
460
+ document.body.appendChild(contextMenu);
461
+
462
+ // Adjust position if menu goes off screen
463
+ const rect = contextMenu.getBoundingClientRect();
464
+ if (rect.right > window.innerWidth) {
465
+ contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`;
466
+ }
467
+ if (rect.bottom > window.innerHeight) {
468
+ contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`;
469
+ }
470
+ }
471
+
472
+ function hideContextMenu() {
473
+ if (contextMenu) {
474
+ contextMenu.remove();
475
+ contextMenu = null;
476
+ }
477
+ }
478
+
479
+ document.addEventListener('click', hideContextMenu);
480
+
481
+ function promptRename(path) {
482
+ const currentName = path.split('/').pop();
483
+ const newName = prompt('Enter new name:', currentName);
484
+ if (newName && newName !== currentName) {
485
+ renameFile(path, newName);
486
+ }
487
+ }
488
+
489
+ // Export functions
490
+ window.switchTab = switchTab;
491
+ window.navigateToPath = navigateToPath;
492
+ window.openFile = openFile;
493
+ window.activateFile = activateFile;
494
+ window.closeFile = closeFile;
495
+ window.saveFile = saveFile;
496
+ window.deleteFile = deleteFile;
497
+ window.renameFile = renameFile;
498
+ window.showFileContextMenu = showFileContextMenu;
499
+ window.hideContextMenu = hideContextMenu;
500
+ window.promptRename = promptRename;
501
+ window.handleFileSearch = handleFileSearch;
502
+
503
+ // Initialize xterm.js
504
+ const term = new Terminal({
505
+ cursorBlink: true,
506
+ fontSize: 14,
507
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
508
+ theme: {
509
+ background: '#0d1117',
510
+ foreground: '#c9d1d9',
511
+ cursor: '#58a6ff',
512
+ cursorAccent: '#0d1117',
513
+ selection: 'rgba(56, 139, 253, 0.3)',
514
+ black: '#484f58',
515
+ red: '#ff7b72',
516
+ green: '#3fb950',
517
+ yellow: '#d29922',
518
+ blue: '#58a6ff',
519
+ magenta: '#bc8cff',
520
+ cyan: '#39c5cf',
521
+ white: '#b1bac4',
522
+ brightBlack: '#6e7681',
523
+ brightRed: '#ffa198',
524
+ brightGreen: '#56d364',
525
+ brightYellow: '#e3b341',
526
+ brightBlue: '#79c0ff',
527
+ brightMagenta: '#d2a8ff',
528
+ brightCyan: '#56d4dd',
529
+ brightWhite: '#f0f6fc'
530
+ }
531
+ });
532
+
533
+ const fitAddon = new FitAddon.FitAddon();
534
+ term.loadAddon(fitAddon);
535
+
536
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
537
+ term.loadAddon(webLinksAddon);
538
+
539
+ term.open(terminalContainer);
540
+ fitAddon.fit();
541
+
542
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
543
+ const wsUrl = `${protocol}//${window.location.host}`;
544
+ let ws;
545
+
546
+ function showToast(message, type = 'success') {
547
+ toastMessage.textContent = message;
548
+ toast.className = `toast show ${type}`;
549
+ setTimeout(() => {
550
+ toast.classList.remove('show');
551
+ }, 3000);
552
+ }
553
+
554
+ async function updateSystemInfo() {
555
+ try {
556
+ const res = await fetch('/api/system');
557
+ const data = await res.json();
558
+
559
+ document.getElementById('cpu-value').textContent = `${data.cpu.usage}%`;
560
+ document.getElementById('mem-value').textContent = `${data.memory.usage}%`;
561
+ } catch (err) {
562
+ console.error('Failed to fetch system info:', err);
563
+ }
564
+ }
565
+
566
+ updateSystemInfo();
567
+ setInterval(updateSystemInfo, 3000);
568
+
569
+ function renderTerminalTabs() {
570
+ const container = document.getElementById('terminal-tabs');
571
+
572
+ let tabsHtml = '';
573
+
574
+ tmuxSessions.forEach(session => {
575
+ const isActive = currentSessionId && sessionIdDisplay.textContent === `tmux:${session.name}`;
576
+ tabsHtml += `
577
+ <div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToTmux('${session.name}')">
578
+ <span class="tmux-badge">tmux</span>
579
+ <span>${session.name}</span>
580
+ </div>
581
+ `;
582
+ });
583
+
584
+ sessions.forEach(session => {
585
+ const isActive = session.id === currentSessionId;
586
+ tabsHtml += `
587
+ <div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToSession('${session.id}')">
588
+ <span>${session.id.slice(0, 8)}</span>
589
+ <span class="terminal-tab-close" onclick="event.stopPropagation(); killSession('${session.id}')">x</span>
590
+ </div>
591
+ `;
592
+ });
593
+
594
+ tabsHtml += `<button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>`;
595
+ tabsHtml += `<button class="terminal-tab-new" onclick="refreshTmuxSessions()" title="Refresh tmux" style="font-size: 12px;">&#8635;</button>`;
596
+
597
+ container.innerHTML = tabsHtml;
598
+ }
599
+
600
+ function renderSessions() {
601
+ renderTerminalTabs();
602
+ }
603
+
604
+ function renderTmuxSessions() {
605
+ renderTerminalTabs();
606
+ }
607
+
608
+ function attachToTmux(sessionName) {
609
+ if (currentSessionId) {
610
+ ws.send(JSON.stringify({ type: 'detach' }));
611
+ }
612
+ currentSessionId = null;
613
+ localStorage.removeItem('devtunnel-session-id');
614
+ term.clear();
615
+ ws.send(JSON.stringify({ type: 'attach-tmux', tmuxSession: sessionName }));
616
+ switchTab('terminal');
617
+ }
618
+
619
+ function refreshTmuxSessions() {
620
+ ws.send(JSON.stringify({ type: 'refresh-tmux' }));
621
+ }
622
+
623
+ window.attachToTmux = attachToTmux;
624
+ window.refreshTmuxSessions = refreshTmuxSessions;
625
+
626
+ function renderTunnels() {
627
+ const list = document.getElementById('tunnel-list');
628
+ document.getElementById('tunnel-count').textContent = tunnels.length;
629
+
630
+ if (tunnels.length === 0) {
631
+ list.innerHTML = '<div class="empty-state">No active tunnels</div>';
632
+ return;
633
+ }
634
+
635
+ list.innerHTML = tunnels.map(tunnel => `
636
+ <div class="item-card">
637
+ <div class="item-card-header">
638
+ <span class="item-title">Port ${tunnel.port}</span>
639
+ <div style="display: flex; gap: 4px; align-items: center;">
640
+ <span class="item-status ${tunnel.status}">${tunnel.status}</span>
641
+ <button class="btn btn-danger btn-sm" onclick="stopTunnel('${tunnel.id}')" ${tunnel.status !== 'active' ? 'disabled' : ''}>x</button>
642
+ </div>
643
+ </div>
644
+ ${tunnel.url ? `
645
+ <div class="item-url">
646
+ <input type="text" value="${tunnel.url}" readonly onclick="this.select()">
647
+ <button class="btn-copy" onclick="copyUrl('${tunnel.url}', this)">Copy</button>
648
+ </div>
649
+ ` : '<div class="item-meta">Connecting...</div>'}
650
+ </div>
651
+ `).join('');
652
+ }
653
+
654
+ function copyUrl(url, button) {
655
+ navigator.clipboard.writeText(url).then(() => {
656
+ button.textContent = 'Copied!';
657
+ button.classList.add('copied');
658
+ setTimeout(() => {
659
+ button.textContent = 'Copy';
660
+ button.classList.remove('copied');
661
+ }, 2000);
662
+ });
663
+ }
664
+
665
+ function createTunnel() {
666
+ const port = parseInt(portInput.value);
667
+ if (!port || port < 1 || port > 65535) {
668
+ showToast('Please enter a valid port (1-65535)', 'error');
669
+ return;
670
+ }
671
+
672
+ createTunnelBtn.disabled = true;
673
+ createTunnelBtn.textContent = 'Creating...';
674
+
675
+ ws.send(JSON.stringify({ type: 'create-tunnel', port }));
676
+ portInput.value = '';
677
+ }
678
+
679
+ function stopTunnel(id) {
680
+ ws.send(JSON.stringify({ type: 'stop-tunnel', id }));
681
+ }
682
+
683
+ function createNewSession() {
684
+ if (currentSessionId) {
685
+ ws.send(JSON.stringify({ type: 'detach' }));
686
+ }
687
+ currentSessionId = null;
688
+ localStorage.removeItem('devtunnel-session-id');
689
+ term.clear();
690
+ ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
691
+ switchTab('terminal');
692
+ }
693
+
694
+ function attachToSession(sessionId) {
695
+ if (sessionId === currentSessionId) return;
696
+ if (currentSessionId) {
697
+ ws.send(JSON.stringify({ type: 'detach' }));
698
+ }
699
+ currentSessionId = sessionId;
700
+ localStorage.setItem('devtunnel-session-id', sessionId);
701
+ term.clear();
702
+ ws.send(JSON.stringify({ type: 'attach', sessionId }));
703
+ switchTab('terminal');
704
+ }
705
+
706
+ function killSession(sessionId) {
707
+ ws.send(JSON.stringify({ type: 'kill-session', sessionId }));
708
+ }
709
+
710
+ function toggleSection(section) {
711
+ const content = document.getElementById(`${section}-content`);
712
+ content.style.display = content.style.display === 'none' ? 'block' : 'none';
713
+ }
714
+
715
+ // Mobile sidebar toggle
716
+ function toggleMobileSidebar() {
717
+ const sidebar = document.querySelector('.sidebar');
718
+ const overlay = document.getElementById('sidebar-overlay');
719
+ sidebar.classList.toggle('open');
720
+ overlay.classList.toggle('show');
721
+ }
722
+
723
+ window.toggleMobileSidebar = toggleMobileSidebar;
724
+
725
+ window.copyUrl = copyUrl;
726
+ window.stopTunnel = stopTunnel;
727
+ window.createNewSession = createNewSession;
728
+ window.attachToSession = attachToSession;
729
+ window.killSession = killSession;
730
+ window.toggleSection = toggleSection;
731
+
732
+ function connect() {
733
+ ws = new WebSocket(wsUrl);
734
+
735
+ ws.onopen = () => {
736
+ console.log('WebSocket connected');
737
+ statusDot.classList.add('connected');
738
+ statusText.textContent = 'Connected';
739
+ reconnectOverlay.classList.remove('show');
740
+
741
+ term.write('\x1b[90mClick + to create a new session.\x1b[0m\r\n');
742
+ };
743
+
744
+ ws.onmessage = (event) => {
745
+ try {
746
+ const msg = JSON.parse(event.data);
747
+
748
+ switch (msg.type) {
749
+ case 'attached':
750
+ currentSessionId = msg.sessionId;
751
+ localStorage.setItem('devtunnel-session-id', msg.sessionId);
752
+ sessionIndicator.style.display = 'flex';
753
+ sessionIdDisplay.textContent = msg.tmuxSession ? `tmux:${msg.tmuxSession}` : msg.sessionId.slice(0, 8);
754
+
755
+ ws.send(JSON.stringify({
756
+ type: 'resize',
757
+ cols: term.cols,
758
+ rows: term.rows
759
+ }));
760
+
761
+ renderTerminalTabs();
762
+ showToast(msg.tmuxSession ? `Attached to tmux: ${msg.tmuxSession}` : 'Session attached');
763
+ break;
764
+
765
+ case 'output':
766
+ term.write(msg.data);
767
+ break;
768
+
769
+ case 'exit':
770
+ term.write('\r\n\x1b[31mSession ended.\x1b[0m\r\n');
771
+ break;
772
+
773
+ case 'sessions':
774
+ sessions = msg.data;
775
+ renderSessions();
776
+ break;
777
+
778
+ case 'tunnels':
779
+ tunnels = msg.data;
780
+ renderTunnels();
781
+ break;
782
+
783
+ case 'tmux-sessions':
784
+ tmuxSessions = msg.data;
785
+ renderTmuxSessions();
786
+ break;
787
+
788
+ case 'tunnel-created':
789
+ showToast(`Tunnel created for port ${msg.data.port}`);
790
+ createTunnelBtn.disabled = false;
791
+ createTunnelBtn.textContent = 'Expose';
792
+ break;
793
+
794
+ case 'tunnel-error':
795
+ showToast(msg.error, 'error');
796
+ createTunnelBtn.disabled = false;
797
+ createTunnelBtn.textContent = 'Expose';
798
+ break;
799
+
800
+ case 'error':
801
+ showToast(msg.message, 'error');
802
+ break;
803
+ }
804
+ } catch (err) {
805
+ console.error('Error parsing message:', err);
806
+ }
807
+ };
808
+
809
+ ws.onclose = () => {
810
+ console.log('WebSocket disconnected');
811
+ statusDot.classList.remove('connected');
812
+ statusText.textContent = 'Disconnected';
813
+ reconnectOverlay.classList.add('show');
814
+ };
815
+
816
+ ws.onerror = (error) => {
817
+ console.error('WebSocket error:', error);
818
+ };
819
+ }
820
+
821
+ term.onData((data) => {
822
+ if (ws && ws.readyState === WebSocket.OPEN) {
823
+ ws.send(JSON.stringify({ type: 'input', data }));
824
+ }
825
+ });
826
+
827
+ function handleResize() {
828
+ fitAddon.fit();
829
+ if (ws && ws.readyState === WebSocket.OPEN) {
830
+ ws.send(JSON.stringify({
831
+ type: 'resize',
832
+ cols: term.cols,
833
+ rows: term.rows
834
+ }));
835
+ }
836
+ }
837
+
838
+ window.addEventListener('resize', handleResize);
839
+
840
+ createTunnelBtn.addEventListener('click', createTunnel);
841
+ portInput.addEventListener('keypress', (e) => {
842
+ if (e.key === 'Enter') {
843
+ createTunnel();
844
+ }
845
+ });
846
+
847
+ connect();
848
+ term.focus();
849
+ loadFiles();