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/README.md +23 -5
- package/bin/webtun.js +12 -0
- package/electron/main.js +176 -0
- package/electron/preload.js +7 -0
- package/package.json +43 -4
- package/postinstall.js +38 -20
- package/public/commands.js +1 -1
- package/public/index.html +549 -177
- package/public/manifest.json +1 -1
- package/public/sw.js +16 -3
- package/server.js +719 -174
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:
|
|
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:
|
|
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: -
|
|
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:
|
|
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:
|
|
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;
|
|
414
|
-
#sidebar-header
|
|
415
|
-
#file-
|
|
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:
|
|
424
|
-
#file-list-wrap.loading::after { content: ''; position: absolute; top: 0; left: -30%; width: 30%; height:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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;
|
|
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:
|
|
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:
|
|
468
|
-
width:
|
|
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:
|
|
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.
|
|
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:
|
|
722
|
-
padding:
|
|
723
|
-
text-transform: uppercase; letter-spacing: 0.
|
|
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:
|
|
731
|
-
padding:
|
|
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:
|
|
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="
|
|
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
|
|
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"
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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) >
|
|
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')
|
|
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 ||
|
|
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
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
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
|
|
2421
|
-
|
|
2422
|
-
|
|
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
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
2566
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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);
|
|
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
|
-
|
|
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':
|
|
3909
|
-
case '
|
|
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')
|
|
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 },
|
|
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')
|
|
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)
|
|
4459
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
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
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
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
|
-
|
|
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
|
|
5171
|
-
|
|
5172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
const
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
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
|
|
5273
|
-
|
|
5274
|
-
|
|
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
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
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.
|
|
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
|
-
|
|
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(() =>
|
|
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');
|