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 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 + `horizontal/vertical` split toggle, markdown/HTML preview
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) | `false` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
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: fixed; inset: 0; z-index: var(--z-overlay); width: 100vw; height: 100vh; max-height: 100vh; background: var(--bg); border: none; }
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: 100vh; }
274
- #terminals.fullscreen { position: fixed; inset: 0; z-index: var(--z-overlay); width: 100vw; height: 100vh; max-height: 100vh; background: var(--bg); display: flex; flex-direction: column; }
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; content-visibility: auto; contain-intrinsic-size: 600px; }
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
- if (skipHistoryPush) {
2901
- // back/forward don't push and don't clear forward
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
- if (dir !== homeDir) loadFiles(homeDir);
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(fullPath, div);
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 = fullPath.match(/\.([^.]+)$/);
3060
- showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: fullPath, name, isDir, ext: m ? '.' + m[1].toLowerCase() : '' });
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(fullPath, div);
3087
+ toggleFileSelection(curPath, div);
3065
3088
  return;
3066
3089
  }
3067
- if (isDir) loadFiles(fullPath);
3068
- else if (isImageFile(fullPath)) openImageViewer(fullPath);
3069
- else openFileEditor(fullPath);
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 = fullPath.match(/\.([^.]+)$/);
3079
- showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: fullPath, name, isDir, ext: m ? '.' + m[1].toLowerCase() : '' });
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) { loadFiles(p || homeDir); }
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
- if (content) content.setAttribute('inert', '');
4637
- installFocusTrap(panel);
4638
- panel.querySelector('select, button, input')?.focus();
4639
- // Defer outside-click handler to next tick so opening click doesn't immediately close
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
- function toggleTerminalFullscreen() {
5137
- const content = document.getElementById('content');
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' && e.shiftKey) { e.preventDefault(); toggleTerminalFullscreen(); }
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
- dragCnt--;
5665
- if (dragCnt <= 0) { dragCnt = 0; overlay.classList.remove('active'); }
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 === 'true';
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: e.isDirectory(),
640
- isSymlink: e.isSymbolicLink(),
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
- const parent = path.dirname(dir);
652
- res.json({ path: dir, parent: parent !== dir ? parent : null, files });
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 });