@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/.claude/settings.local.json +6 -1
- package/.prettierignore +2 -0
- package/.prettierrc +8 -0
- package/CHANGELOG.md +47 -0
- package/README.md +138 -0
- package/bin/cli.js +40 -11
- package/eslint.config.js +32 -0
- package/package.json +12 -3
- package/public/app.js +849 -0
- package/public/index.html +13 -1501
- package/public/login.html +274 -0
- package/public/styles.css +1212 -0
- package/server.js +276 -7
- package/test/server.test.js +204 -0
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 ? '📁' : '📄'}</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;">↻</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();
|