webtun 1.5.4 → 1.5.5
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/README.md +10 -4
- package/package.json +1 -1
- package/public/index.html +132 -105
- package/server.js +11 -6
package/README.md
CHANGED
|
@@ -69,17 +69,16 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
|
|
|
69
69
|
- **Bracketed paste** — Ctrl+V works in TUI apps (vim, nano, htop) — 60KB chunked
|
|
70
70
|
- **Command history** — keystroke-based capture, strips ANSI/control sequences
|
|
71
71
|
- **Session persistence** — tmux or in-memory PTY; tabs survive page reload (all platforms)
|
|
72
|
-
- **Fullscreen** — terminal fullscreen `Shift+F11` (header button), fits and refocuses
|
|
73
72
|
- **Keyboard Shortcuts** — reference dialog in the overflow menu (Ctrl+P/T/W/B/F, etc.)
|
|
74
73
|
|
|
75
74
|
### File Explorer
|
|
76
|
-
- Browse, upload, download, rename, delete files
|
|
75
|
+
- Browse, upload, download, rename, delete files — **full filesystem access** (`ALLOW_FULL_FS=true` by default)
|
|
77
76
|
- **Multi-select** — batch delete, download, zip, cut, copy
|
|
78
77
|
- **Zip/Extract** — right-click any file or folder
|
|
79
78
|
- **Image viewer** — preview inline (png, jpg, gif, svg, webp)
|
|
80
79
|
- **Conflict resolution** — replace, merge, keep both, skip
|
|
81
80
|
- **Breadcrumb navigation** — tappable segments, folder icon + path one line, `2px` tight spacing, header/breadcrumb unified `30px` bar
|
|
82
|
-
- **Editor** — CodeMirror with autosave draft, `F11` fullscreen
|
|
81
|
+
- **Editor** — CodeMirror with autosave draft, `F11` fullscreen (covers terminal area only, never the file explorer), `horizontal/vertical` split toggle, markdown/HTML preview
|
|
83
82
|
|
|
84
83
|
### Mobile
|
|
85
84
|
- **Touch gestures** — swipe to close tabs, pull-to-refresh
|
|
@@ -135,7 +134,7 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
|
|
|
135
134
|
| `PIN` | Auth PIN (empty = no auth) | none |
|
|
136
135
|
| `SHELL` | Shell to use | platform default |
|
|
137
136
|
| `WORKSPACE_ROOT` | File explorer root | `~` |
|
|
138
|
-
| `ALLOW_FULL_FS` | Allow browsing outside `WORKSPACE_ROOT` (full FS) | `
|
|
137
|
+
| `ALLOW_FULL_FS` | Allow browsing outside `WORKSPACE_ROOT` (full FS) | `true` |
|
|
139
138
|
| `TRUST_PROXY` | Trust `X-Forwarded-For` from first proxy | `false` |
|
|
140
139
|
| `WEBTUN_SHELL` | Override shell on Windows | PowerShell |
|
|
141
140
|
|
|
@@ -178,6 +177,13 @@ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh &&
|
|
|
178
177
|
|
|
179
178
|
## Changelog
|
|
180
179
|
|
|
180
|
+
### v1.5.5
|
|
181
|
+
- Explorer: full filesystem access by default (`ALLOW_FULL_FS=true` unless `ALLOW_FULL_FS=false`), symlink dirs now navigable (`stat` `isDirectory`), fixed silent `403` bounce on `..` (now `toast` + error div), `parent` hidden at workspace root, `history`/`breadcrumb` desync fixed (deferred to success), stale closures on reused file items fixed (`dataset` live), drag overlay stuck fixed (`relatedTarget`/`dragend`/`blur`), relative `path-input` now `joinPath(currentPath)`, `rename` dialog no longer closes on error
|
|
182
|
+
- Settings: drawer flicker fixed (`#main:isolation`, `#sidebar`/`#settings-panel` `will-change`/`backface-visibility`, `#file-list-wrap` `content-visibility` removed, `inert`/`focusTrap` deferred via `rAF` + `preventScroll`, `fileWatcher` skips when drawer/overlay open)
|
|
183
|
+
- Editor: `F11` fullscreen now `absolute` inside terminal area only (`#editor-split-area` `relative`), never covers file explorer/sidebar or header, removed `requestFullscreen` browser overlay, `CodeMirror` height `100%`
|
|
184
|
+
- Terminal: removed `Shift+F11` fullscreen button and `#terminals.fullscreen`/`#content.terminal-fullscreen` per request
|
|
185
|
+
- UI: `#conn-status` `margin-left:6px` before dot
|
|
186
|
+
|
|
181
187
|
### v1.5.4
|
|
182
188
|
- Security hardening: path sandbox `ALLOW_FULL_FS` (default `~`), symlink containment, null-byte/array guards, `rename` traversal block, `delete` uses `lstat`, batch caps 100, zip 1GB/unzip bomb guards, `download` header sanitization, `image` no-store, `write` 10MB cap, tunnel SSRF (localhost-only) + URL validation
|
|
183
189
|
- Auth & transport: `checkPin` array guard, length-leak free `constantTimeEqual`, rate limiter uses `req.ip` with `trust proxy` + Map cap 10k, `--pin` arg consumption fix, WS `cols/rows` clamp 2-500, Origin check + `constantTimeEqual` token, `sessionId` length 1-64, per-IP `clipboard`, `history` max 1k & `cmdhist.json` 0600 atomic writes, `ptySessions` TTL 30m + cap 100, `TMUX_PREFIX=wt-webtun-`
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -208,10 +208,10 @@ svg { display: block; }
|
|
|
208
208
|
#upload-progress.active #upload-progress-text { opacity: 1; }
|
|
209
209
|
#upload-cancel-btn { position: absolute; right: 8px; top: -28px; height: 22px; padding: 0 8px; font-size: 11px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); color: var(--fg2); cursor: pointer; display: none; z-index: 2; }
|
|
210
210
|
#upload-progress.active #upload-cancel-btn { display: block; pointer-events: auto; }
|
|
211
|
-
#main { display: flex; flex: 1; overflow: hidden; min-height: 0; position: relative; }
|
|
212
|
-
#sidebar { position: relative; width: var(--sidebar-w); background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0; transition: var(--transition-normal); }
|
|
213
|
-
#sidebar.dragging { transition: none; }
|
|
214
|
-
#sidebar.hidden { width: 0 !important; }
|
|
211
|
+
#main { display: flex; flex: 1; overflow: hidden; min-height: 0; position: relative; isolation: isolate; }
|
|
212
|
+
#sidebar { position: relative; width: var(--sidebar-w); background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0; transition: width var(--transition-normal) ease, transform var(--transition-normal) ease; will-change: width, transform; backface-visibility: hidden; }
|
|
213
|
+
#sidebar.dragging { transition: none; will-change: width; }
|
|
214
|
+
#sidebar.hidden { width: 0 !important; overflow: hidden; }
|
|
215
215
|
#content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; min-height: 0; position: relative; }
|
|
216
216
|
#tab-bar { display: flex; align-items: center; height: var(--tab-h); background: var(--bg2); border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 2px; padding: 0 4px; }
|
|
217
217
|
#tab-scroll { display: flex; align-items: center; gap: 2px; height: 100%; flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); -webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); scrollbar-width: none; }
|
|
@@ -267,17 +267,11 @@ svg { display: block; }
|
|
|
267
267
|
}
|
|
268
268
|
#editor-view { display: none; flex-direction: column; background: var(--bg); overflow: hidden; }
|
|
269
269
|
#editor-view.open { display: flex; }
|
|
270
|
-
#editor-view.fullscreen { position:
|
|
270
|
+
#editor-view.fullscreen { position: absolute; inset: 0; z-index: 30; width: 100%; height: 100%; background: var(--bg); border: none; }
|
|
271
271
|
#editor-view.fullscreen #editor-header { border-bottom: 1px solid var(--border); background: var(--bg2); }
|
|
272
272
|
#editor-view.fullscreen #editor-area { flex: 1; min-height: 0; }
|
|
273
|
-
#editor-view.fullscreen #editor-area .CodeMirror { height:
|
|
274
|
-
#
|
|
275
|
-
#terminals.fullscreen .term-wrapper { flex: 1; min-height: 0; }
|
|
276
|
-
#terminals.fullscreen #search-bar { top: 8px; }
|
|
277
|
-
#content.terminal-fullscreen { position: fixed; inset: 0; z-index: var(--z-overlay); background: var(--bg); display: flex; flex-direction: column; }
|
|
278
|
-
#content.terminal-fullscreen #tab-bar { flex-shrink: 0; }
|
|
279
|
-
#content.terminal-fullscreen #editor-split-area { flex: 1; min-height: 0; }
|
|
280
|
-
#content.terminal-fullscreen #sidebar { display: none !important; }
|
|
273
|
+
#editor-view.fullscreen #editor-area .CodeMirror { height: 100%; }
|
|
274
|
+
#editor-split-area { position: relative; }
|
|
281
275
|
|
|
282
276
|
/* Desktop: split layout */
|
|
283
277
|
@media (min-width: 769px) {
|
|
@@ -350,8 +344,11 @@ svg { display: block; }
|
|
|
350
344
|
background: var(--bg2); border-left: 1px solid var(--border);
|
|
351
345
|
display: flex; flex-direction: column;
|
|
352
346
|
transform: translateX(100%);
|
|
353
|
-
transition: var(--transition-normal);
|
|
347
|
+
transition: transform var(--transition-normal) ease;
|
|
348
|
+
will-change: transform;
|
|
349
|
+
backface-visibility: hidden;
|
|
354
350
|
z-index: var(--z-drawer);
|
|
351
|
+
contain: layout style;
|
|
355
352
|
}
|
|
356
353
|
#settings-panel.open {
|
|
357
354
|
transform: translateX(0);
|
|
@@ -401,7 +398,7 @@ svg { display: block; }
|
|
|
401
398
|
.tunnel-status, .tunnel-target { font-size: 9px; padding: 1px 5px; border-radius: 4px; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
|
|
402
399
|
.tunnel-btn { width: 28px; height: 28px; min-width: 28px; min-height: 28px; flex-shrink: 0; }
|
|
403
400
|
.tunnel-btn.stop { color: var(--red); }
|
|
404
|
-
#conn-status { transition: background var(--transition-slow); }
|
|
401
|
+
#conn-status { transition: background var(--transition-slow); margin-left: 6px; }
|
|
405
402
|
.reconnect-spinner { display: inline-flex; vertical-align: -1px; margin-right: 4px; }
|
|
406
403
|
.reconnect-spinner svg { animation: reconnectSpin 1s linear infinite; }
|
|
407
404
|
@keyframes reconnectSpin { to { transform: rotate(360deg); } }
|
|
@@ -429,7 +426,7 @@ svg { display: block; }
|
|
|
429
426
|
#sidebar-header { flex-wrap: wrap; height: auto; }
|
|
430
427
|
#file-breadcrumb { white-space: nowrap; overflow-x: auto; line-height: 1; scrollbar-width: none; font-size: 11px; color: var(--fg2); }
|
|
431
428
|
#sidebar-header input { flex: 1 1 60px; min-width: 40px; height: 24px; font-size: 12px; border-radius: var(--radius-sm); }
|
|
432
|
-
#file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; position: relative; overscroll-behavior: contain;
|
|
429
|
+
#file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; position: relative; overscroll-behavior: contain; }
|
|
433
430
|
#ptr-indicator { position: absolute; top: -40px; left: 0; right: 0; height: 36px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 11px; color: var(--fg2); opacity: 0; pointer-events: none; }
|
|
434
431
|
#ptr-indicator .ptr-arrow { display: inline-flex; }
|
|
435
432
|
#ptr-indicator .ptr-arrow svg { transition: transform 0.2s ease; }
|
|
@@ -1061,9 +1058,6 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
|
|
|
1061
1058
|
<button class="icon-btn" id="tilesBtn" aria-label="Tiles Mode" title="Tiles Mode" onclick="toggleTiles()">
|
|
1062
1059
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
1063
1060
|
</button>
|
|
1064
|
-
<button class="icon-btn" id="term-fullscreen-btn" aria-label="Terminal fullscreen" title="Terminal fullscreen (Shift+F11)" onclick="toggleTerminalFullscreen()">
|
|
1065
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
1066
|
-
</button>
|
|
1067
1061
|
<button class="icon-btn" id="more-btn" aria-label="More options" title="More options" onclick="toggleMoreMenu()">
|
|
1068
1062
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>
|
|
1069
1063
|
</button>
|
|
@@ -1850,6 +1844,7 @@ async function unlockApp() {
|
|
|
1850
1844
|
setupSwipeGestures();
|
|
1851
1845
|
setupFileListTouch();
|
|
1852
1846
|
setupFileListKeyboard();
|
|
1847
|
+
setupFileListContextMenu();
|
|
1853
1848
|
setupCtxMenuKeyboard();
|
|
1854
1849
|
setupMoreMenuKeyboard();
|
|
1855
1850
|
restoreSidebarWidth();
|
|
@@ -2897,21 +2892,10 @@ async function loadFiles(dir) {
|
|
|
2897
2892
|
if (loadFilesAbortController) loadFilesAbortController.abort();
|
|
2898
2893
|
loadFilesAbortController = new AbortController();
|
|
2899
2894
|
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
} else {
|
|
2903
|
-
if (currentPath && currentPath !== dir) {
|
|
2904
|
-
navHistory.push(currentPath);
|
|
2905
|
-
}
|
|
2906
|
-
navForwardHistory = [];
|
|
2907
|
-
}
|
|
2895
|
+
const prevPath = currentPath;
|
|
2896
|
+
const shouldPushHistory = !skipHistoryPush && currentPath && currentPath !== dir;
|
|
2908
2897
|
skipHistoryPush = false;
|
|
2909
2898
|
|
|
2910
|
-
currentPath = dir;
|
|
2911
|
-
document.getElementById('path-input').value = dir;
|
|
2912
|
-
renderBreadcrumb(dir);
|
|
2913
|
-
saveCurrentPath();
|
|
2914
|
-
|
|
2915
2899
|
const list = document.getElementById('file-list');
|
|
2916
2900
|
|
|
2917
2901
|
setFileListLoading(true);
|
|
@@ -2924,19 +2908,52 @@ async function loadFiles(dir) {
|
|
|
2924
2908
|
const errorDiv = document.createElement('div');
|
|
2925
2909
|
errorDiv.style.cssText = 'padding:16px;text-align:center;';
|
|
2926
2910
|
errorDiv.innerHTML = '<div style="color:var(--red);margin-bottom:8px">Failed to load directory</div>' +
|
|
2911
|
+
'<div style="color:var(--fg2);font-size:11px;margin-bottom:8px;word-break:break-all">' + escHtml(e.message || 'Unknown error') + '</div>' +
|
|
2927
2912
|
'<button class="btn btn-primary" style="height:28px;padding:0 12px;font-size:11px">Retry</button>';
|
|
2928
2913
|
list.innerHTML = '';
|
|
2929
2914
|
list.appendChild(errorDiv);
|
|
2930
2915
|
errorDiv.querySelector('button').addEventListener('click', () => loadFiles(dir));
|
|
2916
|
+
toast(e.message || 'Failed to load directory', 'error');
|
|
2931
2917
|
return;
|
|
2932
2918
|
}
|
|
2933
2919
|
|
|
2934
2920
|
if (!data.files) {
|
|
2935
2921
|
setFileListLoading(false);
|
|
2936
|
-
|
|
2922
|
+
const msg = data.error || 'Failed to load directory';
|
|
2923
|
+
// Revert optimistic path — don't save failed dir
|
|
2924
|
+
// Show error instead of silent bounce
|
|
2925
|
+
const errorDiv = document.createElement('div');
|
|
2926
|
+
errorDiv.style.cssText = 'padding:16px;text-align:center;';
|
|
2927
|
+
errorDiv.innerHTML = '<div style="color:var(--red);margin-bottom:8px">' + escHtml(msg) + '</div>' +
|
|
2928
|
+
'<button class="btn btn-primary" style="height:28px;padding:0 12px;font-size:11px">Retry</button>';
|
|
2929
|
+
list.innerHTML = '';
|
|
2930
|
+
list.appendChild(errorDiv);
|
|
2931
|
+
errorDiv.querySelector('button').addEventListener('click', () => loadFiles(dir));
|
|
2932
|
+
toast(msg, 'error');
|
|
2933
|
+
// Only bounce to home if dir != home and home is different and we haven't already shown home
|
|
2934
|
+
if (dir !== homeDir && prevPath !== homeDir) {
|
|
2935
|
+
// keep currentPath as prevPath, don't update breadcrumb to failed dir
|
|
2936
|
+
document.getElementById('path-input').value = prevPath;
|
|
2937
|
+
renderBreadcrumb(prevPath);
|
|
2938
|
+
} else {
|
|
2939
|
+
// we are already at home or prevPath is home — update UI to reflect attempted dir for debugging
|
|
2940
|
+
document.getElementById('path-input').value = dir;
|
|
2941
|
+
renderBreadcrumb(dir);
|
|
2942
|
+
}
|
|
2937
2943
|
return;
|
|
2938
2944
|
}
|
|
2939
2945
|
|
|
2946
|
+
// Success — now update history and UI state
|
|
2947
|
+
if (shouldPushHistory) {
|
|
2948
|
+
navHistory.push(prevPath);
|
|
2949
|
+
navForwardHistory = [];
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
currentPath = data.path || dir;
|
|
2953
|
+
document.getElementById('path-input').value = currentPath;
|
|
2954
|
+
renderBreadcrumb(currentPath);
|
|
2955
|
+
saveCurrentPath();
|
|
2956
|
+
|
|
2940
2957
|
currentParent = data.parent || '';
|
|
2941
2958
|
|
|
2942
2959
|
// Map existing DOM children by dataset.path
|
|
@@ -3000,13 +3017,16 @@ async function loadFiles(dir) {
|
|
|
3000
3017
|
sizeEl.remove();
|
|
3001
3018
|
}
|
|
3002
3019
|
existing.dataset.isDir = String(f.isDir);
|
|
3020
|
+
existing.dataset.path = key;
|
|
3021
|
+
const checkEl = existing.querySelector('.file-select-check');
|
|
3022
|
+
if (checkEl) checkEl.setAttribute('data-path', key);
|
|
3023
|
+
const ellEl = existing.querySelector('.file-ellipsis');
|
|
3024
|
+
if (ellEl) ellEl.setAttribute('aria-label', 'More actions for ' + f.name);
|
|
3025
|
+
const nameEl2 = existing.querySelector('.file-name');
|
|
3026
|
+
if (nameEl2) nameEl2.title = key;
|
|
3003
3027
|
}
|
|
3004
3028
|
|
|
3005
|
-
addItem(key, () =>
|
|
3006
|
-
const item = makeFileItem(f.name, f.isDir, key, sizeStr, icon);
|
|
3007
|
-
item.addEventListener('contextmenu', e => showCtxMenu(e, f));
|
|
3008
|
-
return item;
|
|
3009
|
-
});
|
|
3029
|
+
addItem(key, () => makeFileItem(f.name, f.isDir, key, sizeStr, icon));
|
|
3010
3030
|
});
|
|
3011
3031
|
|
|
3012
3032
|
// Remove any orphaned nodes (not in new data)
|
|
@@ -3048,25 +3068,28 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
|
|
|
3048
3068
|
<span class="file-ellipsis" role="button" tabindex="0" aria-label="More actions for ${escHtml(name)}" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></span>
|
|
3049
3069
|
`;
|
|
3050
3070
|
div.addEventListener('click', e => {
|
|
3071
|
+
const curPath = div.dataset.path;
|
|
3072
|
+
const curIsDir = div.dataset.isDir === 'true';
|
|
3073
|
+
const curName = (div.querySelector('.file-name')?.textContent || curPath.split(/[\\/]/).pop() || curPath);
|
|
3051
3074
|
if (e.target.closest('.file-select-check')) {
|
|
3052
|
-
toggleFileSelection(
|
|
3075
|
+
toggleFileSelection(curPath, div);
|
|
3053
3076
|
if (selectedFiles.length > 0) document.getElementById('select-actions').style.display = 'flex';
|
|
3054
3077
|
return;
|
|
3055
3078
|
}
|
|
3056
3079
|
if (e.target.closest('.file-ellipsis')) {
|
|
3057
3080
|
e.stopPropagation();
|
|
3058
3081
|
const rect = div.querySelector('.file-ellipsis').getBoundingClientRect();
|
|
3059
|
-
const m =
|
|
3060
|
-
showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path:
|
|
3082
|
+
const m = curPath.match(/\.([^.]+)$/);
|
|
3083
|
+
showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: curPath, name: curName, isDir: curIsDir, ext: m ? '.' + m[1].toLowerCase() : '' });
|
|
3061
3084
|
return;
|
|
3062
3085
|
}
|
|
3063
3086
|
if (selectMode) {
|
|
3064
|
-
toggleFileSelection(
|
|
3087
|
+
toggleFileSelection(curPath, div);
|
|
3065
3088
|
return;
|
|
3066
3089
|
}
|
|
3067
|
-
if (
|
|
3068
|
-
else if (isImageFile(
|
|
3069
|
-
else openFileEditor(
|
|
3090
|
+
if (curIsDir) loadFiles(curPath);
|
|
3091
|
+
else if (isImageFile(curPath)) openImageViewer(curPath);
|
|
3092
|
+
else openFileEditor(curPath);
|
|
3070
3093
|
});
|
|
3071
3094
|
const ell = div.querySelector('.file-ellipsis');
|
|
3072
3095
|
if (ell) {
|
|
@@ -3074,9 +3097,12 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
|
|
|
3074
3097
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
3075
3098
|
e.preventDefault();
|
|
3076
3099
|
e.stopPropagation();
|
|
3100
|
+
const curPath = div.dataset.path;
|
|
3101
|
+
const curIsDir = div.dataset.isDir === 'true';
|
|
3102
|
+
const curName = (div.querySelector('.file-name')?.textContent || curPath.split(/[\\/]/).pop() || curPath);
|
|
3077
3103
|
const rect = ell.getBoundingClientRect();
|
|
3078
|
-
const m =
|
|
3079
|
-
showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path:
|
|
3104
|
+
const m = curPath.match(/\.([^.]+)$/);
|
|
3105
|
+
showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: curPath, name: curName, isDir: curIsDir, ext: m ? '.' + m[1].toLowerCase() : '' });
|
|
3080
3106
|
}
|
|
3081
3107
|
});
|
|
3082
3108
|
}
|
|
@@ -3120,10 +3146,33 @@ function setupFileListKeyboard() {
|
|
|
3120
3146
|
});
|
|
3121
3147
|
}
|
|
3122
3148
|
|
|
3149
|
+
function setupFileListContextMenu() {
|
|
3150
|
+
const list = document.getElementById('file-list');
|
|
3151
|
+
if (!list || list.dataset._ctxSetup) return;
|
|
3152
|
+
list.dataset._ctxSetup = '1';
|
|
3153
|
+
list.addEventListener('contextmenu', e => {
|
|
3154
|
+
const item = e.target.closest('.file-item');
|
|
3155
|
+
if (!item || !item.dataset.path) return;
|
|
3156
|
+
const curPath = item.dataset.path;
|
|
3157
|
+
const curIsDir = item.dataset.isDir === 'true';
|
|
3158
|
+
const curName = (item.querySelector('.file-name')?.textContent || curPath.split(/[\\/]/).pop() || curPath);
|
|
3159
|
+
const m = curPath.match(/\.([^.]+)$/);
|
|
3160
|
+
const ext = m ? '.' + m[1].toLowerCase() : '';
|
|
3161
|
+
showCtxMenu(e, { path: curPath, name: curName, isDir: curIsDir, ext });
|
|
3162
|
+
});
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3123
3165
|
function refreshFiles() {
|
|
3124
3166
|
if (currentPath) loadFiles(currentPath);
|
|
3125
3167
|
}
|
|
3126
|
-
function navigateTo(p) {
|
|
3168
|
+
function navigateTo(p) {
|
|
3169
|
+
if (!p || !String(p).trim()) return loadFiles(homeDir);
|
|
3170
|
+
let target = String(p).trim();
|
|
3171
|
+
// If relative path, resolve against currentPath
|
|
3172
|
+
const isAbsolute = target.startsWith('/') || /^[A-Za-z]:[\\/]/.test(target) || target.startsWith('\\\\');
|
|
3173
|
+
if (!isAbsolute) target = joinPath(currentPath || homeDir, target);
|
|
3174
|
+
loadFiles(target);
|
|
3175
|
+
}
|
|
3127
3176
|
function navigateUp() {
|
|
3128
3177
|
if (currentParent && currentParent !== currentPath) {
|
|
3129
3178
|
loadFiles(currentParent);
|
|
@@ -3159,7 +3208,10 @@ function startFileWatcher() {
|
|
|
3159
3208
|
if (fileWatchTimer) return;
|
|
3160
3209
|
const watch = () => {
|
|
3161
3210
|
const sb = document.getElementById('sidebar');
|
|
3211
|
+
const sp = document.getElementById('settings-panel');
|
|
3162
3212
|
if (!sb || sb.classList.contains('hidden') || document.hidden) return;
|
|
3213
|
+
if (sp && sp.classList.contains('open')) return;
|
|
3214
|
+
if (document.querySelector('.overlay.open')) return;
|
|
3163
3215
|
if (currentPath) loadFiles(currentPath);
|
|
3164
3216
|
};
|
|
3165
3217
|
const onVisibility = () => {
|
|
@@ -3664,9 +3716,8 @@ async function confirmRename() {
|
|
|
3664
3716
|
body: JSON.stringify({ oldPath: renamePath, newName })
|
|
3665
3717
|
});
|
|
3666
3718
|
setBtnBusy(document.getElementById('rename-ok-btn'), false);
|
|
3667
|
-
if (r.success) { toast('Renamed', 'success'); refreshFiles(); }
|
|
3719
|
+
if (r.success) { toast('Renamed', 'success'); refreshFiles(); closeOverlay('rename-overlay'); }
|
|
3668
3720
|
else showFieldError('rename-error', r.error || 'Rename failed');
|
|
3669
|
-
closeOverlay('rename-overlay');
|
|
3670
3721
|
}
|
|
3671
3722
|
|
|
3672
3723
|
async function deleteFile(file) {
|
|
@@ -4218,7 +4269,6 @@ async function closeEditor() {
|
|
|
4218
4269
|
ev.classList.remove('open');
|
|
4219
4270
|
ev.classList.remove('fullscreen');
|
|
4220
4271
|
document.removeEventListener('keydown', _fsEscHandler);
|
|
4221
|
-
try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
|
|
4222
4272
|
const fsBtn = document.getElementById('editor-fullscreen-toggle');
|
|
4223
4273
|
if (fsBtn) { fsBtn.setAttribute('aria-label','Fullscreen'); fsBtn.title='Fullscreen (F11)'; fsBtn.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>'; }
|
|
4224
4274
|
document.getElementById('content').classList.remove('editor-open');
|
|
@@ -4629,16 +4679,20 @@ function openSettings() {
|
|
|
4629
4679
|
const isOpen = panel.classList.contains('open');
|
|
4630
4680
|
// Don't inert #main because panel is inside #main — inert would disable panel itself. Inert content only (sidebar is sibling).
|
|
4631
4681
|
const content = document.getElementById('content');
|
|
4632
|
-
panel.classList.toggle('open');
|
|
4633
4682
|
if (!isOpen) {
|
|
4683
|
+
panel.classList.add('open');
|
|
4634
4684
|
panel.setAttribute('aria-modal', 'true');
|
|
4635
4685
|
panel.setAttribute('role', 'dialog');
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4686
|
+
// Defer inert + focus to next frame so panel transform can composite on GPU without reflowing sidebar/file-list
|
|
4687
|
+
requestAnimationFrame(() => {
|
|
4688
|
+
if (content) content.setAttribute('inert', '');
|
|
4689
|
+
installFocusTrap(panel);
|
|
4690
|
+
const firstFocusable = panel.querySelector('select, button, input');
|
|
4691
|
+
if (firstFocusable) firstFocusable.focus({ preventScroll: true });
|
|
4692
|
+
});
|
|
4640
4693
|
setTimeout(() => document.addEventListener('click', closeSettingsOnClickOutside, true), 50);
|
|
4641
4694
|
} else {
|
|
4695
|
+
panel.classList.remove('open');
|
|
4642
4696
|
panel.removeAttribute('aria-modal');
|
|
4643
4697
|
if (content) content.removeAttribute('inert');
|
|
4644
4698
|
removeFocusTrap();
|
|
@@ -5118,13 +5172,13 @@ function toggleEditorFullscreen() {
|
|
|
5118
5172
|
}
|
|
5119
5173
|
if (isFS) {
|
|
5120
5174
|
document.addEventListener('keydown', _fsEscHandler);
|
|
5121
|
-
try { view.requestFullscreen?.(); } catch {}
|
|
5122
5175
|
} else {
|
|
5123
5176
|
document.removeEventListener('keydown', _fsEscHandler);
|
|
5124
|
-
try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
|
|
5125
5177
|
requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); if(editor) editor.refresh(); });
|
|
5126
5178
|
}
|
|
5127
5179
|
if (editor) setTimeout(()=>editor.refresh(), 80);
|
|
5180
|
+
// Keep sidebar visible — editor fullscreen now only covers terminal area (absolute inside #editor-split-area)
|
|
5181
|
+
requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); });
|
|
5128
5182
|
}
|
|
5129
5183
|
function _fsEscHandler(e) {
|
|
5130
5184
|
if (e.key === 'Escape' || e.key === 'F11') {
|
|
@@ -5133,49 +5187,8 @@ function _fsEscHandler(e) {
|
|
|
5133
5187
|
}
|
|
5134
5188
|
}
|
|
5135
5189
|
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
const term = document.getElementById('terminals');
|
|
5139
|
-
const sidebar = document.getElementById('sidebar');
|
|
5140
|
-
if (!content || !term) return;
|
|
5141
|
-
const isFS = content.classList.toggle('terminal-fullscreen');
|
|
5142
|
-
term.classList.toggle('fullscreen', isFS);
|
|
5143
|
-
const btn = document.getElementById('term-fullscreen-btn');
|
|
5144
|
-
if (btn) {
|
|
5145
|
-
btn.classList.toggle('active', isFS);
|
|
5146
|
-
btn.setAttribute('aria-label', isFS ? 'Exit terminal fullscreen' : 'Terminal fullscreen');
|
|
5147
|
-
btn.title = isFS ? 'Exit terminal fullscreen (Esc/Shift+F11)' : 'Terminal fullscreen (Shift+F11)';
|
|
5148
|
-
btn.innerHTML = isFS
|
|
5149
|
-
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 4"/><polyline points="20 10 14 10 14 20"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="10" y1="14" x2="3" y2="21"/></svg>'
|
|
5150
|
-
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
|
5151
|
-
}
|
|
5152
|
-
if (isFS) {
|
|
5153
|
-
document.addEventListener('keydown', _termFsEscHandler);
|
|
5154
|
-
try { content.requestFullscreen?.(); } catch {}
|
|
5155
|
-
} else {
|
|
5156
|
-
document.removeEventListener('keydown', _termFsEscHandler);
|
|
5157
|
-
try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
|
|
5158
|
-
// Ensure sidebar is visible when exiting fullscreen
|
|
5159
|
-
if (sidebar) {
|
|
5160
|
-
sidebar.style.display = '';
|
|
5161
|
-
sidebar.classList.remove('hidden');
|
|
5162
|
-
if (window.innerWidth <= 768) {
|
|
5163
|
-
sidebar.classList.add('mobile-open');
|
|
5164
|
-
}
|
|
5165
|
-
}
|
|
5166
|
-
}
|
|
5167
|
-
requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); const at=getActiveTab(); try{at?.term?.focus();}catch{} });
|
|
5168
|
-
}
|
|
5169
|
-
function _termFsEscHandler(e) {
|
|
5170
|
-
const isTermFS = document.getElementById('content')?.classList.contains('terminal-fullscreen');
|
|
5171
|
-
if (!isTermFS) return;
|
|
5172
|
-
if (e.key === 'Escape' || (e.key === 'F11' && e.shiftKey)) { e.preventDefault(); toggleTerminalFullscreen(); }
|
|
5173
|
-
else if (e.key === 'F11' && !e.shiftKey) {
|
|
5174
|
-
// Let editor handle F11 when editor fullscreen, but prevent browser default
|
|
5175
|
-
const ev = document.getElementById('editor-view');
|
|
5176
|
-
if (!ev || !ev.classList.contains('fullscreen')) e.preventDefault();
|
|
5177
|
-
}
|
|
5178
|
-
}
|
|
5190
|
+
// Terminal fullscreen removed — per request, terminal fullscreen should not cover sidebar
|
|
5191
|
+
// toggleTerminalFullscreen and _termFsEscHandler intentionally removed
|
|
5179
5192
|
const hasWakeLock = 'wakeLock' in navigator;
|
|
5180
5193
|
|
|
5181
5194
|
async function requestWakeLock() {
|
|
@@ -5270,8 +5283,7 @@ function setupKeyboardShortcuts() {
|
|
|
5270
5283
|
if (ctrl && e.shiftKey && e.key === 'ArrowRight') { e.preventDefault(); cycleTab(1); }
|
|
5271
5284
|
if (ctrl && e.shiftKey && e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); }
|
|
5272
5285
|
if (ctrl && e.shiftKey && e.key === 'r') { e.preventDefault(); refreshPreview(); }
|
|
5273
|
-
if (e.key === 'F11' &&
|
|
5274
|
-
else if (e.key === 'F11' && document.getElementById('editor-view').classList.contains('open')) { e.preventDefault(); toggleEditorFullscreen(); }
|
|
5286
|
+
if (e.key === 'F11' && document.getElementById('editor-view').classList.contains('open')) { e.preventDefault(); toggleEditorFullscreen(); }
|
|
5275
5287
|
if (e.key === 'Escape') {
|
|
5276
5288
|
const termMenu = document.getElementById('term-ctx-menu');
|
|
5277
5289
|
if (termMenu && termMenu.style.display !== 'none') {
|
|
@@ -5661,8 +5673,23 @@ function setupDragDrop() {
|
|
|
5661
5673
|
});
|
|
5662
5674
|
document.addEventListener('dragleave', e => {
|
|
5663
5675
|
if (!isFileDrag(e)) return;
|
|
5664
|
-
|
|
5665
|
-
|
|
5676
|
+
// Only hide when leaving the viewport (relatedTarget null) or overlay itself
|
|
5677
|
+
// Prevents nested dragenter/leave from child elements desyncing counter
|
|
5678
|
+
if (!e.relatedTarget || e.relatedTarget === document.documentElement || e.target === overlay) {
|
|
5679
|
+
dragCnt = 0;
|
|
5680
|
+
overlay.classList.remove('active');
|
|
5681
|
+
} else {
|
|
5682
|
+
dragCnt--;
|
|
5683
|
+
if (dragCnt <= 0) { dragCnt = 0; overlay.classList.remove('active'); }
|
|
5684
|
+
}
|
|
5685
|
+
});
|
|
5686
|
+
document.addEventListener('dragend', () => {
|
|
5687
|
+
dragCnt = 0;
|
|
5688
|
+
overlay.classList.remove('active');
|
|
5689
|
+
});
|
|
5690
|
+
window.addEventListener('blur', () => {
|
|
5691
|
+
dragCnt = 0;
|
|
5692
|
+
overlay.classList.remove('active');
|
|
5666
5693
|
});
|
|
5667
5694
|
document.addEventListener('dragover', e => {
|
|
5668
5695
|
if (!isFileDrag(e)) return;
|
package/server.js
CHANGED
|
@@ -132,7 +132,7 @@ const SHELL = (os.platform() === 'win32' && !process.env.WEBTUN_SHELL)
|
|
|
132
132
|
? 'powershell.exe'
|
|
133
133
|
: (process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh')));
|
|
134
134
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
135
|
-
const ALLOW_FULL_FS = process.env.ALLOW_FULL_FS
|
|
135
|
+
const ALLOW_FULL_FS = process.env.ALLOW_FULL_FS !== 'false';
|
|
136
136
|
const WORKSPACE_ROOT = (() => {
|
|
137
137
|
let ws = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WORKSPACE_ROOT) : os.homedir();
|
|
138
138
|
if (!ws || ws.trim() === '') ws = os.homedir() || process.cwd();
|
|
@@ -613,7 +613,7 @@ app.get('/api/files', checkPin, async (req, res) => {
|
|
|
613
613
|
const full = path.join(dir, e.name);
|
|
614
614
|
const st = await safeStat(full);
|
|
615
615
|
files.push({
|
|
616
|
-
name: e.name, path: full, isDir: e.isDirectory(),
|
|
616
|
+
name: e.name, path: full, isDir: st ? st.isDirectory() : e.isDirectory(),
|
|
617
617
|
isSymlink: e.isSymbolicLink(), size: st ? st.size : 0,
|
|
618
618
|
modified: st ? st.mtime : null, ext: path.extname(e.name).toLowerCase()
|
|
619
619
|
});
|
|
@@ -633,11 +633,14 @@ app.get('/api/files', checkPin, async (req, res) => {
|
|
|
633
633
|
entries.map(async e => {
|
|
634
634
|
const full = path.join(dir, e.name);
|
|
635
635
|
const st = await safeStat(full);
|
|
636
|
+
const isSymlink = e.isSymbolicLink();
|
|
637
|
+
// Use stat result for isDir so symlink→dir is navigable; Dirent.isDirectory() is false for symlink
|
|
638
|
+
const isDir = st ? st.isDirectory() : e.isDirectory();
|
|
636
639
|
return {
|
|
637
640
|
name: e.name,
|
|
638
641
|
path: full,
|
|
639
|
-
isDir
|
|
640
|
-
isSymlink
|
|
642
|
+
isDir,
|
|
643
|
+
isSymlink,
|
|
641
644
|
size: st ? st.size : 0,
|
|
642
645
|
modified: st ? st.mtime : null,
|
|
643
646
|
ext: path.extname(e.name).toLowerCase()
|
|
@@ -648,8 +651,10 @@ app.get('/api/files', checkPin, async (req, res) => {
|
|
|
648
651
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
649
652
|
return a.name.localeCompare(b.name);
|
|
650
653
|
});
|
|
651
|
-
|
|
652
|
-
|
|
654
|
+
let parent = path.dirname(dir);
|
|
655
|
+
if (parent === dir) parent = null;
|
|
656
|
+
else if (!ALLOW_FULL_FS && !pathContained(WORKSPACE_ROOT, parent)) parent = null;
|
|
657
|
+
res.json({ path: dir, parent, files });
|
|
653
658
|
} catch (e) {
|
|
654
659
|
const status = e.status || 500;
|
|
655
660
|
res.status(status).json({ error: e.message });
|