webtun 1.5.3 → 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/public/index.html CHANGED
@@ -147,10 +147,10 @@
147
147
  RESET & BASE
148
148
  ═══════════════════════════════════════════════════════ */
149
149
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
150
- html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--fg); font-family: var(--font-ui); font-size: 13px; padding-bottom: env(safe-area-inset-bottom); touch-action: manipulation; -webkit-overflow-scrolling: touch; -webkit-tap-highlight-color: transparent; }
150
+ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--fg); font-family: var(--font-ui); font-size: 13px; padding-bottom: env(safe-area-inset-bottom); touch-action: pan-y manipulation; -webkit-overflow-scrolling: touch; -webkit-tap-highlight-color: transparent; overscroll-behavior: contain; }
151
151
 
152
152
  button { background: none; border: none; cursor: pointer; color: inherit; font-family: inherit; }
153
- input, select, textarea { font-family: var(--font-ui); background: var(--bg3); border: 1px solid var(--border); color: var(--fg); border-radius: var(--radius); padding: 6px 10px; outline: none; font-size: 16px; }
153
+ input, select, textarea { font-family: var(--font-ui); background: var(--bg3); border: 1px solid var(--border); color: var(--fg); border-radius: var(--radius); padding: 6px 10px; outline: none; font-size: 13px; }
154
154
  @media (pointer: coarse) { input, select, textarea { font-size: 16px; } }
155
155
  input:focus, select:focus, textarea:focus { border-color: var(--accent); }
156
156
  a { color: var(--accent); text-decoration: none; }
@@ -165,7 +165,7 @@ a { color: var(--accent); text-decoration: none; }
165
165
  @media (pointer: coarse) {
166
166
  .tab { height: 38px; }
167
167
  .tab .tab-close { width: 44px; height: 44px; }
168
- #new-tab-btn { width: 36px; height: 36px; }
168
+ #new-tab-btn { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
169
169
  .icon-btn { width: 40px; height: 40px; }
170
170
  .file-action-btn { width: 36px; height: 36px; }
171
171
  .search-btn { width: 32px; height: 32px; }
@@ -196,20 +196,22 @@ svg { display: block; }
196
196
  LAYOUT
197
197
  ═══════════════════════════════════════════════════════ */
198
198
  #app { display: flex; flex-direction: column; height: 100dvh; }
199
- #header { display: flex; align-items: center; height: var(--header-h); background: var(--bg2); border-bottom: 1px solid var(--border); padding: 0 8px; padding-top: env(safe-area-inset-top, 0px); gap: 6px; flex-shrink: 0; z-index: var(--z-header); position: relative; }
199
+ #header { display: flex; align-items: center; height: var(--header-h); background: var(--bg2); border-bottom: 1px solid var(--border); padding: 0 8px; padding-top: max(0px, env(safe-area-inset-top, 0px)); gap: 6px; flex-shrink: 0; z-index: var(--z-header); position: relative; box-sizing: border-box; }
200
200
 
201
201
  /* Upload progress bar in header */
202
202
  #upload-progress { position: absolute; bottom: 0; left: 0; right: 0; height: 0; background: var(--bg3); z-index: calc(var(--z-header) + 1); overflow: visible; transition: height var(--transition-normal) ease; }
203
- #upload-progress.active { height: 3px; }
203
+ #upload-progress.active { height: 6px; }
204
204
  #upload-progress-bar { height: 100%; width: 0%; background: var(--accent); transition: width var(--transition-slow) ease; border-radius: 0 2px 2px 0; }
205
205
  #upload-progress.success #upload-progress-bar { background: var(--green); }
206
206
  #upload-progress.error #upload-progress-bar { background: var(--red); }
207
- #upload-progress-text { position: absolute; right: 8px; top: -14px; font-size: 10px; color: var(--fg2); white-space: nowrap; opacity: 0; transition: var(--transition-normal); pointer-events: none; line-height: 1; }
207
+ #upload-progress-text { position: absolute; right: 8px; top: -18px; font-size: 12px; font-weight: 600; color: var(--fg); background: var(--bg3); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border); white-space: nowrap; opacity: 0; transition: var(--transition-normal); pointer-events: none; line-height: 1; box-shadow: 0 2px 8px var(--shadow-color); }
208
208
  #upload-progress.active #upload-progress-text { opacity: 1; }
209
- #main { display: flex; flex: 1; overflow: hidden; min-height: 0; position: relative; }
210
- #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); }
211
- #sidebar.dragging { transition: none; }
212
- #sidebar.hidden { width: 0 !important; }
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
+ #upload-progress.active #upload-cancel-btn { display: block; pointer-events: auto; }
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; }
213
215
  #content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; min-height: 0; position: relative; }
214
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; }
215
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; }
@@ -234,14 +236,16 @@ svg { display: block; }
234
236
  .xterm-viewport { border-radius: 0; }
235
237
  .term-bell-flash { animation: bellFlash 0.15s ease-out; }
236
238
  @keyframes bellFlash { 0% { filter: brightness(1.8); } 100% { filter: brightness(1); } }
237
- #terminals.tiles-mode { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-auto-rows: 1fr; gap: 4px; padding: 4px; position: relative; overflow: auto; min-height: 0; overscroll-behavior: none; }
239
+ #terminals.tiles-mode { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); grid-auto-rows: 1fr; gap: 4px; padding: 4px; position: relative; overflow: auto; min-height: 0; overscroll-behavior: none; }
238
240
  #terminals.tiles-mode .term-wrapper { position: relative; inset: unset; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; min-height: 150px; }
239
241
  #terminals.tiles-mode .term-wrapper.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
240
242
  #terminals.tiles-mode .term-wrapper.active .term-tile-header { background: var(--bg); color: var(--accent); }
241
243
  .term-tile-header { display: flex; align-items: center; height: 24px; padding: 0 8px; background: var(--bg3); border-bottom: 1px solid var(--border); font-size: 11px; color: var(--fg2); flex-shrink: 0; gap: 6px; cursor: pointer; user-select: none; }
244
+ .term-tile-header:focus-visible, .term-tile-header .tth-name:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
242
245
  .term-tile-header .tth-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
243
246
  .term-tile-header .tth-close { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); opacity: 0.4; font-size: 12px; line-height: 1; }
244
247
  .term-tile-header .tth-close:hover { opacity: 1; background: var(--border); }
248
+ .term-tile-header .tth-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
245
249
  #terminals.tiles-mode .xterm { flex: 1; }
246
250
  #mobile-keys { display: none; height: var(--mobilekey-h); background: var(--bg3); border-top: 1px solid var(--border); flex-shrink: 0; overflow-x: auto; overflow-y: hidden; white-space: nowrap; padding: 4px 6px; gap: 4px; padding-bottom: calc(4px + env(safe-area-inset-bottom)); min-height: calc(var(--mobilekey-h) + env(safe-area-inset-bottom)); }
247
251
  /* ═══════════════════════════════════════════════════════
@@ -263,6 +267,11 @@ svg { display: block; }
263
267
  }
264
268
  #editor-view { display: none; flex-direction: column; background: var(--bg); overflow: hidden; }
265
269
  #editor-view.open { display: flex; }
270
+ #editor-view.fullscreen { position: absolute; inset: 0; z-index: 30; width: 100%; height: 100%; background: var(--bg); border: none; }
271
+ #editor-view.fullscreen #editor-header { border-bottom: 1px solid var(--border); background: var(--bg2); }
272
+ #editor-view.fullscreen #editor-area { flex: 1; min-height: 0; }
273
+ #editor-view.fullscreen #editor-area .CodeMirror { height: 100%; }
274
+ #editor-split-area { position: relative; }
266
275
 
267
276
  /* Desktop: split layout */
268
277
  @media (min-width: 769px) {
@@ -335,8 +344,11 @@ svg { display: block; }
335
344
  background: var(--bg2); border-left: 1px solid var(--border);
336
345
  display: flex; flex-direction: column;
337
346
  transform: translateX(100%);
338
- transition: var(--transition-normal);
347
+ transition: transform var(--transition-normal) ease;
348
+ will-change: transform;
349
+ backface-visibility: hidden;
339
350
  z-index: var(--z-drawer);
351
+ contain: layout style;
340
352
  }
341
353
  #settings-panel.open {
342
354
  transform: translateX(0);
@@ -361,7 +373,7 @@ svg { display: block; }
361
373
  ═══════════════════════════════════════════════════════ */
362
374
  #logo { font-weight: 700; color: var(--accent); font-size: 15px; letter-spacing: 1px; user-select: none; padding: 0 4px; }
363
375
  .header-spacer { flex: 1; }
364
- .icon-btn { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius); color: var(--fg2); transition: background var(--transition-fast), color var(--transition-fast); }
376
+ .icon-btn { width: 36px; height: 36px; min-width: 36px; min-height: 36px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius); color: var(--fg2); transition: background var(--transition-fast), color var(--transition-fast); }
365
377
  .icon-btn:hover { background: var(--bg4); color: var(--fg); }
366
378
  .icon-btn.active { color: var(--accent); }
367
379
 
@@ -384,9 +396,9 @@ svg { display: block; }
384
396
  .tunnel-inner { display: flex; align-items: center; gap: 5px; min-width: 0; }
385
397
  .tunnel-url { font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
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; }
387
- .tunnel-btn { width: 22px; height: 22px; flex-shrink: 0; }
399
+ .tunnel-btn { width: 28px; height: 28px; min-width: 28px; min-height: 28px; flex-shrink: 0; }
388
400
  .tunnel-btn.stop { color: var(--red); }
389
- #conn-status { transition: background var(--transition-slow); }
401
+ #conn-status { transition: background var(--transition-slow); margin-left: 6px; }
390
402
  .reconnect-spinner { display: inline-flex; vertical-align: -1px; margin-right: 4px; }
391
403
  .reconnect-spinner svg { animation: reconnectSpin 1s linear infinite; }
392
404
  @keyframes reconnectSpin { to { transform: rotate(360deg); } }
@@ -404,15 +416,17 @@ svg { display: block; }
404
416
  .tab-close { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-sm); opacity: 0.4; flex-shrink: 0; transition: opacity var(--transition-fast), background var(--transition-fast); }
405
417
  .tab:hover .tab-close, .tab.active .tab-close { opacity: 0.6; }
406
418
  .tab-close:hover { opacity: 1; background: var(--border); }
407
- #new-tab-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius); color: var(--fg2); flex-shrink: 0; }
419
+ #new-tab-btn { width: 28px; height: 28px; min-width: 28px; min-height: 28px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius); color: var(--fg2); flex-shrink: 0; position: sticky; right: 0; background: var(--bg2); border: 1px solid var(--border); z-index: 1; margin-left: 4px; }
408
420
  #new-tab-btn:hover { background: var(--bg4); color: var(--fg); }
409
421
 
410
422
  /* ═══════════════════════════════════════════════════════
411
423
  SIDEBAR / FILE EXPLORER
412
424
  ═══════════════════════════════════════════════════════ */
413
- #sidebar-header { display: flex; align-items: center; padding: 6px 8px; gap: 4px; border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; min-height: 40px; height: auto; }
414
- #sidebar-header input { flex: 1 1 60px; min-width: 40px; height: 26px; font-size: 12px; }
415
- #file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; position: relative; }
425
+ #sidebar-header, #file-breadcrumb { display: flex; align-items: center; gap: 3px; padding: 3px 6px; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 30px; background: var(--bg2); }
426
+ #sidebar-header { flex-wrap: wrap; height: auto; }
427
+ #file-breadcrumb { white-space: nowrap; overflow-x: auto; line-height: 1; scrollbar-width: none; font-size: 11px; color: var(--fg2); }
428
+ #sidebar-header input { flex: 1 1 60px; min-width: 40px; height: 24px; font-size: 12px; border-radius: var(--radius-sm); }
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; }
416
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; }
417
431
  #ptr-indicator .ptr-arrow { display: inline-flex; }
418
432
  #ptr-indicator .ptr-arrow svg { transition: transform 0.2s ease; }
@@ -420,28 +434,28 @@ svg { display: block; }
420
434
  #ptr-indicator.release .ptr-text { color: var(--accent); }
421
435
  /* Loading state: dim content + indeterminate progress bar */
422
436
  #file-list-wrap.loading #file-list { opacity: 0.45; transition: opacity var(--transition-fast); }
423
- #file-list-wrap.loading::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--bg3); z-index: 5; }
424
- #file-list-wrap.loading::after { content: ''; position: absolute; top: 0; left: -30%; width: 30%; height: 2px; background: var(--accent); border-radius: 0 2px 2px 0; z-index: 6; animation: fileListProgress 0.9s ease-in-out infinite; }
437
+ #file-list-wrap.loading::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--bg3); z-index: 5; }
438
+ #file-list-wrap.loading::after { content: ''; position: absolute; top: 0; left: -30%; width: 30%; height: 3px; background: var(--accent); border-radius: 0 2px 2px 0; z-index: 6; animation: fileListProgress 0.9s ease-in-out infinite; box-shadow: 0 1px 4px var(--shadow-color); }
425
439
  @keyframes fileListProgress { from { left: -30%; } to { left: 100%; } }
426
- .file-list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 20px; color: var(--fg2); font-size: 13px; text-align: center; gap: 8px; }
440
+ .file-list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px 12px; color: var(--fg2); font-size: 13px; text-align: center; gap: 6px; }
427
441
  .file-list-empty svg { opacity: 0.4; }
428
- .empty-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
442
+ .empty-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
429
443
  .empty-actions .btn { font-size: 12px; padding: 4px 12px; }
430
- #file-breadcrumb { padding: 8px 10px; font-size: 12px; color: var(--fg2); border-bottom: 1px solid var(--border); white-space: nowrap; overflow-x: auto; line-height: 1.8; flex-shrink: 0; scrollbar-width: none; }
444
+ #file-breadcrumb { display: flex; align-items: center; gap: 0; padding: 2px 4px; font-size: 11px; color: var(--fg2); border-bottom: 1px solid var(--border); white-space: nowrap; overflow-x: auto; line-height: 1; flex-shrink: 0; scrollbar-width: none; }
431
445
  #file-breadcrumb::-webkit-scrollbar { display: none; }
432
- #file-breadcrumb a { transition: background var(--transition-fast); border-radius: var(--radius-sm); padding: 4px 6px !important; display: inline-block; }
446
+ #file-breadcrumb a { transition: background var(--transition-fast); border-radius: var(--radius-sm); padding: 2px 6px !important; display: inline-flex !important; align-items: center; height: 24px; line-height: 1; }
433
447
  #file-breadcrumb a:hover { background: var(--bg3); }
434
448
  @media (pointer: coarse) {
435
449
  #file-breadcrumb { padding: 10px 12px; font-size: 13px; }
436
450
  #file-breadcrumb a { padding: 6px 8px !important; }
437
451
  }
438
- .file-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; border-bottom: 1px solid transparent; font-size: 12px; position: relative; user-select: none; }
452
+ .file-item { display: flex; align-items: center; gap: 2px; padding: 1px 4px; cursor: pointer; border-bottom: 1px solid transparent; font-size: 12px; position: relative; user-select: none; white-space: nowrap; flex-wrap: nowrap; }
439
453
  .file-item:hover { background: var(--bg3); }
440
454
  .file-item.selected { background: var(--bg4); }
441
455
  .file-item.focused { background: var(--bg3); outline: 1px solid var(--accent); outline-offset: -1px; }
442
- .file-item .file-ellipsis { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-sm); color: var(--fg2); opacity: 0.45; flex-shrink: 0; }
456
+ .file-item .file-ellipsis { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; min-width: 32px; min-height: 32px; border-radius: var(--radius-sm); color: var(--fg2); opacity: 0.45; flex-shrink: 0; }
443
457
  .file-item:hover .file-ellipsis { opacity: 1; }
444
- .file-icon { font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
458
+ .file-icon { font-size: 14px; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
445
459
  .file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: text; -webkit-user-select: text; }
446
460
  .file-size { font-size: 10px; color: var(--fg2); flex-shrink: 0; user-select: text; -webkit-user-select: text; }
447
461
  .file-dir { color: var(--accent); }
@@ -458,14 +472,14 @@ svg { display: block; }
458
472
  #sidebar[data-select-mode="true"] .file-select-check { display: flex; }
459
473
  #sidebar[data-select-mode="true"] .file-item { padding-left: 6px; }
460
474
  .file-action-btn:hover { background: var(--border); color: var(--fg); }
461
- #sidebar-footer { padding: 8px 10px; border-top: 1px solid var(--border); display: flex; gap: 4px; flex-wrap: wrap; flex-shrink: 0; }
475
+ #sidebar-footer { padding: 4px 6px; border-top: 1px solid var(--border); display: flex; gap: 2px; flex-wrap: wrap; flex-shrink: 0; }
462
476
  .sidebar-action-btn { flex: 1; min-width: 60px; height: 28px; background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); color: var(--fg2); font-size: 11px; display: flex; align-items: center; justify-content: center; gap: 4px; transition: all var(--transition-fast); }
463
477
  .sidebar-action-btn:hover { background: var(--bg4); color: var(--fg); border-color: var(--accent); }
464
478
 
465
479
  /* Sidebar resize handle */
466
480
  .sidebar-resize-handle {
467
- position: absolute; right: 0; top: 0; bottom: 0;
468
- width: 5px; cursor: col-resize; z-index: var(--z-header);
481
+ position: absolute; right: -4px; top: 0; bottom: 0;
482
+ width: 12px; cursor: col-resize; z-index: var(--z-header);
469
483
  background: transparent; transition: background var(--transition-fast); flex-shrink: 0;
470
484
  }
471
485
  .sidebar-resize-handle:hover,
@@ -521,11 +535,12 @@ svg { display: block; }
521
535
  ═══════════════════════════════════════════════════════ */
522
536
  #search-bar { position: absolute; top: 8px; right: 12px; z-index: var(--z-popover); background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 8px; display: none; align-items: center; gap: 6px; box-shadow: 0 4px 16px var(--shadow-color); }
523
537
  #search-bar.open { display: flex; }
538
+ #terminals.tiles-mode #search-bar { position: fixed; top: calc(var(--header-h) + var(--tab-h) + 8px); right: 12px; left: auto; z-index: var(--z-popover); }
524
539
  #search-input { width: 200px; height: 26px; }
525
540
  @media (max-width: 480px) { #search-bar { left: 8px; right: 8px; } #search-input { flex: 1; width: auto; min-width: 0; } }
526
541
  .search-btn { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border-radius: 4px; color: var(--fg2); }
527
542
  .search-btn:hover { background: var(--bg4); color: var(--fg); }
528
- .search-btn#search-case-btn { font-size: 11px; font-weight: bold; width: 22px; height: 22px; line-height: 22px; }
543
+ .search-btn#search-case-btn { font-size: 11px; font-weight: bold; width: 28px; height: 28px; min-width: 28px; min-height: 28px; line-height: 28px; }
529
544
  .search-btn#search-case-btn.active { background: var(--accent); color: var(--bg); }
530
545
  #search-results { font-size: 11px; color: var(--fg2); white-space: nowrap; }
531
546
 
@@ -565,13 +580,15 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
565
580
  .btn-danger:hover { filter: brightness(1.06); }
566
581
  .btn:not(.btn-primary):not(.btn-ghost):not(.btn-danger):hover { background: var(--bg4); }
567
582
  .btn:active { filter: brightness(0.95); }
568
- .btn.loading { opacity: 0.65; pointer-events: none; position: relative; color: transparent; }
583
+ .btn.loading { opacity: 0.85; pointer-events: none; position: relative; }
569
584
  .btn.loading::after {
570
585
  content: ''; position: absolute; inset: 0; margin: auto;
571
586
  width: 14px; height: 14px; border-radius: 50%;
572
587
  border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
573
588
  border-top-color: currentColor; animation: reconnectSpin 0.7s linear infinite;
574
589
  }
590
+ .btn.loading[aria-busy="true"] { color: inherit; }
591
+ .btn.loading[aria-busy="true"] > * { opacity: 0.15; }
575
592
  .field-error { display: none; color: var(--red); font-size: 11px; margin-top: 5px; line-height: 1.4; }
576
593
  .field-error.show { display: block; }
577
594
 
@@ -718,17 +735,17 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
718
735
  BOOKMARKS
719
736
  ═══════════════════════════════════════════════════════ */
720
737
  #bookmarks-header {
721
- display: flex; align-items: center; gap: 6px;
722
- padding: 4px 10px; font-size: 11px; color: var(--fg2);
723
- text-transform: uppercase; letter-spacing: 0.5px;
738
+ display: flex; align-items: center; gap: 3px;
739
+ padding: 2px 6px; font-size: 10px; color: var(--fg2);
740
+ text-transform: uppercase; letter-spacing: 0.3px;
724
741
  border-bottom: 1px solid var(--border); flex-shrink: 0; cursor: pointer;
725
742
  }
726
743
  #bookmarks-header:hover { color: var(--fg); }
727
744
  #bookmarks-list { max-height: 0; overflow: hidden; transition: max-height var(--transition-normal); flex-shrink: 0; }
728
745
  #bookmarks-list.open { max-height: 200px; overflow-y: auto; }
729
746
  .bookmark-item {
730
- display: flex; align-items: center; gap: 6px;
731
- padding: 3px 10px; font-size: 12px; cursor: pointer;
747
+ display: flex; align-items: center; gap: 3px;
748
+ padding: 1px 6px; font-size: 11px; cursor: pointer;
732
749
  border-bottom: 1px solid transparent;
733
750
  }
734
751
  .bookmark-item:hover { background: var(--bg3); }
@@ -918,7 +935,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
918
935
  #mobile-keys { padding: 4px 4px; gap: 2px; }
919
936
  .mkey { min-width: 38px; padding: 0 6px; font-size: 11px; }
920
937
  #header { padding: 0 4px; gap: 3px; }
921
- .icon-btn { width: 28px; height: 28px; }
938
+ .icon-btn { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
922
939
  #logo { font-size: 13px; }
923
940
  }
924
941
 
@@ -948,8 +965,9 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
948
965
 
949
966
  /* Landscape orientation on small screens */
950
967
  @media (max-width: 768px) and (orientation: landscape) {
951
- #sidebar { width: 50vw; max-width: 320px; }
968
+ #sidebar { width: 50vw; max-width: 320px; top: 32px; }
952
969
  #header { height: 32px; }
970
+ #settings-panel { top: 32px; }
953
971
  #mobile-keys { height: 36px; min-height: calc(36px + env(safe-area-inset-bottom)); }
954
972
  .mkey { height: 28px; min-width: 36px; font-size: 10px; }
955
973
  .tab { height: 32px; font-size: 11px; }
@@ -966,14 +984,19 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
966
984
  .terminal { background: #fff !important; color: #000 !important; }
967
985
  #file-breadcrumb, #file-list-wrap { overflow: visible !important; }
968
986
  }
987
+ @supports (width: env(titlebar-area-width, 100%)) {
988
+ #header { padding-left: env(titlebar-area-x, 0px); width: env(titlebar-area-width, 100%); }
989
+ }
969
990
  #install-banner { display: none; }
970
991
  @media (prefers-reduced-motion: reduce) {
971
- *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
992
+ *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; animation-delay: 0ms !important; }
993
+ .overlay { backdrop-filter: none !important; }
994
+ #sidebar, #settings-panel, .modal { transition-duration: 0.01ms !important; }
972
995
  }
973
996
  </style>
974
997
  </head>
975
998
  <body data-theme="light">
976
- <a class="skip-link" href="#content">Skip to content</a>
999
+ <a class="skip-link" href="#content" onclick="document.getElementById('content')?.setAttribute('tabindex','-1');document.getElementById('content')?.focus();">Skip to content</a>
977
1000
  <script>
978
1001
  // Safe localStorage wrapper — degrades gracefully when Tracking Prevention blocks storage
979
1002
  const safeStorage = {
@@ -998,7 +1021,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
998
1021
  <div id="pin-box">
999
1022
  <h1 style="display:flex;align-items:center;justify-content:center;gap:6px"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="6" y1="16" x2="8" y2="16"/><line x1="12" y1="8" x2="18" y2="8"/><line x1="12" y1="12" x2="18" y2="12"/><line x1="12" y1="16" x2="16" y2="16"/><line x1="10" y1="16" x2="10" y2="16"/></svg>WebTun</h1>
1000
1023
  <p>Enter PIN to continue</p>
1001
- <input id="pin-input" type="password" placeholder="••••" autocomplete="off" maxlength="32" inputmode="numeric" aria-label="PIN code">
1024
+ <input id="pin-input" type="password" placeholder="••••" autocomplete="off" maxlength="32" inputmode="text" aria-label="PIN code">
1002
1025
  <div id="pin-error" role="alert"></div>
1003
1026
  <div style="margin-top:12px">
1004
1027
  <button class="btn btn-primary" onclick="submitPin()">Unlock</button>
@@ -1048,7 +1071,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1048
1071
  <button class="mm-item" role="menuitem" onclick="closeMoreMenu();toggleSearch()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> Search</button>
1049
1072
  <button class="mm-item" role="menuitem" onclick="closeMoreMenu();openShortcuts()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 8h6M12 8v8"/><path d="m7 12 10-4"/><path d="m7 12 10 4"/></svg> Keyboard Shortcuts</button>
1050
1073
  </div>
1051
- <div id="upload-progress"><div id="upload-progress-bar"></div><span id="upload-progress-text"></span></div>
1074
+ <div id="upload-progress"><div id="upload-progress-bar"></div><span id="upload-progress-text"></span><button id="upload-cancel-btn" aria-label="Cancel upload" title="Cancel upload" onclick="cancelUpload()">Cancel</button></div>
1052
1075
  </div>
1053
1076
  <div id="reconnect-banner" style="display:none;padding:4px 12px;font-size:11px;background:var(--accent);color:#fff;text-align:center;flex-shrink:0"><span class="reconnect-spinner"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></span> Reconnecting…</div>
1054
1077
 
@@ -1085,14 +1108,14 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1085
1108
  </div>
1086
1109
  <div id="file-list"></div>
1087
1110
  </div>
1088
- <div id="select-actions" style="display:none;padding:8px 10px;border-top:1px solid var(--border);background:var(--bg2);gap:4px;flex-wrap:wrap">
1111
+ <div id="select-actions" style="display:none;padding:6px 8px;border-top:1px solid var(--border);background:var(--bg2);gap:3px;flex-wrap:wrap">
1089
1112
  <span id="select-count" style="font-size:11px;color:var(--fg2);line-height:28px;flex:1"></span>
1090
1113
  <button class="sidebar-action-btn" id="select-all-btn" onclick="selectAllFiles()">Select All</button>
1091
1114
  <button class="sidebar-action-btn" onclick="deleteSelected()">Delete</button>
1092
1115
  <button class="sidebar-action-btn" onclick="downloadSelected()">Download</button>
1093
1116
  <button class="sidebar-action-btn" onclick="clearSelection()">Cancel</button>
1094
1117
  </div>
1095
- <div id="bookmarks-header" onclick="toggleBookmarks()"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg> Bookmarks <span id="bm-count" style="margin-left:auto;font-size:10px"></span></div>
1118
+ <div id="bookmarks-header" role="button" tabindex="0" aria-expanded="true" aria-controls="bookmarks-list" onclick="toggleBookmarks()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleBookmarks();}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg> Bookmarks <span id="bm-count" style="margin-left:auto;font-size:10px"></span></div>
1096
1119
  <div id="bookmarks-list"></div>
1097
1120
  <div id="sidebar-footer">
1098
1121
  <button class="sidebar-action-btn" onclick="bookmarkCurrentDir()" title="Bookmark current directory">
@@ -1120,12 +1143,13 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1120
1143
  </div>
1121
1144
 
1122
1145
  <!-- Content -->
1123
- <div id="content" role="main">
1146
+ <div id="content" role="main" tabindex="-1">
1124
1147
  <div id="tab-bar">
1125
- <div id="tab-scroll"></div>
1126
- <button id="new-tab-btn" onclick="newTab()" title="New terminal" aria-label="New terminal">
1127
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
1128
- </button>
1148
+ <div id="tab-scroll">
1149
+ <button id="new-tab-btn" onclick="newTab()" title="New terminal" aria-label="New terminal">
1150
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
1151
+ </button>
1152
+ </div>
1129
1153
  </div>
1130
1154
  <div id="editor-split-area">
1131
1155
  <div id="terminals">
@@ -1138,7 +1162,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1138
1162
  <button class="search-btn" title="Next (Enter)" onclick="searchNext()">
1139
1163
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
1140
1164
  </button>
1141
- <button class="search-btn" id="search-case-btn" title="Match Case" onclick="toggleSearchCase()">
1165
+ <button class="search-btn" id="search-case-btn" title="Match Case" aria-pressed="false" onclick="toggleSearchCase()">
1142
1166
  Aa
1143
1167
  </button>
1144
1168
  <button class="search-btn" title="Close (Esc)" onclick="closeSearch()">
@@ -1158,9 +1182,9 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1158
1182
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1159
1183
  </button>
1160
1184
  </div>
1161
- <div class="cmd-lib-tabs">
1162
- <button class="cmd-lib-tab active" id="cmd-tab-lib" onclick="switchCmdTab('library')">Library</button>
1163
- <button class="cmd-lib-tab" id="cmd-tab-hist" onclick="switchCmdTab('history')">History</button>
1185
+ <div class="cmd-lib-tabs" role="tablist">
1186
+ <button class="cmd-lib-tab active" id="cmd-tab-lib" role="tab" aria-selected="true" aria-controls="cmd-lib-list" onclick="switchCmdTab('library')">Library</button>
1187
+ <button class="cmd-lib-tab" id="cmd-tab-hist" role="tab" aria-selected="false" aria-controls="cmd-hist-list" onclick="switchCmdTab('history')">History</button>
1164
1188
  </div>
1165
1189
  <input id="cmd-lib-search" type="text" placeholder="Filter commands…" oninput="filterCmdLib()">
1166
1190
  <div id="cmd-lib-list"></div>
@@ -1187,7 +1211,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1187
1211
  <button class="icon-btn" aria-label="Close editor" onclick="closeEditor()" title="Close editor">
1188
1212
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1189
1213
  </button>
1190
- <span id="editor-filename-wrap"><span class="editor-dirty-dot" id="editor-dirty-dot" aria-label="Unsaved changes"></span><span id="editor-filename">—</span></span>
1214
+ <span id="editor-filename-wrap" aria-live="polite"><span class="editor-dirty-dot" id="editor-dirty-dot" aria-label="Unsaved changes" role="status"></span><span id="editor-filename">—</span></span>
1191
1215
  <span id="editor-status"></span>
1192
1216
  <button class="btn" id="md-preview-toggle" style="height:26px;padding:0 12px;font-size:12px;display:none" onclick="toggleMdPreview()">Preview</button>
1193
1217
  <button class="icon-btn" id="preview-refresh-btn" aria-label="Refresh preview" title="Refresh preview (Ctrl+Shift+R)" onclick="refreshPreview()" style="display:none">
@@ -1196,11 +1220,14 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1196
1220
  <button class="icon-btn active" id="editor-split-toggle" aria-label="Toggle split orientation" onclick="toggleEditorSplit()" title="Switch to horizontal split">
1197
1221
  <svg id="split-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
1198
1222
  </button>
1223
+ <button class="icon-btn" id="editor-fullscreen-toggle" aria-label="Fullscreen" title="Fullscreen (F11)" onclick="toggleEditorFullscreen()">
1224
+ <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>
1225
+ </button>
1199
1226
  <button class="btn btn-primary" id="editor-save-btn" style="height:26px;padding:0 12px;font-size:12px" onclick="saveFile()">Save</button>
1200
1227
  </div>
1201
1228
  <div id="editor-area">
1202
1229
  <textarea id="editor-textarea" spellcheck="false"></textarea>
1203
- <div id="editor-preview"><iframe id="editor-preview-iframe" sandbox="allow-same-origin allow-scripts"></iframe></div>
1230
+ <div id="editor-preview"><iframe id="editor-preview-iframe" sandbox="allow-scripts"></iframe></div>
1204
1231
  </div>
1205
1232
  </div>
1206
1233
  </div>
@@ -1521,7 +1548,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1521
1548
  <h3>Top Processes by CPU</h3>
1522
1549
  <div class="sys-table-wrap">
1523
1550
  <table class="sys-table" id="sys-proc-table">
1524
- <thead><tr><th>PID</th><th>User</th><th>CPU%</th><th>Mem%</th><th>Command</th></tr></thead>
1551
+ <thead><tr><th>PID</th><th>User</th><th>CPU%</th><th>Mem%</th><th>Command</th><th style="width:48px">Kill</th></tr></thead>
1525
1552
  <tbody id="sys-proc-body"></tbody>
1526
1553
  </table>
1527
1554
  </div>
@@ -1703,7 +1730,18 @@ function loadTabState() {
1703
1730
  } catch(e) { console.warn('loadTabState error:', e); return []; }
1704
1731
  }
1705
1732
 
1706
- // ═══════════════════════════════════════════════════════
1733
+ function saveCurrentPath() {
1734
+ try { safeStorage.setItem('wt-current-path', currentPath); } catch(e) { console.warn('Failed to save path:', e); }
1735
+ }
1736
+
1737
+ function loadCurrentPath() {
1738
+ try {
1739
+ const val = safeStorage.getItem('wt-current-path');
1740
+ return typeof val === 'string' && val ? val : null;
1741
+ } catch(e) { console.warn('loadCurrentPath error:', e); return null; }
1742
+ }
1743
+
1744
+ // ════════════════════════════════════════════════════════
1707
1745
  // INIT
1708
1746
  // ═══════════════════════════════════════════════════════
1709
1747
  function handlePinKeydown(e) {
@@ -1770,10 +1808,22 @@ async function unlockApp() {
1770
1808
 
1771
1809
  const info = await api('/api/home');
1772
1810
  homeDir = info.home;
1773
- currentPath = homeDir;
1774
1811
  serverPlatform = info.platform || '';
1775
1812
  document.getElementById('hostname-badge').textContent = info.hostname || '—';
1776
1813
 
1814
+ // Restore last visited path
1815
+ const savedPath = loadCurrentPath();
1816
+ if (savedPath) {
1817
+ try {
1818
+ // Validate saved path is within workspace or full fs is allowed
1819
+ currentPath = savedPath;
1820
+ } catch(e) {
1821
+ currentPath = homeDir;
1822
+ }
1823
+ } else {
1824
+ currentPath = homeDir;
1825
+ }
1826
+
1777
1827
  // Check tmux availability
1778
1828
  const sessInfo = await api('/api/sessions');
1779
1829
  hasTmux = sessInfo.tmux === true;
@@ -1794,6 +1844,7 @@ async function unlockApp() {
1794
1844
  setupSwipeGestures();
1795
1845
  setupFileListTouch();
1796
1846
  setupFileListKeyboard();
1847
+ setupFileListContextMenu();
1797
1848
  setupCtxMenuKeyboard();
1798
1849
  setupMoreMenuKeyboard();
1799
1850
  restoreSidebarWidth();
@@ -1980,30 +2031,36 @@ function setupTabInlineRename(tabTitleSpan, tab) {
1980
2031
  }
1981
2032
 
1982
2033
  function setupTabSwipeGesture(tabEl, id) {
1983
- let swipeStartX = 0;
2034
+ let swipeStartX = 0, swipeStartY = 0;
1984
2035
  let swipeStartScroll = 0;
1985
2036
  tabEl.addEventListener('touchstart', e => {
1986
2037
  swipeStartX = e.touches[0].clientX;
2038
+ swipeStartY = e.touches[0].clientY;
1987
2039
  swipeStartScroll = tabEl.parentElement.scrollLeft;
1988
2040
  }, { passive: true });
1989
2041
  tabEl.addEventListener('touchmove', e => {
1990
2042
  if (swipeStartX === 0) return;
1991
2043
  if (tabEl.parentElement.scrollLeft !== swipeStartScroll) { swipeStartX = 0; return; }
1992
2044
  const dx = e.touches[0].clientX - swipeStartX;
1993
- if (Math.abs(dx) > 2) {
2045
+ const dy = e.touches[0].clientY - swipeStartY;
2046
+ // Require horizontal swipe dominant (dx > dy*1.5) to avoid scroll confusion (U59)
2047
+ if (Math.abs(dx) < Math.abs(dy) * 1.5) return;
2048
+ if (Math.abs(dx) > 5) {
2049
+ e.preventDefault();
1994
2050
  tabEl.style.transform = `translateX(${dx}px)`;
1995
2051
  tabEl.style.opacity = Math.max(0.3, 1 - Math.abs(dx) / 200);
1996
2052
  tabEl.style.background = `rgba(247,118,142,${Math.min(Math.abs(dx) / 80, 1) * 0.2})`;
1997
2053
  }
1998
- }, { passive: true });
2054
+ }, { passive: false });
1999
2055
  tabEl.addEventListener('touchend', e => {
2000
2056
  if (swipeStartX === 0) return;
2001
2057
  const dx = e.changedTouches[0].clientX - swipeStartX;
2058
+ const dy = e.changedTouches[0].clientY - swipeStartY;
2002
2059
  tabEl.style.transform = '';
2003
2060
  tabEl.style.opacity = '';
2004
2061
  tabEl.style.background = '';
2005
- if (Math.abs(dx) > 80) closeTab({ stopPropagation() {} }, id);
2006
- swipeStartX = 0;
2062
+ if (Math.abs(dx) > 90 && Math.abs(dx) > Math.abs(dy) * 1.5) closeTab({ stopPropagation() {} }, id);
2063
+ swipeStartX = 0; swipeStartY = 0;
2007
2064
  }, { passive: true });
2008
2065
  }
2009
2066
 
@@ -2045,7 +2102,10 @@ function createTabButton(tab) {
2045
2102
  setupTabInlineRename(titleSpan, tab);
2046
2103
  setupTabSwipeGesture(tabEl, id);
2047
2104
 
2048
- document.getElementById('tab-scroll').appendChild(tabEl);
2105
+ const scroll = document.getElementById('tab-scroll');
2106
+ const newBtn = document.getElementById('new-tab-btn');
2107
+ if (newBtn && newBtn.parentElement === scroll) scroll.insertBefore(tabEl, newBtn);
2108
+ else scroll.appendChild(tabEl);
2049
2109
  tab.el = tabEl;
2050
2110
  }
2051
2111
 
@@ -2278,19 +2338,21 @@ function connectWebSocket(tab, isReconnect = false) {
2278
2338
  if (tab.closed) return;
2279
2339
  updateConnStatus(false);
2280
2340
  tab.reconnectAttempts = (tab.reconnectAttempts || 0) + 1;
2281
- if (tab.reconnectAttempts >= 3) {
2282
- const banner = document.getElementById('reconnect-banner');
2283
- if (banner) {
2284
- banner.innerHTML = 'Connection lost. <button class="btn btn-primary" data-action="manual-reconnect" style="height:26px;padding:0 12px;font-size:11px;margin-left:8px">Reconnect</button>';
2285
- banner.style.display = 'block';
2341
+ const _attempt = tab.reconnectAttempts;
2342
+ const _maxAttempts = 10;
2343
+ const spinnerHtml = '<span class="reconnect-spinner"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></span>';
2344
+ const banner = document.getElementById('reconnect-banner');
2345
+ if (banner) {
2346
+ if (_attempt >= 3) {
2347
+ banner.innerHTML = `Connection lost — retry ${_attempt}/${_maxAttempts}. <button class="btn btn-primary" onclick="manualReconnect()" style="height:26px;padding:0 12px;font-size:11px;margin-left:8px">Reconnect</button>`;
2348
+ } else {
2349
+ banner.innerHTML = `${spinnerHtml} Reconnecting (${_attempt}/${_maxAttempts})…`;
2286
2350
  }
2287
- } else {
2288
- const banner = document.getElementById('reconnect-banner');
2289
- if (banner) banner.style.display = 'block';
2351
+ banner.style.display = 'block';
2290
2352
  }
2291
2353
  tab.reconnectDelay = Math.min((tab.reconnectDelay || 1000) * 2, 15000);
2292
2354
  const secs = tab.reconnectDelay / 1000;
2293
- tab.term.writeln(`\r\n\x1b[33m[Disconnected — reconnecting in ${secs}s…]\x1b[0m`);
2355
+ tab.term.writeln(`\r\n\x1b[33m[Disconnected — reconnecting in ${secs}s (attempt ${_attempt}/${_maxAttempts})…]\x1b[0m`);
2294
2356
  tab.reconnectTimer = setTimeout(() => {
2295
2357
  if (!tab.closed) connectWebSocket(tab, true);
2296
2358
  }, tab.reconnectDelay);
@@ -2305,7 +2367,7 @@ function connectWebSocket(tab, isReconnect = false) {
2305
2367
  function manualReconnect() {
2306
2368
  const banner = document.getElementById('reconnect-banner');
2307
2369
  if (banner) {
2308
- banner.innerHTML = '<span class="reconnect-spinner"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></span> Reconnecting…';
2370
+ banner.innerHTML = '<span class="reconnect-spinner"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></span> Reconnecting (1/10)…';
2309
2371
  banner.style.display = 'block';
2310
2372
  }
2311
2373
  tabs.forEach(tab => {
@@ -2389,12 +2451,15 @@ function initTerminal(tab) {
2389
2451
  term.unicode.activeVersion = '11';
2390
2452
  } catch (_) {}
2391
2453
 
2392
- // GPU-accelerated renderer — falls back to canvas if WebGL unavailable
2393
- try {
2394
- const webglAddon = new WebglAddon.WebglAddon();
2395
- webglAddon.onContextLoss(() => { webglAddon.dispose(); });
2396
- term.loadAddon(webglAddon);
2397
- } catch (_) {}
2454
+ // GPU-accelerated renderer — falls back to canvas if WebGL unavailable (skip if >4 tabs to guard memory)
2455
+ if (tabs.filter(t => t.term).length < 4) {
2456
+ try {
2457
+ const webglAddon = new WebglAddon.WebglAddon();
2458
+ webglAddon.onContextLoss(() => { try { webglAddon.dispose(); } catch {} try { term.element?.querySelector('canvas')?.remove(); } catch {} });
2459
+ term.loadAddon(webglAddon);
2460
+ tab._webglAddon = webglAddon;
2461
+ } catch (_) {}
2462
+ }
2398
2463
 
2399
2464
  term.open(tab.wrapper);
2400
2465
 
@@ -2408,7 +2473,7 @@ function initTerminal(tab) {
2408
2473
  tab.textarea.name = 'terminal-input';
2409
2474
  }
2410
2475
 
2411
- // Intercept paste events to use bracketed paste mode (required for TUI apps like vim, nano, mc)
2476
+ // Intercept paste events to use bracketed paste mode (required for TUI apps like vim, nano, mc) — chunk 60KB with loop
2412
2477
  if (tab.textarea) {
2413
2478
  tab.textarea.addEventListener('paste', e => {
2414
2479
  e.preventDefault();
@@ -2417,9 +2482,13 @@ function initTerminal(tab) {
2417
2482
  tab._lastPasteText = text;
2418
2483
  const bracketed = '\x1b[200~' + text + '\x1b[201~';
2419
2484
  const enc = new TextEncoder().encode(bracketed);
2420
- const buf = new Uint8Array(1 + enc.length);
2421
- buf[0] = 0x00; buf.set(enc, 1);
2422
- tab.ws.send(buf.buffer);
2485
+ const CHUNK = 60 * 1024;
2486
+ for (let off = 0; off < enc.length; off += CHUNK) {
2487
+ const slice = enc.subarray(off, Math.min(off + CHUNK, enc.length));
2488
+ const buf = new Uint8Array(1 + slice.length);
2489
+ buf[0] = 0x00; buf.set(slice, 1);
2490
+ try { tab.ws.send(buf.buffer); } catch {}
2491
+ }
2423
2492
  }, true);
2424
2493
  }
2425
2494
 
@@ -2436,15 +2505,18 @@ function initTerminal(tab) {
2436
2505
  tab.el.querySelector('.tab-title').textContent = tab.title;
2437
2506
  });
2438
2507
 
2439
- // Bell: visual flash + desktop notification for background tabs
2508
+ // Bell: visual flash + desktop notification for background tabs (also flash parent for WebGL)
2440
2509
  term.onBell(() => {
2441
- // Visual flash on terminal screen
2510
+ // Visual flash on terminal screen and parent (WebGL uses canvas, so also flash parent)
2442
2511
  const screen = term.element?.querySelector('.xterm-screen');
2443
- if (screen) {
2444
- screen.classList.remove('term-bell-flash');
2445
- void screen.offsetWidth; // reflow to restart animation
2446
- screen.classList.add('term-bell-flash');
2447
- }
2512
+ const parent = term.element;
2513
+ [screen, parent].forEach(el => {
2514
+ if (!el) return;
2515
+ el.classList.remove('term-bell-flash');
2516
+ void el.offsetWidth; // reflow to restart animation
2517
+ el.classList.add('term-bell-flash');
2518
+ setTimeout(() => el.classList.remove('term-bell-flash'), 150);
2519
+ });
2448
2520
  // Desktop notification if tab is in the background
2449
2521
  if (document.hidden && settings.bell && Notification.permission === 'granted') {
2450
2522
  try {
@@ -2543,27 +2615,42 @@ function initTerminal(tab) {
2543
2615
  if (!tab.closed) connectWebSocket(tab, false);
2544
2616
  });
2545
2617
 
2546
- // Scroll terminal on wheel
2618
+ // Scroll terminal on wheel — with try/catch fallback for isMouseTracking
2547
2619
  tab.wrapper.addEventListener('wheel', e => {
2548
2620
  if (e.ctrlKey || e.metaKey) return;
2549
2621
  e.preventDefault();
2550
2622
  // TUI apps with mouse tracking handle scroll themselves — forward the event to xterm.js
2623
+ let isTracking = false;
2551
2624
  try {
2552
2625
  const core = term._core;
2553
- if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive()) {
2626
+ if (core?.coreMouseService?.isMouseTrackingActive?.()) isTracking = core.coreMouseService.isMouseTrackingActive();
2627
+ // fallback check via private API may throw across xterm versions
2628
+ } catch (_) { isTracking = false; }
2629
+ if (isTracking) {
2630
+ try {
2554
2631
  const vp = tab.wrapper.querySelector('.xterm-viewport');
2555
- if (vp) vp.dispatchEvent(new WheelEvent('wheel', e));
2632
+ if (vp) vp.dispatchEvent(new WheelEvent('wheel', { deltaY: e.deltaY, deltaMode: e.deltaMode, deltaX: e.deltaX, clientX: e.clientX, clientY: e.clientY, ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey, bubbles: true }));
2556
2633
  return;
2557
- }
2558
- } catch (_) {}
2634
+ } catch (_) { /* fallback to manual scroll */ }
2635
+ }
2559
2636
  const atTop = term.buffer.active.baseY === 0;
2560
2637
  const atBottom = term.buffer.active.baseY + term.rows >= term.buffer.active.length;
2561
2638
  if (tilesMode && ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom))) return;
2562
2639
  let delta = e.deltaY;
2563
2640
  if (e.deltaMode === 1) delta *= 16;
2564
2641
  else if (e.deltaMode === 2) delta *= 16 * term.rows;
2565
- const rowsEl = tab.wrapper.querySelector('.xterm-rows');
2566
- const lineHeight = rowsEl && rowsEl.children[0] ? rowsEl.children[0].getBoundingClientRect().height || 16 : 16;
2642
+ let lineHeight = 16;
2643
+ try {
2644
+ const d = term._core?.viewportRenderer?.dimensions;
2645
+ if (d?.actualCellHeight) lineHeight = d.actualCellHeight;
2646
+ else {
2647
+ const rowsEl = tab.wrapper.querySelector('.xterm-rows');
2648
+ lineHeight = rowsEl && rowsEl.children[0] ? rowsEl.children[0].getBoundingClientRect().height || 16 : 16;
2649
+ }
2650
+ } catch (_) {
2651
+ const rowsEl = tab.wrapper.querySelector('.xterm-rows');
2652
+ lineHeight = rowsEl && rowsEl.children[0] ? rowsEl.children[0].getBoundingClientRect().height || 16 : 16;
2653
+ }
2567
2654
  const lines = Math.sign(delta) * Math.max(1, Math.round(Math.abs(delta) / lineHeight));
2568
2655
  term.scrollLines(lines);
2569
2656
  }, { passive: false });
@@ -2720,19 +2807,32 @@ function layoutTiles() {
2720
2807
  }
2721
2808
  hdr = document.createElement('div');
2722
2809
  hdr.className = 'term-tile-header';
2810
+ hdr.setAttribute('tabindex', '0');
2811
+ hdr.setAttribute('role', 'button');
2812
+ hdr.setAttribute('aria-label', 'Activate ' + tab.title);
2723
2813
  const tthName = document.createElement('span');
2724
2814
  tthName.className = 'tth-name';
2815
+ tthName.setAttribute('tabindex', '0');
2816
+ tthName.setAttribute('role', 'button');
2817
+ tthName.setAttribute('aria-label', 'Focus terminal ' + tab.title);
2725
2818
  tthName.textContent = tab.title;
2726
2819
  hdr.appendChild(tthName);
2727
2820
  const tthClose = document.createElement('button');
2728
2821
  tthClose.className = 'tth-close';
2729
2822
  tthClose.setAttribute('aria-label', 'Close tab');
2823
+ tthClose.setAttribute('tabindex', '0');
2730
2824
  tthClose.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
2731
2825
  hdr.appendChild(tthClose);
2732
2826
  hdr.addEventListener('click', e => {
2733
2827
  if (e.target.closest('.tth-close')) return;
2734
2828
  activateTab(tab.id);
2735
2829
  });
2830
+ hdr.addEventListener('keydown', e => {
2831
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activateTab(tab.id); }
2832
+ });
2833
+ tthName.addEventListener('keydown', e => {
2834
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); activateTab(tab.id); tab.term?.focus(); }
2835
+ });
2736
2836
  hdr.querySelector('.tth-close').addEventListener('click', e => {
2737
2837
  e.stopPropagation();
2738
2838
  closeTab(e, tab.id);
@@ -2792,20 +2892,10 @@ async function loadFiles(dir) {
2792
2892
  if (loadFilesAbortController) loadFilesAbortController.abort();
2793
2893
  loadFilesAbortController = new AbortController();
2794
2894
 
2795
- if (skipHistoryPush) {
2796
- // back/forward don't push and don't clear forward
2797
- } else {
2798
- if (currentPath && currentPath !== dir) {
2799
- navHistory.push(currentPath);
2800
- }
2801
- navForwardHistory = [];
2802
- }
2895
+ const prevPath = currentPath;
2896
+ const shouldPushHistory = !skipHistoryPush && currentPath && currentPath !== dir;
2803
2897
  skipHistoryPush = false;
2804
2898
 
2805
- currentPath = dir;
2806
- document.getElementById('path-input').value = dir;
2807
- renderBreadcrumb(dir);
2808
-
2809
2899
  const list = document.getElementById('file-list');
2810
2900
 
2811
2901
  setFileListLoading(true);
@@ -2818,19 +2908,52 @@ async function loadFiles(dir) {
2818
2908
  const errorDiv = document.createElement('div');
2819
2909
  errorDiv.style.cssText = 'padding:16px;text-align:center;';
2820
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>' +
2821
2912
  '<button class="btn btn-primary" style="height:28px;padding:0 12px;font-size:11px">Retry</button>';
2822
2913
  list.innerHTML = '';
2823
2914
  list.appendChild(errorDiv);
2824
2915
  errorDiv.querySelector('button').addEventListener('click', () => loadFiles(dir));
2916
+ toast(e.message || 'Failed to load directory', 'error');
2825
2917
  return;
2826
2918
  }
2827
2919
 
2828
2920
  if (!data.files) {
2829
2921
  setFileListLoading(false);
2830
- 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
+ }
2831
2943
  return;
2832
2944
  }
2833
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
+
2834
2957
  currentParent = data.parent || '';
2835
2958
 
2836
2959
  // Map existing DOM children by dataset.path
@@ -2894,13 +3017,16 @@ async function loadFiles(dir) {
2894
3017
  sizeEl.remove();
2895
3018
  }
2896
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;
2897
3027
  }
2898
3028
 
2899
- addItem(key, () => {
2900
- const item = makeFileItem(f.name, f.isDir, key, sizeStr, icon);
2901
- item.addEventListener('contextmenu', e => showCtxMenu(e, f));
2902
- return item;
2903
- });
3029
+ addItem(key, () => makeFileItem(f.name, f.isDir, key, sizeStr, icon));
2904
3030
  });
2905
3031
 
2906
3032
  // Remove any orphaned nodes (not in new data)
@@ -2942,25 +3068,28 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
2942
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>
2943
3069
  `;
2944
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);
2945
3074
  if (e.target.closest('.file-select-check')) {
2946
- toggleFileSelection(fullPath, div);
3075
+ toggleFileSelection(curPath, div);
2947
3076
  if (selectedFiles.length > 0) document.getElementById('select-actions').style.display = 'flex';
2948
3077
  return;
2949
3078
  }
2950
3079
  if (e.target.closest('.file-ellipsis')) {
2951
3080
  e.stopPropagation();
2952
3081
  const rect = div.querySelector('.file-ellipsis').getBoundingClientRect();
2953
- const m = fullPath.match(/\.([^.]+)$/);
2954
- 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() : '' });
2955
3084
  return;
2956
3085
  }
2957
3086
  if (selectMode) {
2958
- toggleFileSelection(fullPath, div);
3087
+ toggleFileSelection(curPath, div);
2959
3088
  return;
2960
3089
  }
2961
- if (isDir) loadFiles(fullPath);
2962
- else if (isImageFile(fullPath)) openImageViewer(fullPath);
2963
- else openFileEditor(fullPath);
3090
+ if (curIsDir) loadFiles(curPath);
3091
+ else if (isImageFile(curPath)) openImageViewer(curPath);
3092
+ else openFileEditor(curPath);
2964
3093
  });
2965
3094
  const ell = div.querySelector('.file-ellipsis');
2966
3095
  if (ell) {
@@ -2968,9 +3097,12 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
2968
3097
  if (e.key === 'Enter' || e.key === ' ') {
2969
3098
  e.preventDefault();
2970
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);
2971
3103
  const rect = ell.getBoundingClientRect();
2972
- const m = fullPath.match(/\.([^.]+)$/);
2973
- 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() : '' });
2974
3106
  }
2975
3107
  });
2976
3108
  }
@@ -3014,10 +3146,33 @@ function setupFileListKeyboard() {
3014
3146
  });
3015
3147
  }
3016
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
+
3017
3165
  function refreshFiles() {
3018
3166
  if (currentPath) loadFiles(currentPath);
3019
3167
  }
3020
- 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
+ }
3021
3176
  function navigateUp() {
3022
3177
  if (currentParent && currentParent !== currentPath) {
3023
3178
  loadFiles(currentParent);
@@ -3053,7 +3208,10 @@ function startFileWatcher() {
3053
3208
  if (fileWatchTimer) return;
3054
3209
  const watch = () => {
3055
3210
  const sb = document.getElementById('sidebar');
3211
+ const sp = document.getElementById('settings-panel');
3056
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;
3057
3215
  if (currentPath) loadFiles(currentPath);
3058
3216
  };
3059
3217
  const onVisibility = () => {
@@ -3134,13 +3292,13 @@ function renderBreadcrumb(fullPath) {
3134
3292
  }
3135
3293
 
3136
3294
  if (!segments.length) {
3137
- html = `<span style="color:var(--fg2)">\\</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
3295
+ html = `<span style="color:var(--fg2)">\\</span><span style="color:var(--fg2);font-size:11px;margin-left:3px">(root)</span>`;
3138
3296
  } else {
3139
3297
  html = segments.map((seg, i) => {
3140
3298
  const isLast = i === segments.length - 1;
3141
- if (isLast) return `<span style="color:var(--fg);font-weight:500">${escHtml(seg.label)}</span>`;
3142
- return `<a href="#" data-path="${escHtml(seg.path)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(seg.label)}</a>` +
3143
- `<span style="color:var(--fg2);margin:0 2px">\\</span>`;
3299
+ if (isLast) return `<span style="color:var(--fg);font-weight:500;display:inline-flex;align-items:center;height:24px;padding:2px 4px">${escHtml(seg.label)}</span>`;
3300
+ return `<a href="#" data-path="${escHtml(seg.path)}" style="color:var(--accent);text-decoration:none;display:inline-flex;align-items:center;height:24px;padding:2px 6px;border-radius:var(--radius-sm)">${escHtml(seg.label)}</a>` +
3301
+ `<span style="color:var(--fg2);margin:0 2px;display:inline-flex;align-items:center">\\</span>`;
3144
3302
  }).join('');
3145
3303
  }
3146
3304
  } else {
@@ -3150,10 +3308,10 @@ function renderBreadcrumb(fullPath) {
3150
3308
  accumulated += '/' + part;
3151
3309
  const isLast = part === parts[parts.length - 1];
3152
3310
  if (isLast) {
3153
- html += `<span style="color:var(--fg);font-weight:500">${escHtml(part)}</span>`;
3311
+ html += `<span style="color:var(--fg);font-weight:500;display:inline-flex;align-items:center;height:24px;padding:2px 4px">${escHtml(part)}</span>`;
3154
3312
  } else {
3155
- html += `<a href="#" data-path="${escHtml(accumulated)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(part)}</a>`;
3156
- html += `<span style="color:var(--fg2);margin:0 2px">/</span>`;
3313
+ html += `<a href="#" data-path="${escHtml(accumulated)}" style="color:var(--accent);text-decoration:none;display:inline-flex;align-items:center;height:24px;padding:2px 6px;border-radius:var(--radius-sm)">${escHtml(part)}</a>`;
3314
+ html += `<span style="color:var(--fg2);margin:0 2px;display:inline-flex;align-items:center">/</span>`;
3157
3315
  }
3158
3316
  }
3159
3317
  if (!parts.length) {
@@ -3161,7 +3319,7 @@ function renderBreadcrumb(fullPath) {
3161
3319
  }
3162
3320
  }
3163
3321
 
3164
- el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--fg2);margin-right:4px;vertical-align:middle"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' + html;
3322
+ el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--fg2);flex-shrink:0;vertical-align:middle"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' + html;
3165
3323
 
3166
3324
  if (!el.dataset.delegated) {
3167
3325
  el.addEventListener('click', e => {
@@ -3558,9 +3716,8 @@ async function confirmRename() {
3558
3716
  body: JSON.stringify({ oldPath: renamePath, newName })
3559
3717
  });
3560
3718
  setBtnBusy(document.getElementById('rename-ok-btn'), false);
3561
- if (r.success) { toast('Renamed', 'success'); refreshFiles(); }
3719
+ if (r.success) { toast('Renamed', 'success'); refreshFiles(); closeOverlay('rename-overlay'); }
3562
3720
  else showFieldError('rename-error', r.error || 'Rename failed');
3563
- closeOverlay('rename-overlay');
3564
3721
  }
3565
3722
 
3566
3723
  async function deleteFile(file) {
@@ -3763,6 +3920,8 @@ async function handleUploadInput() {
3763
3920
  }
3764
3921
 
3765
3922
  let _uploadProgressTimeout = null;
3923
+ let _currentUploadXhr = null;
3924
+ let _uploadCancelled = false;
3766
3925
  function setUploadProgress(pct, status, text) {
3767
3926
  const bar = document.getElementById('upload-progress');
3768
3927
  const barFill = document.getElementById('upload-progress-bar');
@@ -3783,19 +3942,33 @@ function clearUploadProgress(delay = 1500) {
3783
3942
  bar.classList.remove('active', 'success', 'error');
3784
3943
  bar.querySelector('#upload-progress-bar').style.width = '0%';
3785
3944
  bar.querySelector('#upload-progress-text').textContent = '';
3945
+ _currentUploadXhr = null;
3946
+ _uploadCancelled = false;
3786
3947
  }
3787
3948
  }, delay);
3788
3949
  }
3950
+ function cancelUpload() {
3951
+ _uploadCancelled = true;
3952
+ if (_currentUploadXhr) {
3953
+ try { _currentUploadXhr.abort(); } catch {}
3954
+ _currentUploadXhr = null;
3955
+ }
3956
+ setUploadProgress(100, 'error', 'Upload cancelled');
3957
+ clearUploadProgress(1500);
3958
+ toast('Upload cancelled', 'warning');
3959
+ }
3789
3960
 
3790
3961
  async function uploadFileList(items) {
3791
3962
  const path = encodeURIComponent(currentPath);
3792
3963
  const total = items.length;
3793
3964
  let completed = 0;
3794
3965
  let failedItems = [];
3966
+ _uploadCancelled = false;
3795
3967
 
3796
3968
  setUploadProgress(0, '', `0/${total}`);
3797
3969
 
3798
3970
  for (const item of items) {
3971
+ if (_uploadCancelled) break;
3799
3972
  const file = item.file || item;
3800
3973
  const fileName = item.path || item.webkitRelativePath || item.name;
3801
3974
  const formData = new FormData();
@@ -3804,6 +3977,7 @@ async function uploadFileList(items) {
3804
3977
  try {
3805
3978
  await new Promise((resolve, reject) => {
3806
3979
  const xhr = new XMLHttpRequest();
3980
+ _currentUploadXhr = xhr;
3807
3981
  xhr.open('POST', `/api/files/upload?path=${path}`);
3808
3982
  xhr.setRequestHeader('x-pin-token', authToken);
3809
3983
 
@@ -3816,6 +3990,7 @@ async function uploadFileList(items) {
3816
3990
  });
3817
3991
 
3818
3992
  xhr.onload = () => {
3993
+ _currentUploadXhr = null;
3819
3994
  if (xhr.status >= 200 && xhr.status < 300) {
3820
3995
  try {
3821
3996
  const resp = JSON.parse(xhr.responseText);
@@ -3839,26 +4014,44 @@ async function uploadFileList(items) {
3839
4014
  };
3840
4015
 
3841
4016
  xhr.onerror = () => {
4017
+ _currentUploadXhr = null;
4018
+ if (_uploadCancelled) { reject(new Error('Cancelled')); return; }
3842
4019
  failedItems.push(fileName);
3843
4020
  reject(new Error('Network error'));
3844
4021
  };
4022
+ xhr.onabort = () => {
4023
+ _currentUploadXhr = null;
4024
+ reject(new Error('Cancelled'));
4025
+ };
3845
4026
  xhr.send(formData);
3846
4027
  });
3847
4028
  } catch (e) {
4029
+ if (_uploadCancelled) break;
3848
4030
  console.warn('Upload item failed:', e);
3849
4031
  }
3850
4032
  }
3851
4033
 
3852
- // Final result
4034
+ _currentUploadXhr = null;
4035
+ // Final result — keep error visible longer (no auto-clear 1500ms for errors)
4036
+ if (_uploadCancelled) {
4037
+ // already handled in cancelUpload
4038
+ refreshFiles();
4039
+ return;
4040
+ }
3853
4041
  if (failedItems.length === 0) {
3854
4042
  setUploadProgress(100, 'success', `${completed}/${total} uploaded`);
4043
+ if (failedItems.length) toast(`${failedItems.length} failed`, 'error');
4044
+ clearUploadProgress(2000);
3855
4045
  } else if (completed > 0) {
3856
4046
  setUploadProgress(100, 'error', `${completed} ok, ${failedItems.length} failed`);
4047
+ toast(`${failedItems.length} failed`, 'error');
4048
+ clearUploadProgress(3000);
3857
4049
  } else {
3858
4050
  setUploadProgress(100, 'error', `All ${total} failed`);
4051
+ toast(`All ${total} failed`, 'error');
4052
+ clearUploadProgress(3000);
3859
4053
  }
3860
4054
 
3861
- clearUploadProgress(2000);
3862
4055
  refreshFiles();
3863
4056
  }
3864
4057
 
@@ -3890,9 +4083,27 @@ function initCodeMirror() {
3890
4083
  },
3891
4084
  }
3892
4085
  });
3893
- editor.on('change', () => updateEditorDirty());
4086
+ editor.on('change', () => { updateEditorDirty(); autoSaveDraft(); });
3894
4087
  return editor;
3895
4088
  }
4089
+ let _autoSaveTimer = null;
4090
+ function autoSaveDraft() {
4091
+ clearTimeout(_autoSaveTimer);
4092
+ _autoSaveTimer = setTimeout(() => {
4093
+ try {
4094
+ if (!editorPath) return;
4095
+ const content = editor ? editor.getValue() : '';
4096
+ if (content !== editorOriginalContent) {
4097
+ safeStorage.setItem('wt-draft:' + editorPath, content);
4098
+ } else {
4099
+ safeStorage.removeItem('wt-draft:' + editorPath);
4100
+ }
4101
+ } catch {}
4102
+ }, 2000);
4103
+ }
4104
+ function loadDraft(p) {
4105
+ try { return safeStorage.getItem('wt-draft:' + p); } catch { return null; }
4106
+ }
3896
4107
 
3897
4108
  function updateEditorDirty() {
3898
4109
  const el = document.getElementById('editor-view');
@@ -3905,8 +4116,11 @@ function getCMmode(fileName) {
3905
4116
  const m = fileName.match(/\.([^.]+)$/);
3906
4117
  if (!m) return null;
3907
4118
  switch (m[1].toLowerCase()) {
3908
- case 'js': case 'jsx': case 'mjs': case 'cjs': return 'javascript';
3909
- case 'ts': case 'tsx': return {name: 'javascript', typescript: true};
4119
+ case 'js': return 'javascript';
4120
+ case 'jsx': return {name: 'javascript', json: false, jsx: true};
4121
+ case 'mjs': case 'cjs': return 'javascript';
4122
+ case 'ts': return {name: 'javascript', typescript: true};
4123
+ case 'tsx': return {name: 'javascript', typescript: true, jsx: true};
3910
4124
  case 'json': return {name: 'javascript', json: true};
3911
4125
  case 'py': return 'python';
3912
4126
  case 'html': case 'htm': return 'htmlmixed';
@@ -4051,7 +4265,12 @@ async function closeEditor() {
4051
4265
  if (!ok) return;
4052
4266
  }
4053
4267
  clearPreviewLiveReload();
4054
- document.getElementById('editor-view').classList.remove('open');
4268
+ const ev = document.getElementById('editor-view');
4269
+ ev.classList.remove('open');
4270
+ ev.classList.remove('fullscreen');
4271
+ document.removeEventListener('keydown', _fsEscHandler);
4272
+ const fsBtn = document.getElementById('editor-fullscreen-toggle');
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>'; }
4055
4274
  document.getElementById('content').classList.remove('editor-open');
4056
4275
  document.getElementById('editor-preview').classList.remove('active');
4057
4276
  document.getElementById('md-preview-toggle').style.display = 'none';
@@ -4144,6 +4363,8 @@ function renderHtmlPreview() {
4144
4363
  const iframe = document.getElementById('editor-preview-iframe');
4145
4364
  const raw = editor ? editor.getValue() : '';
4146
4365
  if (!iframe) return;
4366
+ // Clear previous live reload to prevent leak on re-entry (U83)
4367
+ clearPreviewLiveReload();
4147
4368
  const s = getComputedStyle(document.documentElement);
4148
4369
  const bg = s.getPropertyValue('--bg').trim();
4149
4370
  const fg = s.getPropertyValue('--fg').trim();
@@ -4192,7 +4413,7 @@ function renderHtmlPreview() {
4192
4413
  input:focus, textarea:focus, select:focus { outline: 2px solid ${accent}; outline-offset: 1px; }
4193
4414
  </style>
4194
4415
  </head>
4195
- <body>${typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { USE_PROFILES: { html: true }, ADD_TAGS: ['style'], ADD_ATTR: ['target'] }) : raw}</body>
4416
+ <body>${typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { USE_PROFILES: { html: true }, ADD_ATTR: ['target'] }) : raw}</body>
4196
4417
  </html>`;
4197
4418
  iframe.srcdoc = doc;
4198
4419
  iframe.style.display = 'block';
@@ -4319,7 +4540,9 @@ function closeSearch() {
4319
4540
  let searchCaseSensitive = false;
4320
4541
  function toggleSearchCase() {
4321
4542
  searchCaseSensitive = !searchCaseSensitive;
4322
- document.getElementById('search-case-btn').classList.toggle('active', searchCaseSensitive);
4543
+ const btn = document.getElementById('search-case-btn');
4544
+ btn.classList.toggle('active', searchCaseSensitive);
4545
+ btn.setAttribute('aria-pressed', String(searchCaseSensitive));
4323
4546
  doSearch();
4324
4547
  }
4325
4548
 
@@ -4454,12 +4677,35 @@ function toggleSetting(k) {
4454
4677
  function openSettings() {
4455
4678
  const panel = document.getElementById('settings-panel');
4456
4679
  const isOpen = panel.classList.contains('open');
4457
- panel.classList.toggle('open');
4458
- if (!isOpen) document.addEventListener('click', closeSettingsOnClickOutside, true);
4459
- else document.removeEventListener('click', closeSettingsOnClickOutside, true);
4680
+ // Don't inert #main because panel is inside #main — inert would disable panel itself. Inert content only (sidebar is sibling).
4681
+ const content = document.getElementById('content');
4682
+ if (!isOpen) {
4683
+ panel.classList.add('open');
4684
+ panel.setAttribute('aria-modal', 'true');
4685
+ panel.setAttribute('role', 'dialog');
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
+ });
4693
+ setTimeout(() => document.addEventListener('click', closeSettingsOnClickOutside, true), 50);
4694
+ } else {
4695
+ panel.classList.remove('open');
4696
+ panel.removeAttribute('aria-modal');
4697
+ if (content) content.removeAttribute('inert');
4698
+ removeFocusTrap();
4699
+ document.removeEventListener('click', closeSettingsOnClickOutside, true);
4700
+ }
4460
4701
  }
4461
4702
  function closeSettings() {
4462
- document.getElementById('settings-panel').classList.remove('open');
4703
+ const panel = document.getElementById('settings-panel');
4704
+ const content = document.getElementById('content');
4705
+ panel.classList.remove('open');
4706
+ panel.removeAttribute('aria-modal');
4707
+ if (content) content.removeAttribute('inert');
4708
+ removeFocusTrap();
4463
4709
  document.removeEventListener('click', closeSettingsOnClickOutside, true);
4464
4710
  }
4465
4711
  function closeSettingsOnClickOutside(e) {
@@ -4912,8 +5158,37 @@ function toggleEditorSplit() {
4912
5158
  try { safeStorage.setItem('wt-editor-split-orientation', isHoriz ? 'horizontal' : 'vertical'); } catch(e) { console.warn(e); }
4913
5159
  }
4914
5160
 
4915
- // ═══════════════════════════════════════════════════════
4916
- let wakeLock = null;
5161
+ function toggleEditorFullscreen() {
5162
+ const view = document.getElementById('editor-view');
5163
+ if (!view || !view.classList.contains('open')) return;
5164
+ const isFS = view.classList.toggle('fullscreen');
5165
+ const btn = document.getElementById('editor-fullscreen-toggle');
5166
+ if (btn) {
5167
+ btn.setAttribute('aria-label', isFS ? 'Exit fullscreen' : 'Fullscreen');
5168
+ btn.title = isFS ? 'Exit fullscreen (Esc/F11)' : 'Fullscreen (F11)';
5169
+ btn.innerHTML = isFS
5170
+ ? '<svg width="14" height="14" 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>'
5171
+ : '<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>';
5172
+ }
5173
+ if (isFS) {
5174
+ document.addEventListener('keydown', _fsEscHandler);
5175
+ } else {
5176
+ document.removeEventListener('keydown', _fsEscHandler);
5177
+ requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); if(editor) editor.refresh(); });
5178
+ }
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);}}); });
5182
+ }
5183
+ function _fsEscHandler(e) {
5184
+ if (e.key === 'Escape' || e.key === 'F11') {
5185
+ const v = document.getElementById('editor-view');
5186
+ if (v && v.classList.contains('fullscreen')) { e.preventDefault(); toggleEditorFullscreen(); }
5187
+ }
5188
+ }
5189
+
5190
+ // Terminal fullscreen removed — per request, terminal fullscreen should not cover sidebar
5191
+ // toggleTerminalFullscreen and _termFsEscHandler intentionally removed
4917
5192
  const hasWakeLock = 'wakeLock' in navigator;
4918
5193
 
4919
5194
  async function requestWakeLock() {
@@ -4962,7 +5237,11 @@ function moveTabToEnd(fromId) {
4962
5237
  const [moved] = tabs.splice(fromIdx, 1);
4963
5238
  tabs.push(moved);
4964
5239
  const bar = document.getElementById('tab-scroll');
4965
- if (moved.el && moved.el.parentNode === bar) bar.appendChild(moved.el);
5240
+ const newBtn = document.getElementById('new-tab-btn');
5241
+ if (moved.el && moved.el.parentNode === bar) {
5242
+ if (newBtn && newBtn.parentElement === bar) bar.insertBefore(moved.el, newBtn);
5243
+ else bar.appendChild(moved.el);
5244
+ }
4966
5245
  saveTabState();
4967
5246
  }
4968
5247
 
@@ -5004,6 +5283,7 @@ function setupKeyboardShortcuts() {
5004
5283
  if (ctrl && e.shiftKey && e.key === 'ArrowRight') { e.preventDefault(); cycleTab(1); }
5005
5284
  if (ctrl && e.shiftKey && e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); }
5006
5285
  if (ctrl && e.shiftKey && e.key === 'r') { e.preventDefault(); refreshPreview(); }
5286
+ if (e.key === 'F11' && document.getElementById('editor-view').classList.contains('open')) { e.preventDefault(); toggleEditorFullscreen(); }
5007
5287
  if (e.key === 'Escape') {
5008
5288
  const termMenu = document.getElementById('term-ctx-menu');
5009
5289
  if (termMenu && termMenu.style.display !== 'none') {
@@ -5059,26 +5339,25 @@ function toggleTermSelect() {
5059
5339
  }
5060
5340
  }
5061
5341
 
5342
+ const activeTab = getActiveTab();
5062
5343
  if (termSelectMode) {
5063
- tabs.forEach(t => {
5064
- if (!t.term) return;
5065
- t.term.options.disableStdin = true;
5066
- const ta = t.textarea || t.term.textarea || t.term.element?.querySelector('.xterm-textarea, textarea');
5344
+ if (activeTab?.term) {
5345
+ activeTab.term.options.disableStdin = true;
5346
+ const ta = activeTab.textarea || activeTab.term.textarea || activeTab.term.element?.querySelector('.xterm-textarea, textarea');
5067
5347
  if (ta) ta.disabled = true;
5068
- });
5348
+ }
5069
5349
  } else {
5070
5350
  scrollMode = false;
5071
5351
  cleanupScrollHandlers();
5072
5352
  const scrollBtn = document.getElementById('sel-scroll-btn');
5073
5353
  if (scrollBtn) scrollBtn.classList.remove('active-mode');
5074
5354
  shiftLatch = false; altLatch = false; updateModifierButtons();
5075
- tabs.forEach(t => {
5076
- if (!t.term) return;
5077
- t.term.options.disableStdin = false;
5078
- const ta = t.textarea || t.term.textarea || t.term.element?.querySelector('.xterm-textarea, textarea');
5355
+ if (activeTab?.term) {
5356
+ activeTab.term.options.disableStdin = false;
5357
+ const ta = activeTab.textarea || activeTab.term.textarea || activeTab.term.element?.querySelector('.xterm-textarea, textarea');
5079
5358
  if (ta) ta.disabled = false;
5080
- t.term.clearSelection();
5081
- });
5359
+ activeTab.term.clearSelection();
5360
+ }
5082
5361
  }
5083
5362
  }
5084
5363
 
@@ -5164,12 +5443,16 @@ function pasteToTerminal() {
5164
5443
  if (!text) return;
5165
5444
  const tab = getActiveTab();
5166
5445
  if (tab?.ws && tab.ws.readyState === WebSocket.OPEN) {
5167
- // Bracketed paste: wrap in escape sequences so the shell buffers the input
5446
+ // Bracketed paste: wrap in escape sequences so the shell buffers the input — chunk 60KB with loop
5168
5447
  const bracketed = '\x1b[200~' + text + '\x1b[201~';
5169
5448
  const enc = new TextEncoder().encode(bracketed);
5170
- const buf = new Uint8Array(1 + enc.length);
5171
- buf[0] = 0x00; buf.set(enc, 1);
5172
- tab.ws.send(buf.buffer);
5449
+ const CHUNK = 60 * 1024;
5450
+ for (let off = 0; off < enc.length; off += CHUNK) {
5451
+ const slice = enc.subarray(off, Math.min(off + CHUNK, enc.length));
5452
+ const buf = new Uint8Array(1 + slice.length);
5453
+ buf[0] = 0x00; buf.set(slice, 1);
5454
+ try { tab.ws.send(buf.buffer); } catch {}
5455
+ }
5173
5456
  tab.term?.focus();
5174
5457
  toast('Pasted to terminal', 'success');
5175
5458
  }
@@ -5191,14 +5474,24 @@ function fallbackPaste(callback) {
5191
5474
  document.body.appendChild(_pasteInput);
5192
5475
  _pasteInput.addEventListener('paste', () => {
5193
5476
  setTimeout(() => {
5194
- callback(_pasteInput.value);
5477
+ if (_pasteInput.value) {
5478
+ callback(_pasteInput.value);
5479
+ toast('Pasted to terminal', 'success');
5480
+ } else {
5481
+ toast('Paste failed — please use Ctrl+V', 'warning');
5482
+ }
5195
5483
  _pasteInput.value = '';
5196
5484
  }, 0);
5197
5485
  });
5198
5486
  }
5199
5487
  _pasteInput.value = '';
5200
5488
  _pasteInput.focus();
5201
- document.execCommand('paste');
5489
+ try {
5490
+ const ok = document.execCommand('paste');
5491
+ if (!ok) toast('Paste failed — please use Ctrl+V', 'warning');
5492
+ } catch {
5493
+ toast('Paste not supported — use Ctrl+V', 'warning');
5494
+ }
5202
5495
  }
5203
5496
 
5204
5497
  function selectAllTerm() {
@@ -5244,48 +5537,66 @@ function toggleCtrlRow() {
5244
5537
  }
5245
5538
 
5246
5539
  let _viewportHandlerInstalled = false;
5540
+ let _viewportDebounce = null;
5247
5541
  function setupVisualViewport() {
5248
5542
  if (_viewportHandlerInstalled) return;
5249
5543
  _viewportHandlerInstalled = true;
5250
5544
  if (window.visualViewport) {
5251
5545
  const handler = () => {
5252
- const diff = window.visualViewport.height - window.innerHeight;
5253
- const mobileKeys = document.getElementById('mobile-keys');
5254
- const selRow = document.getElementById('mkey-sel-row');
5255
- const terminals = document.getElementById('terminals');
5256
- if (!terminals) return;
5257
- if (diff < -50) {
5258
- terminals.style.marginBottom = Math.abs(diff) + 'px';
5259
- if (mobileKeys) mobileKeys.style.display = 'none';
5260
- const ctrlRow = document.getElementById('mkey-ctrl-row');
5261
- if (ctrlRow && ctrlRow.style.display === 'flex') ctrlRow.dataset.wasOpen = '1';
5262
- if (ctrlRow) ctrlRow.style.display = 'none';
5263
- if (selRow && selRow.style.display === 'flex') selRow.dataset.wasOpen = '1';
5264
- if (selRow) selRow.style.display = 'none';
5265
- } else {
5266
- terminals.style.marginBottom = '0';
5267
- if (mobileKeys && window.innerWidth <= 768 && settings.mobilekeys !== false) {
5268
- mobileKeys.style.display = 'flex';
5269
- }
5270
- if (window.innerWidth <= 768 && settings.mobilekeys !== false) {
5546
+ clearTimeout(_viewportDebounce);
5547
+ _viewportDebounce = setTimeout(() => {
5548
+ const vvH = window.visualViewport.height;
5549
+ const vvScale = window.visualViewport.scale || 1;
5550
+ // Samsung Internet: innerHeight resizes, not visualViewport. Use layout viewport height via window.innerHeight vs vvH
5551
+ // On iOS, keyboard shows as vvH < innerHeight. On Samsung, opposite. Take max diff and ignore when scaled (pinch zoom).
5552
+ if (vvScale !== 1) return; // ignore pinch-zoom (U61, U65)
5553
+ const diff = vvH - window.innerHeight;
5554
+ const absDiff = Math.abs(diff);
5555
+ const isKeyboard = absDiff > 50 && diff < 0; // iOS: vv smaller. For Samsung, also check visualViewport offsetTop
5556
+ // Fallback: check visualViewport offsetTop > 10 indicates keyboard on some Android
5557
+ const offsetTop = window.visualViewport.offsetTop || 0;
5558
+ const keyboardOpen = isKeyboard || offsetTop > 10;
5559
+ const mobileKeys = document.getElementById('mobile-keys');
5560
+ const selRow = document.getElementById('mkey-sel-row');
5561
+ const terminals = document.getElementById('terminals');
5562
+ if (!terminals) return;
5563
+ if (keyboardOpen) {
5564
+ terminals.style.marginBottom = absDiff + 'px';
5565
+ if (mobileKeys) mobileKeys.style.display = 'none';
5271
5566
  const ctrlRow = document.getElementById('mkey-ctrl-row');
5272
- if (ctrlRow && ctrlRow.dataset.wasOpen === '1') {
5273
- ctrlRow.style.display = 'flex';
5274
- delete ctrlRow.dataset.wasOpen;
5567
+ if (ctrlRow && ctrlRow.style.display === 'flex') ctrlRow.dataset.wasOpen = '1';
5568
+ if (ctrlRow) ctrlRow.style.display = 'none';
5569
+ if (selRow && selRow.style.display === 'flex') selRow.dataset.wasOpen = '1';
5570
+ if (selRow) selRow.style.display = 'none';
5571
+ } else {
5572
+ terminals.style.marginBottom = '0';
5573
+ if (mobileKeys && window.innerWidth <= 768 && settings.mobilekeys !== false) {
5574
+ mobileKeys.style.display = 'flex';
5275
5575
  }
5276
- }
5277
- if (selRow && window.innerWidth <= 768) {
5278
- if (selRow.dataset.wasOpen === '1') {
5279
- selRow.style.display = 'flex';
5280
- delete selRow.dataset.wasOpen;
5281
- } else {
5282
- selRow.style.display = termSelectMode ? 'flex' : 'none';
5576
+ if (window.innerWidth <= 768 && settings.mobilekeys !== false) {
5577
+ const ctrlRow = document.getElementById('mkey-ctrl-row');
5578
+ if (ctrlRow && ctrlRow.dataset.wasOpen === '1') {
5579
+ ctrlRow.style.display = 'flex';
5580
+ delete ctrlRow.dataset.wasOpen;
5581
+ }
5582
+ }
5583
+ if (selRow && window.innerWidth <= 768) {
5584
+ if (selRow.dataset.wasOpen === '1') {
5585
+ selRow.style.display = 'flex';
5586
+ delete selRow.dataset.wasOpen;
5587
+ } else {
5588
+ selRow.style.display = termSelectMode ? 'flex' : 'none';
5589
+ }
5283
5590
  }
5284
5591
  }
5285
- }
5592
+ }, 100);
5286
5593
  };
5287
5594
  window.visualViewport.addEventListener('resize', handler);
5288
- window._cleanups?.push(() => window.visualViewport.removeEventListener('resize', handler));
5595
+ window.visualViewport.addEventListener('scroll', handler);
5596
+ window._cleanups?.push(() => {
5597
+ window.visualViewport.removeEventListener('resize', handler);
5598
+ window.visualViewport.removeEventListener('scroll', handler);
5599
+ });
5289
5600
  }
5290
5601
  }
5291
5602
 
@@ -5362,8 +5673,23 @@ function setupDragDrop() {
5362
5673
  });
5363
5674
  document.addEventListener('dragleave', e => {
5364
5675
  if (!isFileDrag(e)) return;
5365
- dragCnt--;
5366
- 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');
5367
5693
  });
5368
5694
  document.addEventListener('dragover', e => {
5369
5695
  if (!isFileDrag(e)) return;
@@ -5440,8 +5766,10 @@ function closeOverlay(id) {
5440
5766
  lastFocusedElement = null;
5441
5767
  }
5442
5768
  }
5769
+ let _focusTrapContainer = null;
5443
5770
  function installFocusTrap(container) {
5444
5771
  removeFocusTrap();
5772
+ _focusTrapContainer = container;
5445
5773
  _focusTrapHandler = e => {
5446
5774
  if (e.key !== 'Tab') return;
5447
5775
  const focusable = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
@@ -5457,15 +5785,25 @@ function installFocusTrap(container) {
5457
5785
  container.addEventListener('keydown', _focusTrapHandler);
5458
5786
  }
5459
5787
  function removeFocusTrap() {
5460
- if (_focusTrapHandler) {
5788
+ if (_focusTrapHandler && _focusTrapContainer) {
5789
+ _focusTrapContainer.removeEventListener('keydown', _focusTrapHandler);
5790
+ _focusTrapHandler = null;
5791
+ _focusTrapContainer = null;
5792
+ } else if (_focusTrapHandler) {
5461
5793
  document.removeEventListener('keydown', _focusTrapHandler);
5462
5794
  _focusTrapHandler = null;
5463
5795
  }
5464
5796
  }
5465
5797
 
5466
5798
  let _confirmState = null;
5799
+ let _confirmQueue = [];
5467
5800
  function confirmDialog({ title = 'Confirm', message = '', okText = 'OK', cancelText = 'Cancel', danger = false } = {}) {
5468
5801
  return new Promise(resolve => {
5802
+ if (_confirmState) {
5803
+ // Queue second dialog until first settles (fixes race U51)
5804
+ _confirmQueue.push({ title, message, okText, cancelText, danger, resolve });
5805
+ return;
5806
+ }
5469
5807
  const ov = document.getElementById('confirm-overlay');
5470
5808
  if (!ov || !ov.parentNode) { resolve(false); return; }
5471
5809
  const okBtn = document.getElementById('confirm-ok-btn');
@@ -5486,8 +5824,14 @@ function confirmDialog({ title = 'Confirm', message = '', okText = 'OK', cancelT
5486
5824
  settled = true;
5487
5825
  _confirmState = null;
5488
5826
  ov.classList.remove('open');
5827
+ ov.removeEventListener('keydown', onKey);
5489
5828
  removeFocusTrap();
5490
5829
  if (lastFocusedElement) { setTimeout(() => lastFocusedElement?.focus(), 50); lastFocusedElement = null; }
5830
+ // Dequeue next pending dialog
5831
+ if (_confirmQueue.length) {
5832
+ const next = _confirmQueue.shift();
5833
+ setTimeout(() => confirmDialog(next).then(next.resolve), 80);
5834
+ }
5491
5835
  };
5492
5836
  const finish = val => { const cb = _confirmState && _confirmState.resolve; cleanup(); if (cb) cb(val); };
5493
5837
  const onKey = e => {
@@ -5517,8 +5861,8 @@ document.querySelectorAll('.overlay').forEach(o => {
5517
5861
 
5518
5862
  function setBtnBusy(btn, busy) {
5519
5863
  if (!btn) return;
5520
- if (busy) { btn.dataset.busy = 'true'; btn.classList.add('loading'); btn.disabled = true; }
5521
- else { btn.dataset.busy = 'false'; btn.classList.remove('loading'); btn.disabled = false; }
5864
+ if (busy) { btn.dataset.busy = 'true'; btn.classList.add('loading'); btn.disabled = true; btn.setAttribute('aria-busy','true'); }
5865
+ else { btn.dataset.busy = 'false'; btn.classList.remove('loading'); btn.disabled = false; btn.removeAttribute('aria-busy'); }
5522
5866
  }
5523
5867
 
5524
5868
  function showFieldError(id, msg) {
@@ -5829,15 +6173,25 @@ function saveCmdLib(cmds) {
5829
6173
  function toggleCmdLib() {
5830
6174
  const panel = document.getElementById('cmd-lib-panel');
5831
6175
  const isOpen = panel.classList.contains('open');
6176
+ // Only inert the terminals area (content), not sidebar (panel is on right side)
6177
+ const terms = document.getElementById('terminals');
5832
6178
  panel.classList.toggle('open');
6179
+ panel.setAttribute('role', 'dialog');
6180
+ panel.setAttribute('aria-modal', String(!isOpen));
5833
6181
  document.getElementById('cmd-lib-toggle').classList.toggle('active', !isOpen);
6182
+ document.getElementById('cmd-lib-toggle').setAttribute('aria-expanded', String(!isOpen));
5834
6183
  if (!isOpen) {
5835
6184
  loadHistMax();
5836
6185
  switchCmdTab('library');
6186
+ if (terms) terms.setAttribute('inert', '');
6187
+ installFocusTrap(panel);
5837
6188
  setTimeout(() => {
5838
6189
  document.addEventListener('click', closeCmdLibOnClickOutside, true);
5839
- }, 0);
6190
+ panel.querySelector('input, button')?.focus();
6191
+ }, 50);
5840
6192
  } else {
6193
+ if (terms) terms.removeAttribute('inert');
6194
+ removeFocusTrap();
5841
6195
  document.removeEventListener('click', closeCmdLibOnClickOutside, true);
5842
6196
  }
5843
6197
  }
@@ -5851,6 +6205,9 @@ function closeCmdLibOnClickOutside(e) {
5851
6205
  }
5852
6206
  if (panel.contains(e.target) || btn.contains(e.target)) return;
5853
6207
  toggleCmdLib();
6208
+ const terms = document.getElementById('terminals');
6209
+ if (terms) terms.removeAttribute('inert');
6210
+ removeFocusTrap();
5854
6211
  }
5855
6212
 
5856
6213
  function renderCmdLib(filter) {
@@ -5935,12 +6292,18 @@ function filterCmdLib() {
5935
6292
  renderCmdLib();
5936
6293
  }
5937
6294
 
5938
- function runCmdLib(cmd) {
6295
+ async function runCmdLib(cmd) {
5939
6296
  const tab = getActiveTab();
5940
6297
  if (!tab || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) {
5941
6298
  toast('No active terminal', 'error');
5942
6299
  return;
5943
6300
  }
6301
+ // RCE guard: curl | bash requires confirmation (U91)
6302
+ const needsConfirm = (typeof DEFAULT_CMDS !== 'undefined' && DEFAULT_CMDS.some(c => c.cmd === cmd && c.requiresConfirm)) || /\bcurl\b.*\|\s*bash/.test(cmd);
6303
+ if (needsConfirm) {
6304
+ const ok = await confirmDialog({ title: 'Confirm run', message: `Run sensitive command?\n\n${cmd}`, okText: 'Run', cancelText: 'Cancel', danger: true });
6305
+ if (!ok) return;
6306
+ }
5944
6307
  const text = cmd + '\n';
5945
6308
  const enc = new TextEncoder().encode(text);
5946
6309
  const buf = new Uint8Array(1 + enc.length);
@@ -6055,8 +6418,8 @@ function switchCmdTab(tab) {
6055
6418
  const footer = document.getElementById('cmd-lib-footer');
6056
6419
  const search = document.getElementById('cmd-lib-search');
6057
6420
  if (tab === 'library') {
6058
- libBtn.classList.add('active');
6059
- histBtn.classList.remove('active');
6421
+ libBtn.classList.add('active'); libBtn.setAttribute('aria-selected','true');
6422
+ histBtn.classList.remove('active'); histBtn.setAttribute('aria-selected','false');
6060
6423
  libList.style.display = '';
6061
6424
  histList.style.display = 'none';
6062
6425
  histHeader.style.display = 'none';
@@ -6065,9 +6428,10 @@ function switchCmdTab(tab) {
6065
6428
  search.oninput = () => filterCmdLib();
6066
6429
  filterCmdLib();
6067
6430
  } else {
6068
- histBtn.classList.add('active');
6069
- libBtn.classList.remove('active');
6431
+ histBtn.classList.add('active'); histBtn.setAttribute('aria-selected','true');
6432
+ libBtn.classList.remove('active'); libBtn.setAttribute('aria-selected','false');
6070
6433
  histList.style.display = 'flex';
6434
+ histList.setAttribute('aria-live','polite');
6071
6435
  libList.style.display = 'none';
6072
6436
  histHeader.style.display = '';
6073
6437
  footer.style.display = 'none';
@@ -6146,13 +6510,23 @@ function openSystemStats() {
6146
6510
  refreshSystemStats(true);
6147
6511
  clearInterval(sysStatsTimer);
6148
6512
  if (!settings.datasaver) {
6149
- sysStatsTimer = setInterval(() => refreshSystemStats(false), 5000);
6513
+ sysStatsTimer = setInterval(() => {
6514
+ if (document.hidden) return;
6515
+ refreshSystemStats(false);
6516
+ }, 5000);
6517
+ document.addEventListener('visibilitychange', _sysVisibilityHandler);
6518
+ }
6519
+ }
6520
+ function _sysVisibilityHandler() {
6521
+ if (document.hidden && sysStatsTimer) {
6522
+ // pause polling when hidden to save tunnel bandwidth (U93)
6150
6523
  }
6151
6524
  }
6152
6525
 
6153
6526
  function closeSystemStats() {
6154
6527
  clearInterval(sysStatsTimer);
6155
6528
  sysStatsTimer = null;
6529
+ document.removeEventListener('visibilitychange', _sysVisibilityHandler);
6156
6530
  closeOverlay('sys-overlay');
6157
6531
  }
6158
6532
 
@@ -6226,11 +6600,31 @@ async function refreshSystemStats(showLoading) {
6226
6600
  const addTd = (txt, extra) => { const td = document.createElement('td'); if (extra) td.style.cssText = extra; td.textContent = txt; tr.appendChild(td); };
6227
6601
  addTd(p.pid); addTd(p.user); addTd(p.cpu); addTd(p.mem);
6228
6602
  addTd(p.cmd, 'max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
6603
+ const killTd = document.createElement('td');
6604
+ killTd.style.textAlign = 'center';
6605
+ const killBtn = document.createElement('button');
6606
+ killBtn.className = 'btn btn-danger';
6607
+ killBtn.style.cssText = 'height:22px;padding:0 8px;font-size:11px;min-width:36px';
6608
+ killBtn.textContent = 'Kill';
6609
+ killBtn.setAttribute('aria-label', 'Kill process ' + p.pid);
6610
+ killBtn.title = 'Kill PID ' + p.pid;
6611
+ killBtn.onclick = () => killProcess(p.pid, p.cmd);
6612
+ killTd.appendChild(killBtn);
6613
+ tr.appendChild(killTd);
6229
6614
  tbody.appendChild(tr);
6230
6615
  });
6231
6616
  }
6232
6617
  }
6233
6618
 
6619
+ async function killProcess(pid, cmd) {
6620
+ const display = cmd ? `${pid} (${cmd.split('/').pop().slice(0,30)})` : String(pid);
6621
+ const ok = await confirmDialog({ title: 'Kill process', message: `Kill process ${display}?\n\nThis will send SIGTERM (then SIGKILL).`, okText: 'Kill', cancelText: 'Cancel', danger: true });
6622
+ if (!ok) return;
6623
+ const r = await api('/api/system/kill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pid: Number(pid) }) });
6624
+ if (r && r.success) { toast('Killed ' + pid, 'success'); refreshSystemStats(false); }
6625
+ else toast(r && r.error ? r.error : 'Kill failed', 'error');
6626
+ }
6627
+
6234
6628
  // ═══════════════════════════════════════════════════════
6235
6629
  // PWA / SERVICE WORKER
6236
6630
  // ═══════════════════════════════════════════════════════
@@ -6262,7 +6656,9 @@ function showInstallBanner(isIOS) {
6262
6656
  }
6263
6657
  const mk = document.getElementById('mobile-keys');
6264
6658
  if (mk && window.getComputedStyle(mk).display !== 'none') {
6265
- el.style.bottom = 'var(--mobilekey-h)';
6659
+ el.style.bottom = 'calc(var(--mobilekey-h) + env(safe-area-inset-bottom, 0px))';
6660
+ } else {
6661
+ el.style.bottom = 'env(safe-area-inset-bottom, 0px)';
6266
6662
  }
6267
6663
  el.style.display = 'flex';
6268
6664
  document.getElementById('toast-container').classList.add('banner-visible');