@mjasano/devtunnel 1.0.0 → 1.2.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
@@ -407,6 +407,336 @@
407
407
  border-color: #f85149;
408
408
  }
409
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
+
410
740
  @keyframes slideIn {
411
741
  from {
412
742
  transform: translateY(20px);
@@ -454,22 +784,80 @@
454
784
  </div>
455
785
  </div>
456
786
 
787
+ <!-- Tab Bar -->
788
+ <div class="tab-bar">
789
+ <div class="tab active" data-tab="terminal" onclick="switchTab('terminal')">
790
+ <span class="tab-icon">></span>
791
+ Terminal
792
+ </div>
793
+ <div class="tab" data-tab="editor" onclick="switchTab('editor')">
794
+ <span class="tab-icon">{}</span>
795
+ Editor
796
+ </div>
797
+ </div>
798
+
457
799
  <div class="main-container">
458
800
  <div id="terminal-container">
801
+ <div class="terminal-tabs" id="terminal-tabs">
802
+ <button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>
803
+ </div>
459
804
  <div id="terminal"></div>
460
805
  </div>
461
806
 
807
+ <div id="editor-container">
808
+ <div class="editor-tabs" id="editor-tabs"></div>
809
+ <div id="monaco-editor"></div>
810
+ <div class="editor-empty" id="editor-empty">
811
+ <div class="editor-empty-icon">{}</div>
812
+ <div>Select a file to edit</div>
813
+ <div style="margin-top: 8px; font-size: 12px; color: #484f58;">
814
+ Ctrl+S to save
815
+ </div>
816
+ </div>
817
+ </div>
818
+
462
819
  <div class="sidebar">
463
- <!-- Sessions Section -->
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
+
850
+ <!-- Files Section -->
464
851
  <div class="sidebar-section">
465
- <div class="sidebar-header" onclick="toggleSection('sessions')">
466
- <h2>Sessions</h2>
467
- <span class="count" id="session-count">0</span>
852
+ <div class="sidebar-header" onclick="toggleSection('files')">
853
+ <h2>Files</h2>
468
854
  </div>
469
- <div class="sidebar-content" id="sessions-content">
470
- <button class="btn btn-primary btn-sm" style="width: 100%; margin-bottom: 8px;" onclick="createNewSession()">+ New Session</button>
471
- <div id="session-list">
472
- <div class="empty-state">No sessions</div>
855
+ <div class="breadcrumb" id="file-breadcrumb">
856
+ <span class="breadcrumb-item" onclick="navigateToPath('')">~</span>
857
+ </div>
858
+ <div class="sidebar-content" id="files-content" style="max-height: 300px;">
859
+ <div class="file-tree" id="file-tree">
860
+ <div class="empty-state">Loading...</div>
473
861
  </div>
474
862
  </div>
475
863
  </div>
@@ -508,6 +896,7 @@
508
896
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
509
897
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
510
898
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
899
+ <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
511
900
  <script>
512
901
  const terminalContainer = document.getElementById('terminal');
513
902
  const statusDot = document.getElementById('status-dot');
@@ -522,8 +911,312 @@
522
911
 
523
912
  let tunnels = [];
524
913
  let sessions = [];
914
+ let tmuxSessions = [];
525
915
  let currentSessionId = localStorage.getItem('devtunnel-session-id');
526
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
+
527
1220
  // Initialize xterm.js
528
1221
  const term = new Terminal({
529
1222
  cursorBlink: true,
@@ -580,33 +1273,119 @@
580
1273
  return date.toLocaleTimeString();
581
1274
  }
582
1275
 
583
- function renderSessions() {
584
- const list = document.getElementById('session-list');
585
- document.getElementById('session-count').textContent = sessions.length;
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
+ }
586
1284
 
587
- if (sessions.length === 0) {
588
- list.innerHTML = '<div class="empty-state">No sessions</div>';
589
- return;
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);
590
1320
  }
1321
+ }
591
1322
 
592
- list.innerHTML = sessions.map(session => `
593
- <div class="item-card ${session.id === currentSessionId ? 'active' : ''}" onclick="attachToSession('${session.id}')">
594
- <div class="item-card-header">
595
- <span class="item-title">
596
- ${session.id.slice(0, 8)}...
597
- </span>
598
- <div style="display: flex; gap: 4px; align-items: center;">
599
- <span class="item-status ${session.alive ? 'active' : 'stopped'}">${session.alive ? 'alive' : 'ended'}</span>
600
- <button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); killSession('${session.id}')">x</button>
601
- </div>
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>
602
1340
  </div>
603
- <div class="item-meta">
604
- ${session.clients} client(s) connected | Last: ${formatTime(session.lastAccess)}
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>
605
1351
  </div>
606
- </div>
607
- `).join('');
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();
608
1368
  }
609
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
+
610
1389
  function renderTunnels() {
611
1390
  const list = document.getElementById('tunnel-list');
612
1391
  document.getElementById('tunnel-count').textContent = tunnels.length;
@@ -665,18 +1444,28 @@
665
1444
  }
666
1445
 
667
1446
  function createNewSession() {
1447
+ // Detach from current session first
1448
+ if (currentSessionId) {
1449
+ ws.send(JSON.stringify({ type: 'detach' }));
1450
+ }
668
1451
  currentSessionId = null;
669
1452
  localStorage.removeItem('devtunnel-session-id');
670
1453
  term.clear();
671
1454
  ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
1455
+ switchTab('terminal');
672
1456
  }
673
1457
 
674
1458
  function attachToSession(sessionId) {
675
1459
  if (sessionId === currentSessionId) return;
1460
+ // Detach from current session first
1461
+ if (currentSessionId) {
1462
+ ws.send(JSON.stringify({ type: 'detach' }));
1463
+ }
676
1464
  currentSessionId = sessionId;
677
1465
  localStorage.setItem('devtunnel-session-id', sessionId);
678
1466
  term.clear();
679
1467
  ws.send(JSON.stringify({ type: 'attach', sessionId }));
1468
+ switchTab('terminal');
680
1469
  }
681
1470
 
682
1471
  function killSession(sessionId) {
@@ -705,8 +1494,8 @@
705
1494
  statusText.textContent = 'Connected';
706
1495
  reconnectOverlay.classList.remove('show');
707
1496
 
708
- // Attach to existing or new session
709
- ws.send(JSON.stringify({ type: 'attach', sessionId: currentSessionId }));
1497
+ // Don't auto-attach - let user choose session manually
1498
+ term.write('\x1b[90mClick + to create a new session.\x1b[0m\r\n');
710
1499
  };
711
1500
 
712
1501
  ws.onmessage = (event) => {
@@ -718,7 +1507,7 @@
718
1507
  currentSessionId = msg.sessionId;
719
1508
  localStorage.setItem('devtunnel-session-id', msg.sessionId);
720
1509
  sessionIndicator.style.display = 'flex';
721
- sessionIdDisplay.textContent = msg.sessionId.slice(0, 8);
1510
+ sessionIdDisplay.textContent = msg.tmuxSession ? `tmux:${msg.tmuxSession}` : msg.sessionId.slice(0, 8);
722
1511
 
723
1512
  // Send initial size
724
1513
  ws.send(JSON.stringify({
@@ -727,7 +1516,10 @@
727
1516
  rows: term.rows
728
1517
  }));
729
1518
 
730
- showToast('Session attached');
1519
+ // Update terminal tabs to show active state
1520
+ renderTerminalTabs();
1521
+
1522
+ showToast(msg.tmuxSession ? `Attached to tmux: ${msg.tmuxSession}` : 'Session attached');
731
1523
  break;
732
1524
 
733
1525
  case 'output':
@@ -748,6 +1540,11 @@
748
1540
  renderTunnels();
749
1541
  break;
750
1542
 
1543
+ case 'tmux-sessions':
1544
+ tmuxSessions = msg.data;
1545
+ renderTmuxSessions();
1546
+ break;
1547
+
751
1548
  case 'tunnel-created':
752
1549
  showToast(`Tunnel created for port ${msg.data.port}`);
753
1550
  createTunnelBtn.disabled = false;
@@ -809,6 +1606,7 @@
809
1606
 
810
1607
  connect();
811
1608
  term.focus();
1609
+ loadFiles(); // Load initial file list
812
1610
  </script>
813
1611
  </body>
814
1612
  </html>