webtun 1.5.2 → 1.5.4

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,16 +196,18 @@ 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
+ #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; }
209
211
  #main { display: flex; flex: 1; overflow: hidden; min-height: 0; position: relative; }
210
212
  #sidebar { position: relative; width: var(--sidebar-w); background: var(--bg2); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; flex-shrink: 0; transition: var(--transition-normal); }
211
213
  #sidebar.dragging { transition: 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,17 @@ 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: fixed; inset: 0; z-index: var(--z-overlay); width: 100vw; height: 100vh; max-height: 100vh; 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: 100vh; }
274
+ #terminals.fullscreen { position: fixed; inset: 0; z-index: var(--z-overlay); width: 100vw; height: 100vh; max-height: 100vh; background: var(--bg); display: flex; flex-direction: column; }
275
+ #terminals.fullscreen .term-wrapper { flex: 1; min-height: 0; }
276
+ #terminals.fullscreen #search-bar { top: 8px; }
277
+ #content.terminal-fullscreen { position: fixed; inset: 0; z-index: var(--z-overlay); background: var(--bg); display: flex; flex-direction: column; }
278
+ #content.terminal-fullscreen #tab-bar { flex-shrink: 0; }
279
+ #content.terminal-fullscreen #editor-split-area { flex: 1; min-height: 0; }
280
+ #content.terminal-fullscreen #sidebar { display: none !important; }
266
281
 
267
282
  /* Desktop: split layout */
268
283
  @media (min-width: 769px) {
@@ -361,7 +376,7 @@ svg { display: block; }
361
376
  ═══════════════════════════════════════════════════════ */
362
377
  #logo { font-weight: 700; color: var(--accent); font-size: 15px; letter-spacing: 1px; user-select: none; padding: 0 4px; }
363
378
  .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); }
379
+ .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
380
  .icon-btn:hover { background: var(--bg4); color: var(--fg); }
366
381
  .icon-btn.active { color: var(--accent); }
367
382
 
@@ -384,7 +399,7 @@ svg { display: block; }
384
399
  .tunnel-inner { display: flex; align-items: center; gap: 5px; min-width: 0; }
385
400
  .tunnel-url { font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
401
  .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; }
402
+ .tunnel-btn { width: 28px; height: 28px; min-width: 28px; min-height: 28px; flex-shrink: 0; }
388
403
  .tunnel-btn.stop { color: var(--red); }
389
404
  #conn-status { transition: background var(--transition-slow); }
390
405
  .reconnect-spinner { display: inline-flex; vertical-align: -1px; margin-right: 4px; }
@@ -404,15 +419,17 @@ svg { display: block; }
404
419
  .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
420
  .tab:hover .tab-close, .tab.active .tab-close { opacity: 0.6; }
406
421
  .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; }
422
+ #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
423
  #new-tab-btn:hover { background: var(--bg4); color: var(--fg); }
409
424
 
410
425
  /* ═══════════════════════════════════════════════════════
411
426
  SIDEBAR / FILE EXPLORER
412
427
  ═══════════════════════════════════════════════════════ */
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; }
428
+ #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); }
429
+ #sidebar-header { flex-wrap: wrap; height: auto; }
430
+ #file-breadcrumb { white-space: nowrap; overflow-x: auto; line-height: 1; scrollbar-width: none; font-size: 11px; color: var(--fg2); }
431
+ #sidebar-header input { flex: 1 1 60px; min-width: 40px; height: 24px; font-size: 12px; border-radius: var(--radius-sm); }
432
+ #file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; position: relative; overscroll-behavior: contain; content-visibility: auto; contain-intrinsic-size: 600px; }
416
433
  #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
434
  #ptr-indicator .ptr-arrow { display: inline-flex; }
418
435
  #ptr-indicator .ptr-arrow svg { transition: transform 0.2s ease; }
@@ -420,28 +437,28 @@ svg { display: block; }
420
437
  #ptr-indicator.release .ptr-text { color: var(--accent); }
421
438
  /* Loading state: dim content + indeterminate progress bar */
422
439
  #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; }
440
+ #file-list-wrap.loading::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--bg3); z-index: 5; }
441
+ #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
442
  @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; }
443
+ .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
444
  .file-list-empty svg { opacity: 0.4; }
428
- .empty-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
445
+ .empty-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
429
446
  .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; }
447
+ #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
448
  #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; }
449
+ #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
450
  #file-breadcrumb a:hover { background: var(--bg3); }
434
451
  @media (pointer: coarse) {
435
452
  #file-breadcrumb { padding: 10px 12px; font-size: 13px; }
436
453
  #file-breadcrumb a { padding: 6px 8px !important; }
437
454
  }
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; }
455
+ .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
456
  .file-item:hover { background: var(--bg3); }
440
457
  .file-item.selected { background: var(--bg4); }
441
458
  .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; }
459
+ .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
460
  .file-item:hover .file-ellipsis { opacity: 1; }
444
- .file-icon { font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
461
+ .file-icon { font-size: 14px; width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
445
462
  .file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: text; -webkit-user-select: text; }
446
463
  .file-size { font-size: 10px; color: var(--fg2); flex-shrink: 0; user-select: text; -webkit-user-select: text; }
447
464
  .file-dir { color: var(--accent); }
@@ -458,14 +475,14 @@ svg { display: block; }
458
475
  #sidebar[data-select-mode="true"] .file-select-check { display: flex; }
459
476
  #sidebar[data-select-mode="true"] .file-item { padding-left: 6px; }
460
477
  .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; }
478
+ #sidebar-footer { padding: 4px 6px; border-top: 1px solid var(--border); display: flex; gap: 2px; flex-wrap: wrap; flex-shrink: 0; }
462
479
  .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
480
  .sidebar-action-btn:hover { background: var(--bg4); color: var(--fg); border-color: var(--accent); }
464
481
 
465
482
  /* Sidebar resize handle */
466
483
  .sidebar-resize-handle {
467
- position: absolute; right: 0; top: 0; bottom: 0;
468
- width: 5px; cursor: col-resize; z-index: var(--z-header);
484
+ position: absolute; right: -4px; top: 0; bottom: 0;
485
+ width: 12px; cursor: col-resize; z-index: var(--z-header);
469
486
  background: transparent; transition: background var(--transition-fast); flex-shrink: 0;
470
487
  }
471
488
  .sidebar-resize-handle:hover,
@@ -521,11 +538,12 @@ svg { display: block; }
521
538
  ═══════════════════════════════════════════════════════ */
522
539
  #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
540
  #search-bar.open { display: flex; }
541
+ #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
542
  #search-input { width: 200px; height: 26px; }
525
543
  @media (max-width: 480px) { #search-bar { left: 8px; right: 8px; } #search-input { flex: 1; width: auto; min-width: 0; } }
526
544
  .search-btn { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border-radius: 4px; color: var(--fg2); }
527
545
  .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; }
546
+ .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
547
  .search-btn#search-case-btn.active { background: var(--accent); color: var(--bg); }
530
548
  #search-results { font-size: 11px; color: var(--fg2); white-space: nowrap; }
531
549
 
@@ -565,13 +583,15 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
565
583
  .btn-danger:hover { filter: brightness(1.06); }
566
584
  .btn:not(.btn-primary):not(.btn-ghost):not(.btn-danger):hover { background: var(--bg4); }
567
585
  .btn:active { filter: brightness(0.95); }
568
- .btn.loading { opacity: 0.65; pointer-events: none; position: relative; color: transparent; }
586
+ .btn.loading { opacity: 0.85; pointer-events: none; position: relative; }
569
587
  .btn.loading::after {
570
588
  content: ''; position: absolute; inset: 0; margin: auto;
571
589
  width: 14px; height: 14px; border-radius: 50%;
572
590
  border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
573
591
  border-top-color: currentColor; animation: reconnectSpin 0.7s linear infinite;
574
592
  }
593
+ .btn.loading[aria-busy="true"] { color: inherit; }
594
+ .btn.loading[aria-busy="true"] > * { opacity: 0.15; }
575
595
  .field-error { display: none; color: var(--red); font-size: 11px; margin-top: 5px; line-height: 1.4; }
576
596
  .field-error.show { display: block; }
577
597
 
@@ -718,17 +738,17 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
718
738
  BOOKMARKS
719
739
  ═══════════════════════════════════════════════════════ */
720
740
  #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;
741
+ display: flex; align-items: center; gap: 3px;
742
+ padding: 2px 6px; font-size: 10px; color: var(--fg2);
743
+ text-transform: uppercase; letter-spacing: 0.3px;
724
744
  border-bottom: 1px solid var(--border); flex-shrink: 0; cursor: pointer;
725
745
  }
726
746
  #bookmarks-header:hover { color: var(--fg); }
727
747
  #bookmarks-list { max-height: 0; overflow: hidden; transition: max-height var(--transition-normal); flex-shrink: 0; }
728
748
  #bookmarks-list.open { max-height: 200px; overflow-y: auto; }
729
749
  .bookmark-item {
730
- display: flex; align-items: center; gap: 6px;
731
- padding: 3px 10px; font-size: 12px; cursor: pointer;
750
+ display: flex; align-items: center; gap: 3px;
751
+ padding: 1px 6px; font-size: 11px; cursor: pointer;
732
752
  border-bottom: 1px solid transparent;
733
753
  }
734
754
  .bookmark-item:hover { background: var(--bg3); }
@@ -918,7 +938,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
918
938
  #mobile-keys { padding: 4px 4px; gap: 2px; }
919
939
  .mkey { min-width: 38px; padding: 0 6px; font-size: 11px; }
920
940
  #header { padding: 0 4px; gap: 3px; }
921
- .icon-btn { width: 28px; height: 28px; }
941
+ .icon-btn { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
922
942
  #logo { font-size: 13px; }
923
943
  }
924
944
 
@@ -948,8 +968,9 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
948
968
 
949
969
  /* Landscape orientation on small screens */
950
970
  @media (max-width: 768px) and (orientation: landscape) {
951
- #sidebar { width: 50vw; max-width: 320px; }
971
+ #sidebar { width: 50vw; max-width: 320px; top: 32px; }
952
972
  #header { height: 32px; }
973
+ #settings-panel { top: 32px; }
953
974
  #mobile-keys { height: 36px; min-height: calc(36px + env(safe-area-inset-bottom)); }
954
975
  .mkey { height: 28px; min-width: 36px; font-size: 10px; }
955
976
  .tab { height: 32px; font-size: 11px; }
@@ -966,14 +987,19 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
966
987
  .terminal { background: #fff !important; color: #000 !important; }
967
988
  #file-breadcrumb, #file-list-wrap { overflow: visible !important; }
968
989
  }
990
+ @supports (width: env(titlebar-area-width, 100%)) {
991
+ #header { padding-left: env(titlebar-area-x, 0px); width: env(titlebar-area-width, 100%); }
992
+ }
969
993
  #install-banner { display: none; }
970
994
  @media (prefers-reduced-motion: reduce) {
971
- *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
995
+ *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; animation-delay: 0ms !important; }
996
+ .overlay { backdrop-filter: none !important; }
997
+ #sidebar, #settings-panel, .modal { transition-duration: 0.01ms !important; }
972
998
  }
973
999
  </style>
974
1000
  </head>
975
1001
  <body data-theme="light">
976
- <a class="skip-link" href="#content">Skip to content</a>
1002
+ <a class="skip-link" href="#content" onclick="document.getElementById('content')?.setAttribute('tabindex','-1');document.getElementById('content')?.focus();">Skip to content</a>
977
1003
  <script>
978
1004
  // Safe localStorage wrapper — degrades gracefully when Tracking Prevention blocks storage
979
1005
  const safeStorage = {
@@ -998,7 +1024,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
998
1024
  <div id="pin-box">
999
1025
  <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
1026
  <p>Enter PIN to continue</p>
1001
- <input id="pin-input" type="password" placeholder="••••" autocomplete="off" maxlength="32" inputmode="numeric" aria-label="PIN code">
1027
+ <input id="pin-input" type="password" placeholder="••••" autocomplete="off" maxlength="32" inputmode="text" aria-label="PIN code">
1002
1028
  <div id="pin-error" role="alert"></div>
1003
1029
  <div style="margin-top:12px">
1004
1030
  <button class="btn btn-primary" onclick="submitPin()">Unlock</button>
@@ -1035,6 +1061,9 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1035
1061
  <button class="icon-btn" id="tilesBtn" aria-label="Tiles Mode" title="Tiles Mode" onclick="toggleTiles()">
1036
1062
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
1037
1063
  </button>
1064
+ <button class="icon-btn" id="term-fullscreen-btn" aria-label="Terminal fullscreen" title="Terminal fullscreen (Shift+F11)" onclick="toggleTerminalFullscreen()">
1065
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
1066
+ </button>
1038
1067
  <button class="icon-btn" id="more-btn" aria-label="More options" title="More options" onclick="toggleMoreMenu()">
1039
1068
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>
1040
1069
  </button>
@@ -1048,7 +1077,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1048
1077
  <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
1078
  <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
1079
  </div>
1051
- <div id="upload-progress"><div id="upload-progress-bar"></div><span id="upload-progress-text"></span></div>
1080
+ <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
1081
  </div>
1053
1082
  <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
1083
 
@@ -1085,14 +1114,14 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1085
1114
  </div>
1086
1115
  <div id="file-list"></div>
1087
1116
  </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">
1117
+ <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
1118
  <span id="select-count" style="font-size:11px;color:var(--fg2);line-height:28px;flex:1"></span>
1090
1119
  <button class="sidebar-action-btn" id="select-all-btn" onclick="selectAllFiles()">Select All</button>
1091
1120
  <button class="sidebar-action-btn" onclick="deleteSelected()">Delete</button>
1092
1121
  <button class="sidebar-action-btn" onclick="downloadSelected()">Download</button>
1093
1122
  <button class="sidebar-action-btn" onclick="clearSelection()">Cancel</button>
1094
1123
  </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>
1124
+ <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
1125
  <div id="bookmarks-list"></div>
1097
1126
  <div id="sidebar-footer">
1098
1127
  <button class="sidebar-action-btn" onclick="bookmarkCurrentDir()" title="Bookmark current directory">
@@ -1120,12 +1149,13 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1120
1149
  </div>
1121
1150
 
1122
1151
  <!-- Content -->
1123
- <div id="content" role="main">
1152
+ <div id="content" role="main" tabindex="-1">
1124
1153
  <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>
1154
+ <div id="tab-scroll">
1155
+ <button id="new-tab-btn" onclick="newTab()" title="New terminal" aria-label="New terminal">
1156
+ <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>
1157
+ </button>
1158
+ </div>
1129
1159
  </div>
1130
1160
  <div id="editor-split-area">
1131
1161
  <div id="terminals">
@@ -1138,7 +1168,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1138
1168
  <button class="search-btn" title="Next (Enter)" onclick="searchNext()">
1139
1169
  <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
1170
  </button>
1141
- <button class="search-btn" id="search-case-btn" title="Match Case" onclick="toggleSearchCase()">
1171
+ <button class="search-btn" id="search-case-btn" title="Match Case" aria-pressed="false" onclick="toggleSearchCase()">
1142
1172
  Aa
1143
1173
  </button>
1144
1174
  <button class="search-btn" title="Close (Esc)" onclick="closeSearch()">
@@ -1158,9 +1188,9 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1158
1188
  <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
1189
  </button>
1160
1190
  </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>
1191
+ <div class="cmd-lib-tabs" role="tablist">
1192
+ <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>
1193
+ <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
1194
  </div>
1165
1195
  <input id="cmd-lib-search" type="text" placeholder="Filter commands…" oninput="filterCmdLib()">
1166
1196
  <div id="cmd-lib-list"></div>
@@ -1187,7 +1217,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1187
1217
  <button class="icon-btn" aria-label="Close editor" onclick="closeEditor()" title="Close editor">
1188
1218
  <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
1219
  </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>
1220
+ <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
1221
  <span id="editor-status"></span>
1192
1222
  <button class="btn" id="md-preview-toggle" style="height:26px;padding:0 12px;font-size:12px;display:none" onclick="toggleMdPreview()">Preview</button>
1193
1223
  <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 +1226,14 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1196
1226
  <button class="icon-btn active" id="editor-split-toggle" aria-label="Toggle split orientation" onclick="toggleEditorSplit()" title="Switch to horizontal split">
1197
1227
  <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
1228
  </button>
1229
+ <button class="icon-btn" id="editor-fullscreen-toggle" aria-label="Fullscreen" title="Fullscreen (F11)" onclick="toggleEditorFullscreen()">
1230
+ <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>
1231
+ </button>
1199
1232
  <button class="btn btn-primary" id="editor-save-btn" style="height:26px;padding:0 12px;font-size:12px" onclick="saveFile()">Save</button>
1200
1233
  </div>
1201
1234
  <div id="editor-area">
1202
1235
  <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>
1236
+ <div id="editor-preview"><iframe id="editor-preview-iframe" sandbox="allow-scripts"></iframe></div>
1204
1237
  </div>
1205
1238
  </div>
1206
1239
  </div>
@@ -1521,7 +1554,7 @@ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-si
1521
1554
  <h3>Top Processes by CPU</h3>
1522
1555
  <div class="sys-table-wrap">
1523
1556
  <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>
1557
+ <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
1558
  <tbody id="sys-proc-body"></tbody>
1526
1559
  </table>
1527
1560
  </div>
@@ -1703,7 +1736,18 @@ function loadTabState() {
1703
1736
  } catch(e) { console.warn('loadTabState error:', e); return []; }
1704
1737
  }
1705
1738
 
1706
- // ═══════════════════════════════════════════════════════
1739
+ function saveCurrentPath() {
1740
+ try { safeStorage.setItem('wt-current-path', currentPath); } catch(e) { console.warn('Failed to save path:', e); }
1741
+ }
1742
+
1743
+ function loadCurrentPath() {
1744
+ try {
1745
+ const val = safeStorage.getItem('wt-current-path');
1746
+ return typeof val === 'string' && val ? val : null;
1747
+ } catch(e) { console.warn('loadCurrentPath error:', e); return null; }
1748
+ }
1749
+
1750
+ // ════════════════════════════════════════════════════════
1707
1751
  // INIT
1708
1752
  // ═══════════════════════════════════════════════════════
1709
1753
  function handlePinKeydown(e) {
@@ -1770,10 +1814,22 @@ async function unlockApp() {
1770
1814
 
1771
1815
  const info = await api('/api/home');
1772
1816
  homeDir = info.home;
1773
- currentPath = homeDir;
1774
1817
  serverPlatform = info.platform || '';
1775
1818
  document.getElementById('hostname-badge').textContent = info.hostname || '—';
1776
1819
 
1820
+ // Restore last visited path
1821
+ const savedPath = loadCurrentPath();
1822
+ if (savedPath) {
1823
+ try {
1824
+ // Validate saved path is within workspace or full fs is allowed
1825
+ currentPath = savedPath;
1826
+ } catch(e) {
1827
+ currentPath = homeDir;
1828
+ }
1829
+ } else {
1830
+ currentPath = homeDir;
1831
+ }
1832
+
1777
1833
  // Check tmux availability
1778
1834
  const sessInfo = await api('/api/sessions');
1779
1835
  hasTmux = sessInfo.tmux === true;
@@ -1980,30 +2036,36 @@ function setupTabInlineRename(tabTitleSpan, tab) {
1980
2036
  }
1981
2037
 
1982
2038
  function setupTabSwipeGesture(tabEl, id) {
1983
- let swipeStartX = 0;
2039
+ let swipeStartX = 0, swipeStartY = 0;
1984
2040
  let swipeStartScroll = 0;
1985
2041
  tabEl.addEventListener('touchstart', e => {
1986
2042
  swipeStartX = e.touches[0].clientX;
2043
+ swipeStartY = e.touches[0].clientY;
1987
2044
  swipeStartScroll = tabEl.parentElement.scrollLeft;
1988
2045
  }, { passive: true });
1989
2046
  tabEl.addEventListener('touchmove', e => {
1990
2047
  if (swipeStartX === 0) return;
1991
2048
  if (tabEl.parentElement.scrollLeft !== swipeStartScroll) { swipeStartX = 0; return; }
1992
2049
  const dx = e.touches[0].clientX - swipeStartX;
1993
- if (Math.abs(dx) > 2) {
2050
+ const dy = e.touches[0].clientY - swipeStartY;
2051
+ // Require horizontal swipe dominant (dx > dy*1.5) to avoid scroll confusion (U59)
2052
+ if (Math.abs(dx) < Math.abs(dy) * 1.5) return;
2053
+ if (Math.abs(dx) > 5) {
2054
+ e.preventDefault();
1994
2055
  tabEl.style.transform = `translateX(${dx}px)`;
1995
2056
  tabEl.style.opacity = Math.max(0.3, 1 - Math.abs(dx) / 200);
1996
2057
  tabEl.style.background = `rgba(247,118,142,${Math.min(Math.abs(dx) / 80, 1) * 0.2})`;
1997
2058
  }
1998
- }, { passive: true });
2059
+ }, { passive: false });
1999
2060
  tabEl.addEventListener('touchend', e => {
2000
2061
  if (swipeStartX === 0) return;
2001
2062
  const dx = e.changedTouches[0].clientX - swipeStartX;
2063
+ const dy = e.changedTouches[0].clientY - swipeStartY;
2002
2064
  tabEl.style.transform = '';
2003
2065
  tabEl.style.opacity = '';
2004
2066
  tabEl.style.background = '';
2005
- if (Math.abs(dx) > 80) closeTab({ stopPropagation() {} }, id);
2006
- swipeStartX = 0;
2067
+ if (Math.abs(dx) > 90 && Math.abs(dx) > Math.abs(dy) * 1.5) closeTab({ stopPropagation() {} }, id);
2068
+ swipeStartX = 0; swipeStartY = 0;
2007
2069
  }, { passive: true });
2008
2070
  }
2009
2071
 
@@ -2045,7 +2107,10 @@ function createTabButton(tab) {
2045
2107
  setupTabInlineRename(titleSpan, tab);
2046
2108
  setupTabSwipeGesture(tabEl, id);
2047
2109
 
2048
- document.getElementById('tab-scroll').appendChild(tabEl);
2110
+ const scroll = document.getElementById('tab-scroll');
2111
+ const newBtn = document.getElementById('new-tab-btn');
2112
+ if (newBtn && newBtn.parentElement === scroll) scroll.insertBefore(tabEl, newBtn);
2113
+ else scroll.appendChild(tabEl);
2049
2114
  tab.el = tabEl;
2050
2115
  }
2051
2116
 
@@ -2068,7 +2133,7 @@ function hideTermLoading(tab) {
2068
2133
 
2069
2134
  function newTab(title, sessionId, dir) {
2070
2135
  const id = ++tabCounter;
2071
- const sid = sessionId || (hasTmux ? uuid() : null);
2136
+ const sid = sessionId || uuid();
2072
2137
  const tab = { id, sessionId: sid, title: title || `Term ${nextTermNumber()}`, term: null, fitAddon: null, searchAddon: null, ws: null, el: null, wrapper: null, closed: false, reconnectDelay: 1000, dataDisposable: null, resizeDisposable: null, resizeObserver: null, cwd: dir || currentPath };
2073
2138
  tabs.push(tab);
2074
2139
  createTabButton(tab);
@@ -2278,19 +2343,21 @@ function connectWebSocket(tab, isReconnect = false) {
2278
2343
  if (tab.closed) return;
2279
2344
  updateConnStatus(false);
2280
2345
  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';
2346
+ const _attempt = tab.reconnectAttempts;
2347
+ const _maxAttempts = 10;
2348
+ 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>';
2349
+ const banner = document.getElementById('reconnect-banner');
2350
+ if (banner) {
2351
+ if (_attempt >= 3) {
2352
+ 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>`;
2353
+ } else {
2354
+ banner.innerHTML = `${spinnerHtml} Reconnecting (${_attempt}/${_maxAttempts})…`;
2286
2355
  }
2287
- } else {
2288
- const banner = document.getElementById('reconnect-banner');
2289
- if (banner) banner.style.display = 'block';
2356
+ banner.style.display = 'block';
2290
2357
  }
2291
2358
  tab.reconnectDelay = Math.min((tab.reconnectDelay || 1000) * 2, 15000);
2292
2359
  const secs = tab.reconnectDelay / 1000;
2293
- tab.term.writeln(`\r\n\x1b[33m[Disconnected — reconnecting in ${secs}s…]\x1b[0m`);
2360
+ tab.term.writeln(`\r\n\x1b[33m[Disconnected — reconnecting in ${secs}s (attempt ${_attempt}/${_maxAttempts})…]\x1b[0m`);
2294
2361
  tab.reconnectTimer = setTimeout(() => {
2295
2362
  if (!tab.closed) connectWebSocket(tab, true);
2296
2363
  }, tab.reconnectDelay);
@@ -2305,7 +2372,7 @@ function connectWebSocket(tab, isReconnect = false) {
2305
2372
  function manualReconnect() {
2306
2373
  const banner = document.getElementById('reconnect-banner');
2307
2374
  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…';
2375
+ 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
2376
  banner.style.display = 'block';
2310
2377
  }
2311
2378
  tabs.forEach(tab => {
@@ -2348,7 +2415,7 @@ async function closeTab(e, id) {
2348
2415
  tab.term?.dispose();
2349
2416
  tab.el?.remove();
2350
2417
  tab.wrapper?.remove();
2351
- // Kill the backing tmux session so it doesn't pile up
2418
+ // Kill the backing session (tmux or in-memory) so it doesn't pile up
2352
2419
  if (tab.sessionId) api(`/api/sessions/${tab.sessionId}`, { method: 'DELETE' });
2353
2420
  tabs = tabs.filter(t => t.id !== id);
2354
2421
  saveTabState();
@@ -2389,12 +2456,15 @@ function initTerminal(tab) {
2389
2456
  term.unicode.activeVersion = '11';
2390
2457
  } catch (_) {}
2391
2458
 
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 (_) {}
2459
+ // GPU-accelerated renderer — falls back to canvas if WebGL unavailable (skip if >4 tabs to guard memory)
2460
+ if (tabs.filter(t => t.term).length < 4) {
2461
+ try {
2462
+ const webglAddon = new WebglAddon.WebglAddon();
2463
+ webglAddon.onContextLoss(() => { try { webglAddon.dispose(); } catch {} try { term.element?.querySelector('canvas')?.remove(); } catch {} });
2464
+ term.loadAddon(webglAddon);
2465
+ tab._webglAddon = webglAddon;
2466
+ } catch (_) {}
2467
+ }
2398
2468
 
2399
2469
  term.open(tab.wrapper);
2400
2470
 
@@ -2408,7 +2478,7 @@ function initTerminal(tab) {
2408
2478
  tab.textarea.name = 'terminal-input';
2409
2479
  }
2410
2480
 
2411
- // Intercept paste events to use bracketed paste mode (required for TUI apps like vim, nano, mc)
2481
+ // Intercept paste events to use bracketed paste mode (required for TUI apps like vim, nano, mc) — chunk 60KB with loop
2412
2482
  if (tab.textarea) {
2413
2483
  tab.textarea.addEventListener('paste', e => {
2414
2484
  e.preventDefault();
@@ -2417,9 +2487,13 @@ function initTerminal(tab) {
2417
2487
  tab._lastPasteText = text;
2418
2488
  const bracketed = '\x1b[200~' + text + '\x1b[201~';
2419
2489
  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);
2490
+ const CHUNK = 60 * 1024;
2491
+ for (let off = 0; off < enc.length; off += CHUNK) {
2492
+ const slice = enc.subarray(off, Math.min(off + CHUNK, enc.length));
2493
+ const buf = new Uint8Array(1 + slice.length);
2494
+ buf[0] = 0x00; buf.set(slice, 1);
2495
+ try { tab.ws.send(buf.buffer); } catch {}
2496
+ }
2423
2497
  }, true);
2424
2498
  }
2425
2499
 
@@ -2436,15 +2510,18 @@ function initTerminal(tab) {
2436
2510
  tab.el.querySelector('.tab-title').textContent = tab.title;
2437
2511
  });
2438
2512
 
2439
- // Bell: visual flash + desktop notification for background tabs
2513
+ // Bell: visual flash + desktop notification for background tabs (also flash parent for WebGL)
2440
2514
  term.onBell(() => {
2441
- // Visual flash on terminal screen
2515
+ // Visual flash on terminal screen and parent (WebGL uses canvas, so also flash parent)
2442
2516
  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
- }
2517
+ const parent = term.element;
2518
+ [screen, parent].forEach(el => {
2519
+ if (!el) return;
2520
+ el.classList.remove('term-bell-flash');
2521
+ void el.offsetWidth; // reflow to restart animation
2522
+ el.classList.add('term-bell-flash');
2523
+ setTimeout(() => el.classList.remove('term-bell-flash'), 150);
2524
+ });
2448
2525
  // Desktop notification if tab is in the background
2449
2526
  if (document.hidden && settings.bell && Notification.permission === 'granted') {
2450
2527
  try {
@@ -2543,27 +2620,42 @@ function initTerminal(tab) {
2543
2620
  if (!tab.closed) connectWebSocket(tab, false);
2544
2621
  });
2545
2622
 
2546
- // Scroll terminal on wheel
2623
+ // Scroll terminal on wheel — with try/catch fallback for isMouseTracking
2547
2624
  tab.wrapper.addEventListener('wheel', e => {
2548
2625
  if (e.ctrlKey || e.metaKey) return;
2549
2626
  e.preventDefault();
2550
2627
  // TUI apps with mouse tracking handle scroll themselves — forward the event to xterm.js
2628
+ let isTracking = false;
2551
2629
  try {
2552
2630
  const core = term._core;
2553
- if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive()) {
2631
+ if (core?.coreMouseService?.isMouseTrackingActive?.()) isTracking = core.coreMouseService.isMouseTrackingActive();
2632
+ // fallback check via private API may throw across xterm versions
2633
+ } catch (_) { isTracking = false; }
2634
+ if (isTracking) {
2635
+ try {
2554
2636
  const vp = tab.wrapper.querySelector('.xterm-viewport');
2555
- if (vp) vp.dispatchEvent(new WheelEvent('wheel', e));
2637
+ 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
2638
  return;
2557
- }
2558
- } catch (_) {}
2639
+ } catch (_) { /* fallback to manual scroll */ }
2640
+ }
2559
2641
  const atTop = term.buffer.active.baseY === 0;
2560
2642
  const atBottom = term.buffer.active.baseY + term.rows >= term.buffer.active.length;
2561
2643
  if (tilesMode && ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom))) return;
2562
2644
  let delta = e.deltaY;
2563
2645
  if (e.deltaMode === 1) delta *= 16;
2564
2646
  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;
2647
+ let lineHeight = 16;
2648
+ try {
2649
+ const d = term._core?.viewportRenderer?.dimensions;
2650
+ if (d?.actualCellHeight) lineHeight = d.actualCellHeight;
2651
+ else {
2652
+ const rowsEl = tab.wrapper.querySelector('.xterm-rows');
2653
+ lineHeight = rowsEl && rowsEl.children[0] ? rowsEl.children[0].getBoundingClientRect().height || 16 : 16;
2654
+ }
2655
+ } catch (_) {
2656
+ const rowsEl = tab.wrapper.querySelector('.xterm-rows');
2657
+ lineHeight = rowsEl && rowsEl.children[0] ? rowsEl.children[0].getBoundingClientRect().height || 16 : 16;
2658
+ }
2567
2659
  const lines = Math.sign(delta) * Math.max(1, Math.round(Math.abs(delta) / lineHeight));
2568
2660
  term.scrollLines(lines);
2569
2661
  }, { passive: false });
@@ -2720,19 +2812,32 @@ function layoutTiles() {
2720
2812
  }
2721
2813
  hdr = document.createElement('div');
2722
2814
  hdr.className = 'term-tile-header';
2815
+ hdr.setAttribute('tabindex', '0');
2816
+ hdr.setAttribute('role', 'button');
2817
+ hdr.setAttribute('aria-label', 'Activate ' + tab.title);
2723
2818
  const tthName = document.createElement('span');
2724
2819
  tthName.className = 'tth-name';
2820
+ tthName.setAttribute('tabindex', '0');
2821
+ tthName.setAttribute('role', 'button');
2822
+ tthName.setAttribute('aria-label', 'Focus terminal ' + tab.title);
2725
2823
  tthName.textContent = tab.title;
2726
2824
  hdr.appendChild(tthName);
2727
2825
  const tthClose = document.createElement('button');
2728
2826
  tthClose.className = 'tth-close';
2729
2827
  tthClose.setAttribute('aria-label', 'Close tab');
2828
+ tthClose.setAttribute('tabindex', '0');
2730
2829
  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
2830
  hdr.appendChild(tthClose);
2732
2831
  hdr.addEventListener('click', e => {
2733
2832
  if (e.target.closest('.tth-close')) return;
2734
2833
  activateTab(tab.id);
2735
2834
  });
2835
+ hdr.addEventListener('keydown', e => {
2836
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activateTab(tab.id); }
2837
+ });
2838
+ tthName.addEventListener('keydown', e => {
2839
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); activateTab(tab.id); tab.term?.focus(); }
2840
+ });
2736
2841
  hdr.querySelector('.tth-close').addEventListener('click', e => {
2737
2842
  e.stopPropagation();
2738
2843
  closeTab(e, tab.id);
@@ -2805,6 +2910,7 @@ async function loadFiles(dir) {
2805
2910
  currentPath = dir;
2806
2911
  document.getElementById('path-input').value = dir;
2807
2912
  renderBreadcrumb(dir);
2913
+ saveCurrentPath();
2808
2914
 
2809
2915
  const list = document.getElementById('file-list');
2810
2916
 
@@ -3134,13 +3240,13 @@ function renderBreadcrumb(fullPath) {
3134
3240
  }
3135
3241
 
3136
3242
  if (!segments.length) {
3137
- html = `<span style="color:var(--fg2)">\\</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
3243
+ html = `<span style="color:var(--fg2)">\\</span><span style="color:var(--fg2);font-size:11px;margin-left:3px">(root)</span>`;
3138
3244
  } else {
3139
3245
  html = segments.map((seg, i) => {
3140
3246
  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>`;
3247
+ 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>`;
3248
+ 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>` +
3249
+ `<span style="color:var(--fg2);margin:0 2px;display:inline-flex;align-items:center">\\</span>`;
3144
3250
  }).join('');
3145
3251
  }
3146
3252
  } else {
@@ -3150,10 +3256,10 @@ function renderBreadcrumb(fullPath) {
3150
3256
  accumulated += '/' + part;
3151
3257
  const isLast = part === parts[parts.length - 1];
3152
3258
  if (isLast) {
3153
- html += `<span style="color:var(--fg);font-weight:500">${escHtml(part)}</span>`;
3259
+ html += `<span style="color:var(--fg);font-weight:500;display:inline-flex;align-items:center;height:24px;padding:2px 4px">${escHtml(part)}</span>`;
3154
3260
  } 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>`;
3261
+ 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>`;
3262
+ html += `<span style="color:var(--fg2);margin:0 2px;display:inline-flex;align-items:center">/</span>`;
3157
3263
  }
3158
3264
  }
3159
3265
  if (!parts.length) {
@@ -3161,7 +3267,7 @@ function renderBreadcrumb(fullPath) {
3161
3267
  }
3162
3268
  }
3163
3269
 
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;
3270
+ 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
3271
 
3166
3272
  if (!el.dataset.delegated) {
3167
3273
  el.addEventListener('click', e => {
@@ -3763,6 +3869,8 @@ async function handleUploadInput() {
3763
3869
  }
3764
3870
 
3765
3871
  let _uploadProgressTimeout = null;
3872
+ let _currentUploadXhr = null;
3873
+ let _uploadCancelled = false;
3766
3874
  function setUploadProgress(pct, status, text) {
3767
3875
  const bar = document.getElementById('upload-progress');
3768
3876
  const barFill = document.getElementById('upload-progress-bar');
@@ -3783,19 +3891,33 @@ function clearUploadProgress(delay = 1500) {
3783
3891
  bar.classList.remove('active', 'success', 'error');
3784
3892
  bar.querySelector('#upload-progress-bar').style.width = '0%';
3785
3893
  bar.querySelector('#upload-progress-text').textContent = '';
3894
+ _currentUploadXhr = null;
3895
+ _uploadCancelled = false;
3786
3896
  }
3787
3897
  }, delay);
3788
3898
  }
3899
+ function cancelUpload() {
3900
+ _uploadCancelled = true;
3901
+ if (_currentUploadXhr) {
3902
+ try { _currentUploadXhr.abort(); } catch {}
3903
+ _currentUploadXhr = null;
3904
+ }
3905
+ setUploadProgress(100, 'error', 'Upload cancelled');
3906
+ clearUploadProgress(1500);
3907
+ toast('Upload cancelled', 'warning');
3908
+ }
3789
3909
 
3790
3910
  async function uploadFileList(items) {
3791
3911
  const path = encodeURIComponent(currentPath);
3792
3912
  const total = items.length;
3793
3913
  let completed = 0;
3794
3914
  let failedItems = [];
3915
+ _uploadCancelled = false;
3795
3916
 
3796
3917
  setUploadProgress(0, '', `0/${total}`);
3797
3918
 
3798
3919
  for (const item of items) {
3920
+ if (_uploadCancelled) break;
3799
3921
  const file = item.file || item;
3800
3922
  const fileName = item.path || item.webkitRelativePath || item.name;
3801
3923
  const formData = new FormData();
@@ -3804,6 +3926,7 @@ async function uploadFileList(items) {
3804
3926
  try {
3805
3927
  await new Promise((resolve, reject) => {
3806
3928
  const xhr = new XMLHttpRequest();
3929
+ _currentUploadXhr = xhr;
3807
3930
  xhr.open('POST', `/api/files/upload?path=${path}`);
3808
3931
  xhr.setRequestHeader('x-pin-token', authToken);
3809
3932
 
@@ -3816,6 +3939,7 @@ async function uploadFileList(items) {
3816
3939
  });
3817
3940
 
3818
3941
  xhr.onload = () => {
3942
+ _currentUploadXhr = null;
3819
3943
  if (xhr.status >= 200 && xhr.status < 300) {
3820
3944
  try {
3821
3945
  const resp = JSON.parse(xhr.responseText);
@@ -3839,26 +3963,44 @@ async function uploadFileList(items) {
3839
3963
  };
3840
3964
 
3841
3965
  xhr.onerror = () => {
3966
+ _currentUploadXhr = null;
3967
+ if (_uploadCancelled) { reject(new Error('Cancelled')); return; }
3842
3968
  failedItems.push(fileName);
3843
3969
  reject(new Error('Network error'));
3844
3970
  };
3971
+ xhr.onabort = () => {
3972
+ _currentUploadXhr = null;
3973
+ reject(new Error('Cancelled'));
3974
+ };
3845
3975
  xhr.send(formData);
3846
3976
  });
3847
3977
  } catch (e) {
3978
+ if (_uploadCancelled) break;
3848
3979
  console.warn('Upload item failed:', e);
3849
3980
  }
3850
3981
  }
3851
3982
 
3852
- // Final result
3983
+ _currentUploadXhr = null;
3984
+ // Final result — keep error visible longer (no auto-clear 1500ms for errors)
3985
+ if (_uploadCancelled) {
3986
+ // already handled in cancelUpload
3987
+ refreshFiles();
3988
+ return;
3989
+ }
3853
3990
  if (failedItems.length === 0) {
3854
3991
  setUploadProgress(100, 'success', `${completed}/${total} uploaded`);
3992
+ if (failedItems.length) toast(`${failedItems.length} failed`, 'error');
3993
+ clearUploadProgress(2000);
3855
3994
  } else if (completed > 0) {
3856
3995
  setUploadProgress(100, 'error', `${completed} ok, ${failedItems.length} failed`);
3996
+ toast(`${failedItems.length} failed`, 'error');
3997
+ clearUploadProgress(3000);
3857
3998
  } else {
3858
3999
  setUploadProgress(100, 'error', `All ${total} failed`);
4000
+ toast(`All ${total} failed`, 'error');
4001
+ clearUploadProgress(3000);
3859
4002
  }
3860
4003
 
3861
- clearUploadProgress(2000);
3862
4004
  refreshFiles();
3863
4005
  }
3864
4006
 
@@ -3890,9 +4032,27 @@ function initCodeMirror() {
3890
4032
  },
3891
4033
  }
3892
4034
  });
3893
- editor.on('change', () => updateEditorDirty());
4035
+ editor.on('change', () => { updateEditorDirty(); autoSaveDraft(); });
3894
4036
  return editor;
3895
4037
  }
4038
+ let _autoSaveTimer = null;
4039
+ function autoSaveDraft() {
4040
+ clearTimeout(_autoSaveTimer);
4041
+ _autoSaveTimer = setTimeout(() => {
4042
+ try {
4043
+ if (!editorPath) return;
4044
+ const content = editor ? editor.getValue() : '';
4045
+ if (content !== editorOriginalContent) {
4046
+ safeStorage.setItem('wt-draft:' + editorPath, content);
4047
+ } else {
4048
+ safeStorage.removeItem('wt-draft:' + editorPath);
4049
+ }
4050
+ } catch {}
4051
+ }, 2000);
4052
+ }
4053
+ function loadDraft(p) {
4054
+ try { return safeStorage.getItem('wt-draft:' + p); } catch { return null; }
4055
+ }
3896
4056
 
3897
4057
  function updateEditorDirty() {
3898
4058
  const el = document.getElementById('editor-view');
@@ -3905,8 +4065,11 @@ function getCMmode(fileName) {
3905
4065
  const m = fileName.match(/\.([^.]+)$/);
3906
4066
  if (!m) return null;
3907
4067
  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};
4068
+ case 'js': return 'javascript';
4069
+ case 'jsx': return {name: 'javascript', json: false, jsx: true};
4070
+ case 'mjs': case 'cjs': return 'javascript';
4071
+ case 'ts': return {name: 'javascript', typescript: true};
4072
+ case 'tsx': return {name: 'javascript', typescript: true, jsx: true};
3910
4073
  case 'json': return {name: 'javascript', json: true};
3911
4074
  case 'py': return 'python';
3912
4075
  case 'html': case 'htm': return 'htmlmixed';
@@ -4051,7 +4214,13 @@ async function closeEditor() {
4051
4214
  if (!ok) return;
4052
4215
  }
4053
4216
  clearPreviewLiveReload();
4054
- document.getElementById('editor-view').classList.remove('open');
4217
+ const ev = document.getElementById('editor-view');
4218
+ ev.classList.remove('open');
4219
+ ev.classList.remove('fullscreen');
4220
+ document.removeEventListener('keydown', _fsEscHandler);
4221
+ try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
4222
+ const fsBtn = document.getElementById('editor-fullscreen-toggle');
4223
+ 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
4224
  document.getElementById('content').classList.remove('editor-open');
4056
4225
  document.getElementById('editor-preview').classList.remove('active');
4057
4226
  document.getElementById('md-preview-toggle').style.display = 'none';
@@ -4144,6 +4313,8 @@ function renderHtmlPreview() {
4144
4313
  const iframe = document.getElementById('editor-preview-iframe');
4145
4314
  const raw = editor ? editor.getValue() : '';
4146
4315
  if (!iframe) return;
4316
+ // Clear previous live reload to prevent leak on re-entry (U83)
4317
+ clearPreviewLiveReload();
4147
4318
  const s = getComputedStyle(document.documentElement);
4148
4319
  const bg = s.getPropertyValue('--bg').trim();
4149
4320
  const fg = s.getPropertyValue('--fg').trim();
@@ -4192,7 +4363,7 @@ function renderHtmlPreview() {
4192
4363
  input:focus, textarea:focus, select:focus { outline: 2px solid ${accent}; outline-offset: 1px; }
4193
4364
  </style>
4194
4365
  </head>
4195
- <body>${typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { USE_PROFILES: { html: true }, ADD_TAGS: ['style'], ADD_ATTR: ['target'] }) : raw}</body>
4366
+ <body>${typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw, { USE_PROFILES: { html: true }, ADD_ATTR: ['target'] }) : raw}</body>
4196
4367
  </html>`;
4197
4368
  iframe.srcdoc = doc;
4198
4369
  iframe.style.display = 'block';
@@ -4319,7 +4490,9 @@ function closeSearch() {
4319
4490
  let searchCaseSensitive = false;
4320
4491
  function toggleSearchCase() {
4321
4492
  searchCaseSensitive = !searchCaseSensitive;
4322
- document.getElementById('search-case-btn').classList.toggle('active', searchCaseSensitive);
4493
+ const btn = document.getElementById('search-case-btn');
4494
+ btn.classList.toggle('active', searchCaseSensitive);
4495
+ btn.setAttribute('aria-pressed', String(searchCaseSensitive));
4323
4496
  doSearch();
4324
4497
  }
4325
4498
 
@@ -4454,12 +4627,31 @@ function toggleSetting(k) {
4454
4627
  function openSettings() {
4455
4628
  const panel = document.getElementById('settings-panel');
4456
4629
  const isOpen = panel.classList.contains('open');
4630
+ // Don't inert #main because panel is inside #main — inert would disable panel itself. Inert content only (sidebar is sibling).
4631
+ const content = document.getElementById('content');
4457
4632
  panel.classList.toggle('open');
4458
- if (!isOpen) document.addEventListener('click', closeSettingsOnClickOutside, true);
4459
- else document.removeEventListener('click', closeSettingsOnClickOutside, true);
4633
+ if (!isOpen) {
4634
+ panel.setAttribute('aria-modal', 'true');
4635
+ panel.setAttribute('role', 'dialog');
4636
+ if (content) content.setAttribute('inert', '');
4637
+ installFocusTrap(panel);
4638
+ panel.querySelector('select, button, input')?.focus();
4639
+ // Defer outside-click handler to next tick so opening click doesn't immediately close
4640
+ setTimeout(() => document.addEventListener('click', closeSettingsOnClickOutside, true), 50);
4641
+ } else {
4642
+ panel.removeAttribute('aria-modal');
4643
+ if (content) content.removeAttribute('inert');
4644
+ removeFocusTrap();
4645
+ document.removeEventListener('click', closeSettingsOnClickOutside, true);
4646
+ }
4460
4647
  }
4461
4648
  function closeSettings() {
4462
- document.getElementById('settings-panel').classList.remove('open');
4649
+ const panel = document.getElementById('settings-panel');
4650
+ const content = document.getElementById('content');
4651
+ panel.classList.remove('open');
4652
+ panel.removeAttribute('aria-modal');
4653
+ if (content) content.removeAttribute('inert');
4654
+ removeFocusTrap();
4463
4655
  document.removeEventListener('click', closeSettingsOnClickOutside, true);
4464
4656
  }
4465
4657
  function closeSettingsOnClickOutside(e) {
@@ -4912,8 +5104,78 @@ function toggleEditorSplit() {
4912
5104
  try { safeStorage.setItem('wt-editor-split-orientation', isHoriz ? 'horizontal' : 'vertical'); } catch(e) { console.warn(e); }
4913
5105
  }
4914
5106
 
4915
- // ═══════════════════════════════════════════════════════
4916
- let wakeLock = null;
5107
+ function toggleEditorFullscreen() {
5108
+ const view = document.getElementById('editor-view');
5109
+ if (!view || !view.classList.contains('open')) return;
5110
+ const isFS = view.classList.toggle('fullscreen');
5111
+ const btn = document.getElementById('editor-fullscreen-toggle');
5112
+ if (btn) {
5113
+ btn.setAttribute('aria-label', isFS ? 'Exit fullscreen' : 'Fullscreen');
5114
+ btn.title = isFS ? 'Exit fullscreen (Esc/F11)' : 'Fullscreen (F11)';
5115
+ btn.innerHTML = isFS
5116
+ ? '<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>'
5117
+ : '<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>';
5118
+ }
5119
+ if (isFS) {
5120
+ document.addEventListener('keydown', _fsEscHandler);
5121
+ try { view.requestFullscreen?.(); } catch {}
5122
+ } else {
5123
+ document.removeEventListener('keydown', _fsEscHandler);
5124
+ try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
5125
+ requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); if(editor) editor.refresh(); });
5126
+ }
5127
+ if (editor) setTimeout(()=>editor.refresh(), 80);
5128
+ }
5129
+ function _fsEscHandler(e) {
5130
+ if (e.key === 'Escape' || e.key === 'F11') {
5131
+ const v = document.getElementById('editor-view');
5132
+ if (v && v.classList.contains('fullscreen')) { e.preventDefault(); toggleEditorFullscreen(); }
5133
+ }
5134
+ }
5135
+
5136
+ function toggleTerminalFullscreen() {
5137
+ const content = document.getElementById('content');
5138
+ const term = document.getElementById('terminals');
5139
+ const sidebar = document.getElementById('sidebar');
5140
+ if (!content || !term) return;
5141
+ const isFS = content.classList.toggle('terminal-fullscreen');
5142
+ term.classList.toggle('fullscreen', isFS);
5143
+ const btn = document.getElementById('term-fullscreen-btn');
5144
+ if (btn) {
5145
+ btn.classList.toggle('active', isFS);
5146
+ btn.setAttribute('aria-label', isFS ? 'Exit terminal fullscreen' : 'Terminal fullscreen');
5147
+ btn.title = isFS ? 'Exit terminal fullscreen (Esc/Shift+F11)' : 'Terminal fullscreen (Shift+F11)';
5148
+ btn.innerHTML = isFS
5149
+ ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 4"/><polyline points="20 10 14 10 14 20"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="10" y1="14" x2="3" y2="21"/></svg>'
5150
+ : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
5151
+ }
5152
+ if (isFS) {
5153
+ document.addEventListener('keydown', _termFsEscHandler);
5154
+ try { content.requestFullscreen?.(); } catch {}
5155
+ } else {
5156
+ document.removeEventListener('keydown', _termFsEscHandler);
5157
+ try { if (document.fullscreenElement) document.exitFullscreen(); } catch {}
5158
+ // Ensure sidebar is visible when exiting fullscreen
5159
+ if (sidebar) {
5160
+ sidebar.style.display = '';
5161
+ sidebar.classList.remove('hidden');
5162
+ if (window.innerWidth <= 768) {
5163
+ sidebar.classList.add('mobile-open');
5164
+ }
5165
+ }
5166
+ }
5167
+ requestAnimationFrame(() => { tabs.forEach(t=>{try{fitTerm(t);}catch(e){console.warn(e);}}); const at=getActiveTab(); try{at?.term?.focus();}catch{} });
5168
+ }
5169
+ function _termFsEscHandler(e) {
5170
+ const isTermFS = document.getElementById('content')?.classList.contains('terminal-fullscreen');
5171
+ if (!isTermFS) return;
5172
+ if (e.key === 'Escape' || (e.key === 'F11' && e.shiftKey)) { e.preventDefault(); toggleTerminalFullscreen(); }
5173
+ else if (e.key === 'F11' && !e.shiftKey) {
5174
+ // Let editor handle F11 when editor fullscreen, but prevent browser default
5175
+ const ev = document.getElementById('editor-view');
5176
+ if (!ev || !ev.classList.contains('fullscreen')) e.preventDefault();
5177
+ }
5178
+ }
4917
5179
  const hasWakeLock = 'wakeLock' in navigator;
4918
5180
 
4919
5181
  async function requestWakeLock() {
@@ -4962,7 +5224,11 @@ function moveTabToEnd(fromId) {
4962
5224
  const [moved] = tabs.splice(fromIdx, 1);
4963
5225
  tabs.push(moved);
4964
5226
  const bar = document.getElementById('tab-scroll');
4965
- if (moved.el && moved.el.parentNode === bar) bar.appendChild(moved.el);
5227
+ const newBtn = document.getElementById('new-tab-btn');
5228
+ if (moved.el && moved.el.parentNode === bar) {
5229
+ if (newBtn && newBtn.parentElement === bar) bar.insertBefore(moved.el, newBtn);
5230
+ else bar.appendChild(moved.el);
5231
+ }
4966
5232
  saveTabState();
4967
5233
  }
4968
5234
 
@@ -5004,6 +5270,8 @@ function setupKeyboardShortcuts() {
5004
5270
  if (ctrl && e.shiftKey && e.key === 'ArrowRight') { e.preventDefault(); cycleTab(1); }
5005
5271
  if (ctrl && e.shiftKey && e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); }
5006
5272
  if (ctrl && e.shiftKey && e.key === 'r') { e.preventDefault(); refreshPreview(); }
5273
+ if (e.key === 'F11' && e.shiftKey) { e.preventDefault(); toggleTerminalFullscreen(); }
5274
+ else if (e.key === 'F11' && document.getElementById('editor-view').classList.contains('open')) { e.preventDefault(); toggleEditorFullscreen(); }
5007
5275
  if (e.key === 'Escape') {
5008
5276
  const termMenu = document.getElementById('term-ctx-menu');
5009
5277
  if (termMenu && termMenu.style.display !== 'none') {
@@ -5059,26 +5327,25 @@ function toggleTermSelect() {
5059
5327
  }
5060
5328
  }
5061
5329
 
5330
+ const activeTab = getActiveTab();
5062
5331
  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');
5332
+ if (activeTab?.term) {
5333
+ activeTab.term.options.disableStdin = true;
5334
+ const ta = activeTab.textarea || activeTab.term.textarea || activeTab.term.element?.querySelector('.xterm-textarea, textarea');
5067
5335
  if (ta) ta.disabled = true;
5068
- });
5336
+ }
5069
5337
  } else {
5070
5338
  scrollMode = false;
5071
5339
  cleanupScrollHandlers();
5072
5340
  const scrollBtn = document.getElementById('sel-scroll-btn');
5073
5341
  if (scrollBtn) scrollBtn.classList.remove('active-mode');
5074
5342
  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');
5343
+ if (activeTab?.term) {
5344
+ activeTab.term.options.disableStdin = false;
5345
+ const ta = activeTab.textarea || activeTab.term.textarea || activeTab.term.element?.querySelector('.xterm-textarea, textarea');
5079
5346
  if (ta) ta.disabled = false;
5080
- t.term.clearSelection();
5081
- });
5347
+ activeTab.term.clearSelection();
5348
+ }
5082
5349
  }
5083
5350
  }
5084
5351
 
@@ -5164,12 +5431,16 @@ function pasteToTerminal() {
5164
5431
  if (!text) return;
5165
5432
  const tab = getActiveTab();
5166
5433
  if (tab?.ws && tab.ws.readyState === WebSocket.OPEN) {
5167
- // Bracketed paste: wrap in escape sequences so the shell buffers the input
5434
+ // Bracketed paste: wrap in escape sequences so the shell buffers the input — chunk 60KB with loop
5168
5435
  const bracketed = '\x1b[200~' + text + '\x1b[201~';
5169
5436
  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);
5437
+ const CHUNK = 60 * 1024;
5438
+ for (let off = 0; off < enc.length; off += CHUNK) {
5439
+ const slice = enc.subarray(off, Math.min(off + CHUNK, enc.length));
5440
+ const buf = new Uint8Array(1 + slice.length);
5441
+ buf[0] = 0x00; buf.set(slice, 1);
5442
+ try { tab.ws.send(buf.buffer); } catch {}
5443
+ }
5173
5444
  tab.term?.focus();
5174
5445
  toast('Pasted to terminal', 'success');
5175
5446
  }
@@ -5191,14 +5462,24 @@ function fallbackPaste(callback) {
5191
5462
  document.body.appendChild(_pasteInput);
5192
5463
  _pasteInput.addEventListener('paste', () => {
5193
5464
  setTimeout(() => {
5194
- callback(_pasteInput.value);
5465
+ if (_pasteInput.value) {
5466
+ callback(_pasteInput.value);
5467
+ toast('Pasted to terminal', 'success');
5468
+ } else {
5469
+ toast('Paste failed — please use Ctrl+V', 'warning');
5470
+ }
5195
5471
  _pasteInput.value = '';
5196
5472
  }, 0);
5197
5473
  });
5198
5474
  }
5199
5475
  _pasteInput.value = '';
5200
5476
  _pasteInput.focus();
5201
- document.execCommand('paste');
5477
+ try {
5478
+ const ok = document.execCommand('paste');
5479
+ if (!ok) toast('Paste failed — please use Ctrl+V', 'warning');
5480
+ } catch {
5481
+ toast('Paste not supported — use Ctrl+V', 'warning');
5482
+ }
5202
5483
  }
5203
5484
 
5204
5485
  function selectAllTerm() {
@@ -5244,48 +5525,66 @@ function toggleCtrlRow() {
5244
5525
  }
5245
5526
 
5246
5527
  let _viewportHandlerInstalled = false;
5528
+ let _viewportDebounce = null;
5247
5529
  function setupVisualViewport() {
5248
5530
  if (_viewportHandlerInstalled) return;
5249
5531
  _viewportHandlerInstalled = true;
5250
5532
  if (window.visualViewport) {
5251
5533
  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) {
5534
+ clearTimeout(_viewportDebounce);
5535
+ _viewportDebounce = setTimeout(() => {
5536
+ const vvH = window.visualViewport.height;
5537
+ const vvScale = window.visualViewport.scale || 1;
5538
+ // Samsung Internet: innerHeight resizes, not visualViewport. Use layout viewport height via window.innerHeight vs vvH
5539
+ // On iOS, keyboard shows as vvH < innerHeight. On Samsung, opposite. Take max diff and ignore when scaled (pinch zoom).
5540
+ if (vvScale !== 1) return; // ignore pinch-zoom (U61, U65)
5541
+ const diff = vvH - window.innerHeight;
5542
+ const absDiff = Math.abs(diff);
5543
+ const isKeyboard = absDiff > 50 && diff < 0; // iOS: vv smaller. For Samsung, also check visualViewport offsetTop
5544
+ // Fallback: check visualViewport offsetTop > 10 indicates keyboard on some Android
5545
+ const offsetTop = window.visualViewport.offsetTop || 0;
5546
+ const keyboardOpen = isKeyboard || offsetTop > 10;
5547
+ const mobileKeys = document.getElementById('mobile-keys');
5548
+ const selRow = document.getElementById('mkey-sel-row');
5549
+ const terminals = document.getElementById('terminals');
5550
+ if (!terminals) return;
5551
+ if (keyboardOpen) {
5552
+ terminals.style.marginBottom = absDiff + 'px';
5553
+ if (mobileKeys) mobileKeys.style.display = 'none';
5271
5554
  const ctrlRow = document.getElementById('mkey-ctrl-row');
5272
- if (ctrlRow && ctrlRow.dataset.wasOpen === '1') {
5273
- ctrlRow.style.display = 'flex';
5274
- delete ctrlRow.dataset.wasOpen;
5555
+ if (ctrlRow && ctrlRow.style.display === 'flex') ctrlRow.dataset.wasOpen = '1';
5556
+ if (ctrlRow) ctrlRow.style.display = 'none';
5557
+ if (selRow && selRow.style.display === 'flex') selRow.dataset.wasOpen = '1';
5558
+ if (selRow) selRow.style.display = 'none';
5559
+ } else {
5560
+ terminals.style.marginBottom = '0';
5561
+ if (mobileKeys && window.innerWidth <= 768 && settings.mobilekeys !== false) {
5562
+ mobileKeys.style.display = 'flex';
5275
5563
  }
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';
5564
+ if (window.innerWidth <= 768 && settings.mobilekeys !== false) {
5565
+ const ctrlRow = document.getElementById('mkey-ctrl-row');
5566
+ if (ctrlRow && ctrlRow.dataset.wasOpen === '1') {
5567
+ ctrlRow.style.display = 'flex';
5568
+ delete ctrlRow.dataset.wasOpen;
5569
+ }
5570
+ }
5571
+ if (selRow && window.innerWidth <= 768) {
5572
+ if (selRow.dataset.wasOpen === '1') {
5573
+ selRow.style.display = 'flex';
5574
+ delete selRow.dataset.wasOpen;
5575
+ } else {
5576
+ selRow.style.display = termSelectMode ? 'flex' : 'none';
5577
+ }
5283
5578
  }
5284
5579
  }
5285
- }
5580
+ }, 100);
5286
5581
  };
5287
5582
  window.visualViewport.addEventListener('resize', handler);
5288
- window._cleanups?.push(() => window.visualViewport.removeEventListener('resize', handler));
5583
+ window.visualViewport.addEventListener('scroll', handler);
5584
+ window._cleanups?.push(() => {
5585
+ window.visualViewport.removeEventListener('resize', handler);
5586
+ window.visualViewport.removeEventListener('scroll', handler);
5587
+ });
5289
5588
  }
5290
5589
  }
5291
5590
 
@@ -5387,6 +5686,9 @@ function setupDragDrop() {
5387
5686
  const traversed = await traverseDirectoryEntry(entry);
5388
5687
  filesToUpload.push(...traversed);
5389
5688
  } catch(e) { console.warn(e); }
5689
+ } else {
5690
+ const file = item.getAsFile();
5691
+ if (file) filesToUpload.push({ file, path: file.name });
5390
5692
  }
5391
5693
  }
5392
5694
  }
@@ -5437,8 +5739,10 @@ function closeOverlay(id) {
5437
5739
  lastFocusedElement = null;
5438
5740
  }
5439
5741
  }
5742
+ let _focusTrapContainer = null;
5440
5743
  function installFocusTrap(container) {
5441
5744
  removeFocusTrap();
5745
+ _focusTrapContainer = container;
5442
5746
  _focusTrapHandler = e => {
5443
5747
  if (e.key !== 'Tab') return;
5444
5748
  const focusable = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
@@ -5454,15 +5758,25 @@ function installFocusTrap(container) {
5454
5758
  container.addEventListener('keydown', _focusTrapHandler);
5455
5759
  }
5456
5760
  function removeFocusTrap() {
5457
- if (_focusTrapHandler) {
5761
+ if (_focusTrapHandler && _focusTrapContainer) {
5762
+ _focusTrapContainer.removeEventListener('keydown', _focusTrapHandler);
5763
+ _focusTrapHandler = null;
5764
+ _focusTrapContainer = null;
5765
+ } else if (_focusTrapHandler) {
5458
5766
  document.removeEventListener('keydown', _focusTrapHandler);
5459
5767
  _focusTrapHandler = null;
5460
5768
  }
5461
5769
  }
5462
5770
 
5463
5771
  let _confirmState = null;
5772
+ let _confirmQueue = [];
5464
5773
  function confirmDialog({ title = 'Confirm', message = '', okText = 'OK', cancelText = 'Cancel', danger = false } = {}) {
5465
5774
  return new Promise(resolve => {
5775
+ if (_confirmState) {
5776
+ // Queue second dialog until first settles (fixes race U51)
5777
+ _confirmQueue.push({ title, message, okText, cancelText, danger, resolve });
5778
+ return;
5779
+ }
5466
5780
  const ov = document.getElementById('confirm-overlay');
5467
5781
  if (!ov || !ov.parentNode) { resolve(false); return; }
5468
5782
  const okBtn = document.getElementById('confirm-ok-btn');
@@ -5483,8 +5797,14 @@ function confirmDialog({ title = 'Confirm', message = '', okText = 'OK', cancelT
5483
5797
  settled = true;
5484
5798
  _confirmState = null;
5485
5799
  ov.classList.remove('open');
5800
+ ov.removeEventListener('keydown', onKey);
5486
5801
  removeFocusTrap();
5487
5802
  if (lastFocusedElement) { setTimeout(() => lastFocusedElement?.focus(), 50); lastFocusedElement = null; }
5803
+ // Dequeue next pending dialog
5804
+ if (_confirmQueue.length) {
5805
+ const next = _confirmQueue.shift();
5806
+ setTimeout(() => confirmDialog(next).then(next.resolve), 80);
5807
+ }
5488
5808
  };
5489
5809
  const finish = val => { const cb = _confirmState && _confirmState.resolve; cleanup(); if (cb) cb(val); };
5490
5810
  const onKey = e => {
@@ -5514,8 +5834,8 @@ document.querySelectorAll('.overlay').forEach(o => {
5514
5834
 
5515
5835
  function setBtnBusy(btn, busy) {
5516
5836
  if (!btn) return;
5517
- if (busy) { btn.dataset.busy = 'true'; btn.classList.add('loading'); btn.disabled = true; }
5518
- else { btn.dataset.busy = 'false'; btn.classList.remove('loading'); btn.disabled = false; }
5837
+ if (busy) { btn.dataset.busy = 'true'; btn.classList.add('loading'); btn.disabled = true; btn.setAttribute('aria-busy','true'); }
5838
+ else { btn.dataset.busy = 'false'; btn.classList.remove('loading'); btn.disabled = false; btn.removeAttribute('aria-busy'); }
5519
5839
  }
5520
5840
 
5521
5841
  function showFieldError(id, msg) {
@@ -5826,15 +6146,25 @@ function saveCmdLib(cmds) {
5826
6146
  function toggleCmdLib() {
5827
6147
  const panel = document.getElementById('cmd-lib-panel');
5828
6148
  const isOpen = panel.classList.contains('open');
6149
+ // Only inert the terminals area (content), not sidebar (panel is on right side)
6150
+ const terms = document.getElementById('terminals');
5829
6151
  panel.classList.toggle('open');
6152
+ panel.setAttribute('role', 'dialog');
6153
+ panel.setAttribute('aria-modal', String(!isOpen));
5830
6154
  document.getElementById('cmd-lib-toggle').classList.toggle('active', !isOpen);
6155
+ document.getElementById('cmd-lib-toggle').setAttribute('aria-expanded', String(!isOpen));
5831
6156
  if (!isOpen) {
5832
6157
  loadHistMax();
5833
6158
  switchCmdTab('library');
6159
+ if (terms) terms.setAttribute('inert', '');
6160
+ installFocusTrap(panel);
5834
6161
  setTimeout(() => {
5835
6162
  document.addEventListener('click', closeCmdLibOnClickOutside, true);
5836
- }, 0);
6163
+ panel.querySelector('input, button')?.focus();
6164
+ }, 50);
5837
6165
  } else {
6166
+ if (terms) terms.removeAttribute('inert');
6167
+ removeFocusTrap();
5838
6168
  document.removeEventListener('click', closeCmdLibOnClickOutside, true);
5839
6169
  }
5840
6170
  }
@@ -5848,6 +6178,9 @@ function closeCmdLibOnClickOutside(e) {
5848
6178
  }
5849
6179
  if (panel.contains(e.target) || btn.contains(e.target)) return;
5850
6180
  toggleCmdLib();
6181
+ const terms = document.getElementById('terminals');
6182
+ if (terms) terms.removeAttribute('inert');
6183
+ removeFocusTrap();
5851
6184
  }
5852
6185
 
5853
6186
  function renderCmdLib(filter) {
@@ -5932,12 +6265,18 @@ function filterCmdLib() {
5932
6265
  renderCmdLib();
5933
6266
  }
5934
6267
 
5935
- function runCmdLib(cmd) {
6268
+ async function runCmdLib(cmd) {
5936
6269
  const tab = getActiveTab();
5937
6270
  if (!tab || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) {
5938
6271
  toast('No active terminal', 'error');
5939
6272
  return;
5940
6273
  }
6274
+ // RCE guard: curl | bash requires confirmation (U91)
6275
+ const needsConfirm = (typeof DEFAULT_CMDS !== 'undefined' && DEFAULT_CMDS.some(c => c.cmd === cmd && c.requiresConfirm)) || /\bcurl\b.*\|\s*bash/.test(cmd);
6276
+ if (needsConfirm) {
6277
+ const ok = await confirmDialog({ title: 'Confirm run', message: `Run sensitive command?\n\n${cmd}`, okText: 'Run', cancelText: 'Cancel', danger: true });
6278
+ if (!ok) return;
6279
+ }
5941
6280
  const text = cmd + '\n';
5942
6281
  const enc = new TextEncoder().encode(text);
5943
6282
  const buf = new Uint8Array(1 + enc.length);
@@ -6052,8 +6391,8 @@ function switchCmdTab(tab) {
6052
6391
  const footer = document.getElementById('cmd-lib-footer');
6053
6392
  const search = document.getElementById('cmd-lib-search');
6054
6393
  if (tab === 'library') {
6055
- libBtn.classList.add('active');
6056
- histBtn.classList.remove('active');
6394
+ libBtn.classList.add('active'); libBtn.setAttribute('aria-selected','true');
6395
+ histBtn.classList.remove('active'); histBtn.setAttribute('aria-selected','false');
6057
6396
  libList.style.display = '';
6058
6397
  histList.style.display = 'none';
6059
6398
  histHeader.style.display = 'none';
@@ -6062,9 +6401,10 @@ function switchCmdTab(tab) {
6062
6401
  search.oninput = () => filterCmdLib();
6063
6402
  filterCmdLib();
6064
6403
  } else {
6065
- histBtn.classList.add('active');
6066
- libBtn.classList.remove('active');
6404
+ histBtn.classList.add('active'); histBtn.setAttribute('aria-selected','true');
6405
+ libBtn.classList.remove('active'); libBtn.setAttribute('aria-selected','false');
6067
6406
  histList.style.display = 'flex';
6407
+ histList.setAttribute('aria-live','polite');
6068
6408
  libList.style.display = 'none';
6069
6409
  histHeader.style.display = '';
6070
6410
  footer.style.display = 'none';
@@ -6143,13 +6483,23 @@ function openSystemStats() {
6143
6483
  refreshSystemStats(true);
6144
6484
  clearInterval(sysStatsTimer);
6145
6485
  if (!settings.datasaver) {
6146
- sysStatsTimer = setInterval(() => refreshSystemStats(false), 5000);
6486
+ sysStatsTimer = setInterval(() => {
6487
+ if (document.hidden) return;
6488
+ refreshSystemStats(false);
6489
+ }, 5000);
6490
+ document.addEventListener('visibilitychange', _sysVisibilityHandler);
6491
+ }
6492
+ }
6493
+ function _sysVisibilityHandler() {
6494
+ if (document.hidden && sysStatsTimer) {
6495
+ // pause polling when hidden to save tunnel bandwidth (U93)
6147
6496
  }
6148
6497
  }
6149
6498
 
6150
6499
  function closeSystemStats() {
6151
6500
  clearInterval(sysStatsTimer);
6152
6501
  sysStatsTimer = null;
6502
+ document.removeEventListener('visibilitychange', _sysVisibilityHandler);
6153
6503
  closeOverlay('sys-overlay');
6154
6504
  }
6155
6505
 
@@ -6223,11 +6573,31 @@ async function refreshSystemStats(showLoading) {
6223
6573
  const addTd = (txt, extra) => { const td = document.createElement('td'); if (extra) td.style.cssText = extra; td.textContent = txt; tr.appendChild(td); };
6224
6574
  addTd(p.pid); addTd(p.user); addTd(p.cpu); addTd(p.mem);
6225
6575
  addTd(p.cmd, 'max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
6576
+ const killTd = document.createElement('td');
6577
+ killTd.style.textAlign = 'center';
6578
+ const killBtn = document.createElement('button');
6579
+ killBtn.className = 'btn btn-danger';
6580
+ killBtn.style.cssText = 'height:22px;padding:0 8px;font-size:11px;min-width:36px';
6581
+ killBtn.textContent = 'Kill';
6582
+ killBtn.setAttribute('aria-label', 'Kill process ' + p.pid);
6583
+ killBtn.title = 'Kill PID ' + p.pid;
6584
+ killBtn.onclick = () => killProcess(p.pid, p.cmd);
6585
+ killTd.appendChild(killBtn);
6586
+ tr.appendChild(killTd);
6226
6587
  tbody.appendChild(tr);
6227
6588
  });
6228
6589
  }
6229
6590
  }
6230
6591
 
6592
+ async function killProcess(pid, cmd) {
6593
+ const display = cmd ? `${pid} (${cmd.split('/').pop().slice(0,30)})` : String(pid);
6594
+ const ok = await confirmDialog({ title: 'Kill process', message: `Kill process ${display}?\n\nThis will send SIGTERM (then SIGKILL).`, okText: 'Kill', cancelText: 'Cancel', danger: true });
6595
+ if (!ok) return;
6596
+ const r = await api('/api/system/kill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pid: Number(pid) }) });
6597
+ if (r && r.success) { toast('Killed ' + pid, 'success'); refreshSystemStats(false); }
6598
+ else toast(r && r.error ? r.error : 'Kill failed', 'error');
6599
+ }
6600
+
6231
6601
  // ═══════════════════════════════════════════════════════
6232
6602
  // PWA / SERVICE WORKER
6233
6603
  // ═══════════════════════════════════════════════════════
@@ -6259,7 +6629,9 @@ function showInstallBanner(isIOS) {
6259
6629
  }
6260
6630
  const mk = document.getElementById('mobile-keys');
6261
6631
  if (mk && window.getComputedStyle(mk).display !== 'none') {
6262
- el.style.bottom = 'var(--mobilekey-h)';
6632
+ el.style.bottom = 'calc(var(--mobilekey-h) + env(safe-area-inset-bottom, 0px))';
6633
+ } else {
6634
+ el.style.bottom = 'env(safe-area-inset-bottom, 0px)';
6263
6635
  }
6264
6636
  el.style.display = 'flex';
6265
6637
  document.getElementById('toast-container').classList.add('banner-visible');