@mjasano/devtunnel 1.0.0 → 1.1.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/package.json +2 -2
- package/public/index.html +541 -0
- package/server.js +139 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasano/devtunnel",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Web terminal with tunnel manager - access your dev environment from anywhere",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Web terminal with code editor and tunnel manager - access your dev environment from anywhere",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"devtunnel": "./bin/cli.js"
|
package/public/index.html
CHANGED
|
@@ -407,6 +407,203 @@
|
|
|
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
|
+
.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
|
+
|
|
410
607
|
@keyframes slideIn {
|
|
411
608
|
from {
|
|
412
609
|
transform: translateY(20px);
|
|
@@ -454,12 +651,51 @@
|
|
|
454
651
|
</div>
|
|
455
652
|
</div>
|
|
456
653
|
|
|
654
|
+
<!-- Tab Bar -->
|
|
655
|
+
<div class="tab-bar">
|
|
656
|
+
<div class="tab active" data-tab="terminal" onclick="switchTab('terminal')">
|
|
657
|
+
<span class="tab-icon">></span>
|
|
658
|
+
Terminal
|
|
659
|
+
</div>
|
|
660
|
+
<div class="tab" data-tab="editor" onclick="switchTab('editor')">
|
|
661
|
+
<span class="tab-icon">{}</span>
|
|
662
|
+
Editor
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
|
|
457
666
|
<div class="main-container">
|
|
458
667
|
<div id="terminal-container">
|
|
459
668
|
<div id="terminal"></div>
|
|
460
669
|
</div>
|
|
461
670
|
|
|
671
|
+
<div id="editor-container">
|
|
672
|
+
<div class="editor-tabs" id="editor-tabs"></div>
|
|
673
|
+
<div id="monaco-editor"></div>
|
|
674
|
+
<div class="editor-empty" id="editor-empty">
|
|
675
|
+
<div class="editor-empty-icon">{}</div>
|
|
676
|
+
<div>Select a file to edit</div>
|
|
677
|
+
<div style="margin-top: 8px; font-size: 12px; color: #484f58;">
|
|
678
|
+
Ctrl+S to save
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
|
|
462
683
|
<div class="sidebar">
|
|
684
|
+
<!-- Files Section -->
|
|
685
|
+
<div class="sidebar-section">
|
|
686
|
+
<div class="sidebar-header" onclick="toggleSection('files')">
|
|
687
|
+
<h2>Files</h2>
|
|
688
|
+
</div>
|
|
689
|
+
<div class="breadcrumb" id="file-breadcrumb">
|
|
690
|
+
<span class="breadcrumb-item" onclick="navigateToPath('')">~</span>
|
|
691
|
+
</div>
|
|
692
|
+
<div class="sidebar-content" id="files-content" style="max-height: 300px;">
|
|
693
|
+
<div class="file-tree" id="file-tree">
|
|
694
|
+
<div class="empty-state">Loading...</div>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
463
699
|
<!-- Sessions Section -->
|
|
464
700
|
<div class="sidebar-section">
|
|
465
701
|
<div class="sidebar-header" onclick="toggleSection('sessions')">
|
|
@@ -508,6 +744,7 @@
|
|
|
508
744
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
|
509
745
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
|
510
746
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
|
|
747
|
+
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
|
|
511
748
|
<script>
|
|
512
749
|
const terminalContainer = document.getElementById('terminal');
|
|
513
750
|
const statusDot = document.getElementById('status-dot');
|
|
@@ -524,6 +761,309 @@
|
|
|
524
761
|
let sessions = [];
|
|
525
762
|
let currentSessionId = localStorage.getItem('devtunnel-session-id');
|
|
526
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 ? '📁' : '📄'}</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
|
+
|
|
527
1067
|
// Initialize xterm.js
|
|
528
1068
|
const term = new Terminal({
|
|
529
1069
|
cursorBlink: true,
|
|
@@ -809,6 +1349,7 @@
|
|
|
809
1349
|
|
|
810
1350
|
connect();
|
|
811
1351
|
term.focus();
|
|
1352
|
+
loadFiles(); // Load initial file list
|
|
812
1353
|
</script>
|
|
813
1354
|
</body>
|
|
814
1355
|
</html>
|
package/server.js
CHANGED
|
@@ -4,6 +4,7 @@ const WebSocket = require('ws');
|
|
|
4
4
|
const pty = require('@homebridge/node-pty-prebuilt-multiarch');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const fs = require('fs').promises;
|
|
7
8
|
const { spawn } = require('child_process');
|
|
8
9
|
const crypto = require('crypto');
|
|
9
10
|
|
|
@@ -364,6 +365,144 @@ wss.on('connection', (ws) => {
|
|
|
364
365
|
});
|
|
365
366
|
});
|
|
366
367
|
|
|
368
|
+
// File System API
|
|
369
|
+
const WORKSPACE_ROOT = process.env.WORKSPACE || os.homedir();
|
|
370
|
+
|
|
371
|
+
// Get file/directory listing
|
|
372
|
+
app.get('/api/files', async (req, res) => {
|
|
373
|
+
try {
|
|
374
|
+
const requestedPath = req.query.path || '';
|
|
375
|
+
const fullPath = path.join(WORKSPACE_ROOT, requestedPath);
|
|
376
|
+
|
|
377
|
+
// Security: ensure path is within workspace
|
|
378
|
+
const normalizedPath = path.normalize(fullPath);
|
|
379
|
+
if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
|
|
380
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const stats = await fs.stat(normalizedPath);
|
|
384
|
+
|
|
385
|
+
if (stats.isDirectory()) {
|
|
386
|
+
const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
|
387
|
+
const items = await Promise.all(
|
|
388
|
+
entries
|
|
389
|
+
.filter(entry => !entry.name.startsWith('.')) // Hide hidden files
|
|
390
|
+
.map(async (entry) => {
|
|
391
|
+
const itemPath = path.join(normalizedPath, entry.name);
|
|
392
|
+
try {
|
|
393
|
+
const itemStats = await fs.stat(itemPath);
|
|
394
|
+
return {
|
|
395
|
+
name: entry.name,
|
|
396
|
+
path: path.join(requestedPath, entry.name),
|
|
397
|
+
isDirectory: entry.isDirectory(),
|
|
398
|
+
size: itemStats.size,
|
|
399
|
+
modified: itemStats.mtime
|
|
400
|
+
};
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Sort: directories first, then files
|
|
408
|
+
const validItems = items.filter(Boolean).sort((a, b) => {
|
|
409
|
+
if (a.isDirectory !== b.isDirectory) {
|
|
410
|
+
return a.isDirectory ? -1 : 1;
|
|
411
|
+
}
|
|
412
|
+
return a.name.localeCompare(b.name);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
res.json({
|
|
416
|
+
path: requestedPath,
|
|
417
|
+
items: validItems
|
|
418
|
+
});
|
|
419
|
+
} else {
|
|
420
|
+
res.json({
|
|
421
|
+
path: requestedPath,
|
|
422
|
+
isFile: true,
|
|
423
|
+
size: stats.size,
|
|
424
|
+
modified: stats.mtime
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (err.code === 'ENOENT') {
|
|
429
|
+
res.status(404).json({ error: 'Path not found' });
|
|
430
|
+
} else {
|
|
431
|
+
res.status(500).json({ error: err.message });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Read file content
|
|
437
|
+
app.get('/api/files/read', async (req, res) => {
|
|
438
|
+
try {
|
|
439
|
+
const requestedPath = req.query.path;
|
|
440
|
+
if (!requestedPath) {
|
|
441
|
+
return res.status(400).json({ error: 'Path required' });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const fullPath = path.join(WORKSPACE_ROOT, requestedPath);
|
|
445
|
+
const normalizedPath = path.normalize(fullPath);
|
|
446
|
+
|
|
447
|
+
if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
|
|
448
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const stats = await fs.stat(normalizedPath);
|
|
452
|
+
|
|
453
|
+
// Limit file size (5MB)
|
|
454
|
+
if (stats.size > 5 * 1024 * 1024) {
|
|
455
|
+
return res.status(413).json({ error: 'File too large' });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const content = await fs.readFile(normalizedPath, 'utf-8');
|
|
459
|
+
res.json({
|
|
460
|
+
path: requestedPath,
|
|
461
|
+
content,
|
|
462
|
+
size: stats.size,
|
|
463
|
+
modified: stats.mtime
|
|
464
|
+
});
|
|
465
|
+
} catch (err) {
|
|
466
|
+
if (err.code === 'ENOENT') {
|
|
467
|
+
res.status(404).json({ error: 'File not found' });
|
|
468
|
+
} else {
|
|
469
|
+
res.status(500).json({ error: err.message });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// Write file content
|
|
475
|
+
app.post('/api/files/write', async (req, res) => {
|
|
476
|
+
try {
|
|
477
|
+
const { path: filePath, content } = req.body;
|
|
478
|
+
|
|
479
|
+
if (!filePath) {
|
|
480
|
+
return res.status(400).json({ error: 'Path required' });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const fullPath = path.join(WORKSPACE_ROOT, filePath);
|
|
484
|
+
const normalizedPath = path.normalize(fullPath);
|
|
485
|
+
|
|
486
|
+
if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
|
|
487
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Ensure parent directory exists
|
|
491
|
+
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
|
492
|
+
|
|
493
|
+
await fs.writeFile(normalizedPath, content, 'utf-8');
|
|
494
|
+
const stats = await fs.stat(normalizedPath);
|
|
495
|
+
|
|
496
|
+
res.json({
|
|
497
|
+
path: filePath,
|
|
498
|
+
size: stats.size,
|
|
499
|
+
modified: stats.mtime
|
|
500
|
+
});
|
|
501
|
+
} catch (err) {
|
|
502
|
+
res.status(500).json({ error: err.message });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
367
506
|
// REST API
|
|
368
507
|
app.get('/api/sessions', (req, res) => {
|
|
369
508
|
const sessionList = Array.from(sessions.values()).map(s => ({
|