webtun 1.4.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,8 @@
32
32
  - **Multi-tab terminal** — drag tabs to reorder, side-by-side sessions like your desktop
33
33
  - **File explorer** — browse, upload, download, rename, delete, cut/copy/paste with conflict resolution (replace, merge, keep both, skip)
34
34
  - **WebSocket + xterm.js** — responsive, low-latency typing
35
+ - **Bracketed paste** — Ctrl+V works correctly in TUI apps (vim, nano, htop, mc)
36
+ - **Command history** — automatic capture with smart dedup, works with pasted commands
35
37
 
36
38
  ### Mobile-First UX
37
39
  - **Touch gestures** — swipe to close tabs, swipe from edge to open sidebar, pull-to-refresh file list
@@ -41,16 +43,28 @@
41
43
  - **Selection mode** — toggle between terminal interaction and text selection
42
44
  - **Responsive** — optimized for phones (480px), tablets (1024px), and landscape orientation
43
45
 
46
+ ### Settings
47
+ - **Right-click menu toggle** — enable/disable custom terminal context menu (Settings > Terminal)
48
+ - **Terminal scroll** — reliable mouse wheel scroll in all apps, including TUI apps with mouse tracking
49
+
44
50
  ### File Explorer
45
- - **Multi-select** — select all / deselect all, batch delete and download files
46
- - **Zip / Extract** — right-click any file or folder to zip; right-click `.zip` files to extract
51
+ - **Multi-select** — select all / deselect all, batch delete, download, zip, cut, and copy files
52
+ - **Context menu on selection** — right-click actions (zip, download, delete, cut, copy) operate on all selected files
53
+ - **Zip / Extract** — right-click any file or folder to zip; right-click `.zip` files to extract (archiver v8)
54
+ - **Folder download** — folders download as `.zip` archives automatically
47
55
  - **Image viewer** — click images (.png, .jpg, .gif, .svg, .webp, .bmp, .ico) to preview inline
48
- - **Cut, Copy, Paste** — with conflict resolution dialog (replace, merge folders, keep both, skip, cancel)
56
+ - **Cut, Copy, Paste** — multi-file support with conflict resolution dialog (replace, merge folders, keep both, skip, cancel)
49
57
  - **Upload with progress** — per-file progress bar via XHR
50
58
  - **Breadcrumb navigation** — tappable directory segments for quick navigation
51
59
  - **Long-press context menu** — rename, delete, download, copy on touch devices
52
60
  - **Text selection** — long-press file names to select and copy text on touch devices
53
61
 
62
+ ### System Stats
63
+ - **CPU, Memory, Disk, Uptime** — real-time usage with progress bars
64
+ - **GPU detection** — cross-platform (Linux: nvidia-smi/lspci, macOS: system_profiler, Windows: WMI)
65
+ - **Multi-GPU support** — detects and displays all GPUs with VRAM, utilization, and temperature
66
+ - **Top processes** — sorted by CPU usage
67
+
54
68
  ### Desktop App (Electron)
55
69
  - Cross-platform app for Linux & Windows
56
70
  - Runs the server as a child process — no terminal needed
@@ -231,6 +245,17 @@ kill $(pgrep -f 'cloudflared tunnel')
231
245
  | Protocol | WebSocket (ws) |
232
246
  | Tunnel | Cloudflare Tunnel (cloudflared) |
233
247
  | Desktop | Electron + electron-builder |
248
+ | Archives | archiver v8 + yauzl |
249
+
250
+ ---
251
+
252
+ ## Security
253
+
254
+ - **npm audit clean** — 0 known vulnerabilities
255
+ - **PIN authentication** — optional, protects all API endpoints
256
+ - **CSP headers** — restricts script sources to CDN
257
+ - **WebSocket origin check** — prevents cross-site WebSocket hijacking
258
+ - **Rate limiting** — per-IP rate limits on auth and search endpoints
234
259
 
235
260
  ---
236
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.4.5",
3
+ "version": "1.5.0",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
package/public/index.html CHANGED
@@ -550,13 +550,18 @@ svg { display: block; }
550
550
  /* ═══════════════════════════════════════════════════════
551
551
  CONTEXT MENU
552
552
  ═══════════════════════════════════════════════════════ */
553
- #ctx-menu { position: fixed; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 4px 20px var(--shadow-color); z-index: 500; display: none; min-width: 160px; overflow: hidden; }
553
+ #ctx-menu { position: fixed; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 4px 20px var(--shadow-color); z-index: 500; display: none; min-width: 160px; overflow: hidden; max-height: calc(100vh - 16px); overflow-y: auto; }
554
554
  @media (max-width: 360px) { #ctx-menu { min-width: 140px; max-width: calc(100vw - 16px); font-size: 11px; } }
555
555
  #ctx-menu.open { display: block; }
556
556
  .ctx-item { padding: 8px 14px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
557
557
  .ctx-item:hover { background: var(--bg4); }
558
558
  .ctx-sep { height: 1px; background: var(--border); margin: 3px 0; }
559
559
  .ctx-item.danger { color: var(--red); }
560
+ .ctx-submenu-wrap { }
561
+ .ctx-submenu-arrow { margin-left: auto; font-size: 10px; color: var(--fg3); transition: transform 0.15s; }
562
+ .ctx-submenu-wrap.open > .ctx-item > .ctx-submenu-arrow { transform: rotate(90deg); }
563
+ .ctx-submenu-items { display: none; }
564
+ .ctx-submenu-wrap.open > .ctx-submenu-items { display: block; }
560
565
 
561
566
  /* ═══════════════════════════════════════════════════════
562
567
  TOAST
@@ -660,8 +665,65 @@ svg { display: block; }
660
665
  #cmd-lib-search {
661
666
  width: calc(100% - 28px); height: 28px; font-size: 12px; margin: 8px 14px 0;
662
667
  }
668
+ .cmd-lib-tabs {
669
+ display: flex; gap: 0; border-bottom: 1px solid var(--border); flex-shrink: 0;
670
+ }
671
+ .cmd-lib-tab {
672
+ flex: 1; height: 32px; font-size: 12px; font-weight: 600; color: var(--fg2);
673
+ background: none; border: none; border-bottom: 2px solid transparent;
674
+ cursor: pointer; transition: all var(--transition-fast);
675
+ display: flex; align-items: center; justify-content: center; gap: 5px;
676
+ }
677
+ .cmd-lib-tab:hover { color: var(--fg); background: var(--bg3); }
678
+ .cmd-lib-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
679
+ #cmd-hist-list {
680
+ flex: 1; overflow-y: auto; overflow-x: hidden; padding: 6px; min-height: 0;
681
+ display: none; flex-direction: column; gap: 2px;
682
+ }
683
+ #cmd-hist-empty {
684
+ text-align: center; color: var(--fg2); padding: 24px 16px; font-size: 12px;
685
+ }
686
+ .cmd-hist-item {
687
+ display: flex; align-items: center; gap: 8px; padding: 6px 10px;
688
+ border-radius: var(--radius-sm); cursor: default; font-size: 12px;
689
+ transition: background var(--transition-fast);
690
+ }
691
+ .cmd-hist-item:hover { background: var(--bg4); }
692
+ .cmd-hist-item .cmd-hist-cmd {
693
+ flex: 1; color: var(--fg); font-family: var(--font); overflow: hidden;
694
+ text-overflow: ellipsis; white-space: nowrap;
695
+ }
696
+ .cmd-hist-item .cmd-hist-time {
697
+ font-size: 10px; color: var(--fg3); flex-shrink: 0;
698
+ }
699
+ .cmd-hist-item .cmd-hist-run {
700
+ width: 24px; height: 24px; display: none; align-items: center; justify-content: center;
701
+ border-radius: var(--radius-sm); color: var(--fg2); flex-shrink: 0; cursor: pointer;
702
+ }
703
+ .cmd-hist-item:hover .cmd-hist-run { display: flex; }
704
+ .cmd-hist-item .cmd-hist-run:hover { background: var(--bg4); color: var(--green); }
705
+ .cmd-hist-item .cmd-hist-del {
706
+ width: 24px; height: 24px; display: none; align-items: center; justify-content: center;
707
+ border-radius: var(--radius-sm); color: var(--fg2); flex-shrink: 0; cursor: pointer;
708
+ }
709
+ .cmd-hist-item:hover .cmd-hist-del { display: flex; }
710
+ .cmd-hist-item .cmd-hist-del:hover { color: var(--red); background: var(--bg4); }
711
+ .cmd-hist-header {
712
+ display: flex; align-items: center; justify-content: space-between;
713
+ padding: 6px 10px; flex-shrink: 0;
714
+ }
715
+ .cmd-hist-header label { font-size: 11px; color: var(--fg2); display: flex; align-items: center; gap: 4px; }
716
+ .cmd-hist-header input[type="number"] {
717
+ width: 52px; height: 22px; font-size: 11px; padding: 0 4px; text-align: center;
718
+ }
719
+ .cmd-hist-clear {
720
+ font-size: 11px; color: var(--red); background: none; border: none; cursor: pointer;
721
+ padding: 2px 6px; border-radius: var(--radius-sm);
722
+ }
723
+ .cmd-hist-clear:hover { background: var(--bg4); }
663
724
  #cmd-lib-list {
664
- flex: 1; overflow-y: auto; padding: 6px; min-height: 0;
725
+ flex: 1; overflow-y: auto; overflow-x: hidden; padding: 6px; min-height: 0;
726
+ display: flex; flex-direction: column; gap: 2px;
665
727
  }
666
728
  #cmd-lib-empty {
667
729
  text-align: center; color: var(--fg2); padding: 24px 16px; font-size: 12px;
@@ -957,8 +1019,19 @@ svg { display: block; }
957
1019
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
958
1020
  </button>
959
1021
  </div>
1022
+ <div class="cmd-lib-tabs">
1023
+ <button class="cmd-lib-tab active" id="cmd-tab-lib" onclick="switchCmdTab('library')">Library</button>
1024
+ <button class="cmd-lib-tab" id="cmd-tab-hist" onclick="switchCmdTab('history')">History</button>
1025
+ </div>
960
1026
  <input id="cmd-lib-search" type="text" placeholder="Filter commands…" oninput="filterCmdLib()">
961
1027
  <div id="cmd-lib-list"></div>
1028
+ <div id="cmd-hist-list">
1029
+ <div id="cmd-hist-empty">No commands run yet</div>
1030
+ </div>
1031
+ <div class="cmd-hist-header" id="cmd-hist-header" style="display:none">
1032
+ <label>Keep last <input type="number" id="cmd-hist-max" min="10" max="500" value="50" onchange="updateHistMax(+this.value)"> cmds</label>
1033
+ <button class="cmd-hist-clear" onclick="clearCmdHist()">Clear History</button>
1034
+ </div>
962
1035
  <div id="cmd-lib-footer">
963
1036
  <input id="cmd-lib-name-input" type="text" placeholder="Name" onkeydown="if(event.key==='Enter')document.getElementById('cmd-lib-cmd-input').focus()">
964
1037
  <input id="cmd-lib-cmd-input" type="text" placeholder="Command (e.g. ls -la)" onkeydown="if(event.key==='Enter')addCustomCmd()">
@@ -1118,6 +1191,10 @@ svg { display: block; }
1118
1191
  <label>Bell Sound</label>
1119
1192
  <div class="toggle" id="s-bell" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('bell')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('bell')}"></div>
1120
1193
  </div>
1194
+ <div class="setting-row">
1195
+ <label>Right-Click Menu</label>
1196
+ <div class="toggle on" id="s-termRightClick" role="switch" tabindex="0" aria-checked="true" onclick="toggleSetting('termRightClick')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('termRightClick')}"></div>
1197
+ </div>
1121
1198
  </div>
1122
1199
  <div class="settings-section">
1123
1200
  <h3>Interface</h3>
@@ -1198,12 +1275,37 @@ svg { display: block; }
1198
1275
  </div>
1199
1276
 
1200
1277
  <!-- Terminal context menu -->
1201
- <div id="term-ctx-menu" role="menu" style="position:fixed;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 20px var(--shadow-color);z-index:500;display:none;min-width:160px;overflow:hidden">
1278
+ <div id="term-ctx-menu" role="menu" style="position:fixed;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 20px var(--shadow-color);z-index:500;display:none;min-width:180px;overflow:hidden">
1279
+ <!-- Clipboard -->
1202
1280
  <div class="ctx-item" id="term-ctx-copy" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</div>
1203
1281
  <div class="ctx-item" id="term-ctx-paste" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="14" y="2" width="8" height="8" rx="2"/><path d="M16 14H8M8 18h8"/><path d="M5 4h4"/><rect x="2" y="6" width="16" height="16" rx="2"/></svg> Paste</div>
1282
+ <div class="ctx-sep"></div>
1204
1283
  <div class="ctx-item" id="term-ctx-select-all" role="menuitem"><svg width="12" height="12" 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 3v18"/><path d="M15 3v18"/><path d="M3 9h18"/><path d="M3 15h18"/></svg> Select All</div>
1284
+ <!-- Tab / Path -->
1285
+ <div class="ctx-sep"></div>
1286
+ <div class="ctx-item" id="term-ctx-new-tab" role="menuitem"><svg width="12" height="12" 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> New Tab Here</div>
1287
+ <div class="ctx-item" id="term-ctx-copy-cwd" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy Working Directory</div>
1288
+ <!-- Control -->
1205
1289
  <div class="ctx-sep"></div>
1206
- <div class="ctx-item" id="term-ctx-clear" role="menuitem"><svg width="12" height="12" 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="M3 9h18"/></svg> Clear</div>
1290
+ <div class="ctx-item" id="term-ctx-interrupt" role="menuitem"><svg width="12" height="12" 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="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg> Interrupt (Ctrl+C)</div>
1291
+ <!-- More Options -->
1292
+ <div class="ctx-sep"></div>
1293
+ <div class="ctx-submenu-wrap">
1294
+ <div class="ctx-item" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> More Options<span class="ctx-submenu-arrow">▸</span></div>
1295
+ <div class="ctx-submenu-items">
1296
+ <div class="ctx-item" id="term-ctx-find" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Find in Terminal</div>
1297
+ <div class="ctx-item" id="term-ctx-zoom-in" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg> Zoom In</div>
1298
+ <div class="ctx-item" id="term-ctx-zoom-out" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg> Zoom Out</div>
1299
+ <div class="ctx-item" id="term-ctx-select-line" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/></svg> Select Line</div>
1300
+ <div class="ctx-item" id="term-ctx-scroll-top" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg> Scroll to Top</div>
1301
+ <div class="ctx-item" id="term-ctx-scroll-bottom" role="menuitem"><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> Scroll to Bottom</div>
1302
+ <div class="ctx-item" id="term-ctx-rename" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg> Rename Tab</div>
1303
+ <div class="ctx-item" id="term-ctx-bookmark" role="menuitem"><svg width="12" height="12" 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> Bookmark Directory</div>
1304
+ </div>
1305
+ </div>
1306
+ <!-- Danger -->
1307
+ <div class="ctx-sep"></div>
1308
+ <div class="ctx-item danger" id="term-ctx-clear" role="menuitem"><svg width="12" height="12" 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="M3 9h18"/></svg> Clear</div>
1207
1309
  </div>
1208
1310
 
1209
1311
  <!-- Fuzzy Finder Modal -->
@@ -1245,6 +1347,7 @@ svg { display: block; }
1245
1347
  <div class="sys-val" id="sys-uptime-val">—</div>
1246
1348
  <div class="sys-sub" id="sys-uptime-sub"></div>
1247
1349
  </div>
1350
+ <div id="sys-gpu-container"></div>
1248
1351
  </div>
1249
1352
  <div class="settings-section">
1250
1353
  <h3>Top Processes by CPU</h3>
@@ -1373,7 +1476,7 @@ let hasTmux = false;
1373
1476
  let settings = {
1374
1477
  theme: 'light', fontSize: 14, font: "'SF Mono', 'Fira Code', Consolas, monospace",
1375
1478
  cursor: 'block', blink: true, scrollback: 5000, bell: false,
1376
- mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false
1479
+ mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false, termRightClick: true
1377
1480
  };
1378
1481
  let serverPlatform = '';
1379
1482
 
@@ -1804,6 +1907,47 @@ function connectWebSocket(tab, isReconnect = false) {
1804
1907
 
1805
1908
  const sendInput = data => {
1806
1909
  if (ws.readyState !== WebSocket.OPEN) return;
1910
+ // On Enter, capture the command for history
1911
+ for (let i = 0; i < data.length; i++) {
1912
+ const ch = data.charCodeAt(i);
1913
+ if (ch === 13 || ch === 10) { // Enter
1914
+ try {
1915
+ // Prefer stored paste text (avoids single-row truncation from getLine)
1916
+ let text = tab._lastPasteText;
1917
+ tab._lastPasteText = null;
1918
+ if (text) {
1919
+ // For multi-line pastes, take only the last line (the command)
1920
+ const lines = text.split('\n');
1921
+ text = lines[lines.length - 1] || lines[lines.length - 2] || text;
1922
+ text = text.trim();
1923
+ } else {
1924
+ // Read from terminal buffer — collect all rows from prompt to cursor
1925
+ const buf = tab.term.buffer.active;
1926
+ const cursorRow = buf.baseY + buf.cursorY;
1927
+ let collected = '';
1928
+ // Scan upward to find the prompt line (contains $, #, or %)
1929
+ for (let r = cursorRow; r >= Math.max(0, cursorRow - 20); r--) {
1930
+ const row = buf.getLine(r);
1931
+ if (!row) break;
1932
+ const rowText = row.translateToString(false);
1933
+ // Prompt detection: line ends with $, #, or % followed by space
1934
+ if (/\s*[#$%]\s*$/.test(rowText)) {
1935
+ const promptEnd = rowText.search(/[#$%]\s*$/);
1936
+ if (promptEnd >= 0) {
1937
+ collected = rowText.substring(promptEnd + 1) + collected;
1938
+ }
1939
+ break;
1940
+ }
1941
+ collected = rowText + collected;
1942
+ }
1943
+ text = collected.trim();
1944
+ // Strip any trailing prompt artifacts from multi-row
1945
+ text = text.replace(/\s*[#$%]\s*$/, '').trim();
1946
+ }
1947
+ if (text) addToCmdHist(text);
1948
+ } catch {}
1949
+ }
1950
+ }
1807
1951
  const enc = new TextEncoder().encode(data);
1808
1952
  const buf = new Uint8Array(1 + enc.length);
1809
1953
  buf[0] = 0x00; buf.set(enc, 1);
@@ -2042,6 +2186,21 @@ function initTerminal(tab) {
2042
2186
  tab.closed = false;
2043
2187
  tab.textarea = term.textarea || term.element?.querySelector('.xterm-textarea, textarea');
2044
2188
 
2189
+ // Intercept paste events to use bracketed paste mode (required for TUI apps like vim, nano, mc)
2190
+ if (tab.textarea) {
2191
+ tab.textarea.addEventListener('paste', e => {
2192
+ e.preventDefault();
2193
+ const text = (e.clipboardData || window.clipboardData)?.getData('text');
2194
+ if (!text || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
2195
+ tab._lastPasteText = text;
2196
+ const bracketed = '\x1b[200~' + text + '\x1b[201~';
2197
+ const enc = new TextEncoder().encode(bracketed);
2198
+ const buf = new Uint8Array(1 + enc.length);
2199
+ buf[0] = 0x00; buf.set(enc, 1);
2200
+ tab.ws.send(buf.buffer);
2201
+ }, true);
2202
+ }
2203
+
2045
2204
  // term.onFocus() was removed from xterm.js — use native DOM event instead
2046
2205
  term.element.addEventListener('focusin', () => {
2047
2206
  if (activeTabId !== tab.id) {
@@ -2093,13 +2252,28 @@ function initTerminal(tab) {
2093
2252
 
2094
2253
  // Terminal right-click context menu
2095
2254
  term.element.addEventListener('contextmenu', e => {
2255
+ if (!settings.termRightClick) return;
2096
2256
  e.preventDefault();
2097
2257
  const menu = document.getElementById('term-ctx-menu');
2098
2258
  const sel = term.getSelection();
2099
2259
  document.getElementById('term-ctx-copy').style.opacity = sel ? '' : '0.5';
2100
- menu.style.left = Math.min(e.clientX, window.innerWidth - 170) + 'px';
2101
- menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
2260
+ // Show first to measure actual dimensions
2102
2261
  menu.style.display = 'block';
2262
+ const mw = menu.offsetWidth;
2263
+ const mh = menu.offsetHeight;
2264
+ const vw = window.innerWidth;
2265
+ const vh = window.innerHeight;
2266
+ let left = e.clientX;
2267
+ let top = e.clientY;
2268
+ // Flip horizontally if overflowing right
2269
+ if (left + mw > vw) left = Math.max(0, e.clientX - mw);
2270
+ // Flip vertically if overflowing bottom
2271
+ if (top + mh > vh) top = Math.max(0, e.clientY - mh);
2272
+ // Clamp to viewport
2273
+ left = Math.max(0, Math.min(left, vw - mw));
2274
+ top = Math.max(0, Math.min(top, vh - mh));
2275
+ menu.style.left = left + 'px';
2276
+ menu.style.top = top + 'px';
2103
2277
  });
2104
2278
 
2105
2279
  // Touch-to-mouse translation for TUI apps (mobile)
@@ -2146,18 +2320,22 @@ function initTerminal(tab) {
2146
2320
  if (!tab.closed) connectWebSocket(tab, false);
2147
2321
  });
2148
2322
 
2149
- // Scroll terminal on wheel; yield to TUI apps when mouse tracking is active
2323
+ // Scroll terminal on wheel
2150
2324
  tab.wrapper.addEventListener('wheel', e => {
2151
2325
  if (e.ctrlKey || e.metaKey) return;
2152
- // When a TUI app (vim, htop, tmux…) has mouse tracking on, let the app handle scroll
2326
+ e.preventDefault();
2327
+ // TUI apps with mouse tracking handle scroll themselves — forward the event to xterm.js
2153
2328
  try {
2154
2329
  const core = term._core;
2155
- if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive()) return;
2330
+ if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive()) {
2331
+ const vp = tab.wrapper.querySelector('.xterm-viewport');
2332
+ if (vp) vp.dispatchEvent(new WheelEvent('wheel', e));
2333
+ return;
2334
+ }
2156
2335
  } catch (_) {}
2157
2336
  const atTop = term.buffer.active.baseY === 0;
2158
2337
  const atBottom = term.buffer.active.baseY + term.rows >= term.buffer.active.length;
2159
2338
  if (tilesMode && ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom))) return;
2160
- e.preventDefault();
2161
2339
  let delta = e.deltaY;
2162
2340
  if (e.deltaMode === 1) delta *= 16;
2163
2341
  else if (e.deltaMode === 2) delta *= 16 * term.rows;
@@ -2686,24 +2864,115 @@ function renderBreadcrumb(fullPath) {
2686
2864
  let fsClipboard = null;
2687
2865
 
2688
2866
  // Terminal context menu actions (registered once)
2867
+ function hideTermCtxMenu() {
2868
+ document.getElementById('term-ctx-menu').style.display = 'none';
2869
+ document.querySelectorAll('#term-ctx-menu .ctx-submenu-wrap.open').forEach(el => el.classList.remove('open'));
2870
+ }
2689
2871
  document.getElementById('term-ctx-copy').addEventListener('click', () => {
2690
2872
  const t = getActiveTab();
2691
- if (t?.term) { const sel = t.term.getSelection(); if (sel) navigator.clipboard.writeText(sel); }
2692
- document.getElementById('term-ctx-menu').style.display = 'none';
2873
+ if (t?.term) {
2874
+ const sel = t.term.getSelection();
2875
+ if (sel) {
2876
+ navigator.clipboard.writeText(sel).then(() => {
2877
+ toast('Copied to clipboard', 'success');
2878
+ }).catch(() => {
2879
+ document.execCommand('copy');
2880
+ toast('Copied to clipboard', 'success');
2881
+ });
2882
+ }
2883
+ }
2884
+ hideTermCtxMenu();
2693
2885
  });
2694
2886
  document.getElementById('term-ctx-paste').addEventListener('click', () => {
2695
2887
  pasteToTerminal();
2696
- document.getElementById('term-ctx-menu').style.display = 'none';
2888
+ hideTermCtxMenu();
2697
2889
  });
2698
2890
  document.getElementById('term-ctx-select-all').addEventListener('click', () => {
2699
2891
  const t = getActiveTab();
2700
2892
  if (t?.term) t.term.selectAll();
2701
- document.getElementById('term-ctx-menu').style.display = 'none';
2893
+ hideTermCtxMenu();
2894
+ });
2895
+ document.getElementById('term-ctx-select-line').addEventListener('click', () => {
2896
+ selectTermLine();
2897
+ hideTermCtxMenu();
2898
+ });
2899
+ document.getElementById('term-ctx-find').addEventListener('click', () => {
2900
+ toggleSearch();
2901
+ hideTermCtxMenu();
2902
+ });
2903
+ document.getElementById('term-ctx-zoom-in').addEventListener('click', () => {
2904
+ applyFontSize((settings.fontSize || 14) + 1);
2905
+ hideTermCtxMenu();
2906
+ });
2907
+ document.getElementById('term-ctx-zoom-out').addEventListener('click', () => {
2908
+ applyFontSize((settings.fontSize || 14) - 1);
2909
+ hideTermCtxMenu();
2910
+ });
2911
+ document.getElementById('term-ctx-new-tab').addEventListener('click', () => {
2912
+ const t = getActiveTab();
2913
+ newTab(null, null, t?.cwd || currentPath);
2914
+ hideTermCtxMenu();
2915
+ });
2916
+ document.getElementById('term-ctx-copy-cwd').addEventListener('click', () => {
2917
+ const t = getActiveTab();
2918
+ const cwd = t?.cwd || currentPath;
2919
+ if (cwd) {
2920
+ navigator.clipboard.writeText(cwd).then(() => {
2921
+ toast('Path copied', 'success');
2922
+ }).catch(() => {
2923
+ document.execCommand('copy');
2924
+ toast('Path copied', 'success');
2925
+ });
2926
+ }
2927
+ hideTermCtxMenu();
2928
+ });
2929
+ document.getElementById('term-ctx-bookmark').addEventListener('click', () => {
2930
+ const t = getActiveTab();
2931
+ const cwd = t?.cwd || currentPath;
2932
+ if (cwd) {
2933
+ const bm = getBookmarks();
2934
+ if (bm.some(b => b.path === cwd)) { toast('Already bookmarked', 'info'); }
2935
+ else {
2936
+ const sep = cwd.includes('\\') ? '\\' : '/';
2937
+ bm.push({ name: cwd.split(sep).filter(Boolean).pop() || cwd, path: cwd });
2938
+ saveBookmarks(bm);
2939
+ toast('Bookmarked', 'success');
2940
+ }
2941
+ }
2942
+ hideTermCtxMenu();
2943
+ });
2944
+ document.getElementById('term-ctx-interrupt').addEventListener('click', () => {
2945
+ sendKey('\x03');
2946
+ hideTermCtxMenu();
2947
+ });
2948
+ document.getElementById('term-ctx-rename').addEventListener('click', () => {
2949
+ const t = getActiveTab();
2950
+ if (t?.el) {
2951
+ const span = t.el.querySelector('.tab-title');
2952
+ if (span) span.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
2953
+ }
2954
+ hideTermCtxMenu();
2955
+ });
2956
+ document.getElementById('term-ctx-scroll-top').addEventListener('click', () => {
2957
+ const t = getActiveTab();
2958
+ if (t?.term) t.term.scrollToTop();
2959
+ hideTermCtxMenu();
2960
+ });
2961
+ document.getElementById('term-ctx-scroll-bottom').addEventListener('click', () => {
2962
+ const t = getActiveTab();
2963
+ if (t?.term) t.term.scrollToBottom();
2964
+ hideTermCtxMenu();
2702
2965
  });
2703
2966
  document.getElementById('term-ctx-clear').addEventListener('click', () => {
2704
2967
  const t = getActiveTab();
2705
2968
  if (t?.term) t.term.clear();
2706
- document.getElementById('term-ctx-menu').style.display = 'none';
2969
+ hideTermCtxMenu();
2970
+ });
2971
+ // Submenu toggle on click
2972
+ document.querySelector('#term-ctx-menu .ctx-submenu-wrap > .ctx-item').addEventListener('click', e => {
2973
+ e.stopPropagation();
2974
+ const wrap = e.currentTarget.closest('.ctx-submenu-wrap');
2975
+ wrap.classList.toggle('open');
2707
2976
  });
2708
2977
 
2709
2978
  function showCtxMenu(e, file) {
@@ -2718,24 +2987,21 @@ function showCtxMenu(e, file) {
2718
2987
  document.getElementById('ctx-zip').style.display = '';
2719
2988
  document.getElementById('ctx-folder-size').style.display = file.isDir ? '' : 'none';
2720
2989
  document.getElementById('ctx-extract').style.display = file.isDir ? 'none' : (file.ext === '.zip' ? '' : 'none');
2990
+ // Measure after content is set
2721
2991
  const mw = menu.offsetWidth, mh = menu.offsetHeight;
2722
- let left = Math.min(e.clientX, window.innerWidth - mw - 8);
2723
- let top = Math.min(e.clientY, window.innerHeight - mh - 8);
2724
- if (left < 0) left = 8;
2725
- if (top < 0) top = 8;
2992
+ const vw = window.innerWidth, vh = window.innerHeight;
2993
+ let left = e.clientX, top = e.clientY;
2994
+ // Flip horizontally if overflowing right
2995
+ if (left + mw > vw) left = Math.max(0, e.clientX - mw);
2996
+ // Flip vertically if overflowing bottom
2997
+ if (top + mh > vh) top = Math.max(0, e.clientY - mh);
2998
+ // Clamp to viewport
2999
+ left = Math.max(0, Math.min(left, vw - mw));
3000
+ top = Math.max(0, Math.min(top, vh - mh));
2726
3001
  menu.style.left = left + 'px';
2727
3002
  menu.style.top = top + 'px';
2728
3003
  menu.classList.add('open');
2729
3004
  menu.style.display = '';
2730
- requestAnimationFrame(() => {
2731
- const rect = menu.getBoundingClientRect();
2732
- if (rect.right > window.innerWidth) {
2733
- menu.style.left = Math.max(0, window.innerWidth - rect.width - 8) + 'px';
2734
- }
2735
- if (rect.bottom > window.innerHeight) {
2736
- menu.style.top = Math.max(0, window.innerHeight - rect.height - 8) + 'px';
2737
- }
2738
- });
2739
3005
  }
2740
3006
 
2741
3007
  document.addEventListener('click', () => {
@@ -2746,12 +3012,20 @@ document.addEventListener('click', () => {
2746
3012
 
2747
3013
  document.getElementById('ctx-open').onclick = () => { if (!ctxTarget) return; if (isImageFile(ctxTarget.path)) openImageViewer(ctxTarget.path); else loadFiles(ctxTarget.path); };
2748
3014
  document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
2749
- document.getElementById('ctx-download').onclick = () => ctxTarget && downloadFile(ctxTarget.path);
3015
+ document.getElementById('ctx-download').onclick = () => {
3016
+ if (selectedFiles.length > 0) { downloadSelected(); return; }
3017
+ if (ctxTarget) downloadFile(ctxTarget.path);
3018
+ };
2750
3019
  document.getElementById('ctx-zip').onclick = async () => {
2751
- if (!ctxTarget) return;
3020
+ const files = selectedFiles.length > 0 ? selectedFiles : (ctxTarget ? [ctxTarget.path] : []);
3021
+ if (!files.length) return;
2752
3022
  const t = toast('Zipping…', 'info');
2753
- await zipFile(ctxTarget.path, ctxTarget.name);
3023
+ for (const p of files) {
3024
+ const name = p.split(/[\\/]/).pop();
3025
+ await zipFile(p, name);
3026
+ }
2754
3027
  t.remove();
3028
+ clearSelection();
2755
3029
  };
2756
3030
  document.getElementById('ctx-extract').onclick = async () => {
2757
3031
  if (!ctxTarget) return;
@@ -2776,10 +3050,29 @@ document.getElementById('ctx-folder-size').onclick = async () => {
2776
3050
  };
2777
3051
  document.getElementById('ctx-rename').onclick = () => ctxTarget && startRename(ctxTarget.path, ctxTarget.name);
2778
3052
  document.getElementById('ctx-copy-path').onclick = () => { if (!ctxTarget) return; navigator.clipboard.writeText(ctxTarget.path).then(() => toast('Path copied')).catch(() => toast('Copy failed', 'error')); };
2779
- document.getElementById('ctx-delete').onclick = () => ctxTarget && deleteFile(ctxTarget);
3053
+ document.getElementById('ctx-delete').onclick = () => {
3054
+ if (selectedFiles.length > 0) { deleteSelected(); return; }
3055
+ if (ctxTarget) deleteFile(ctxTarget);
3056
+ };
2780
3057
  document.getElementById('ctx-open-term').onclick = () => { if (!ctxTarget) return; newTab(`Term @ ${ctxTarget.name}`, null, ctxTarget.path); };
2781
- document.getElementById('ctx-copy').onclick = () => { if (!ctxTarget) return; fsClipboard = { action: 'copy', path: ctxTarget.path, name: ctxTarget.name }; updatePasteUI(); toast('Copied: ' + ctxTarget.name, 'success'); };
2782
- document.getElementById('ctx-cut').onclick = () => { if (!ctxTarget) return; fsClipboard = { action: 'cut', path: ctxTarget.path, name: ctxTarget.name }; updatePasteUI(); toast('Cut: ' + ctxTarget.name, 'success'); };
3058
+ document.getElementById('ctx-copy').onclick = () => {
3059
+ const files = selectedFiles.length > 0
3060
+ ? selectedFiles.map(p => ({ path: p, name: p.split(/[\\/]/).pop() }))
3061
+ : (ctxTarget ? [{ path: ctxTarget.path, name: ctxTarget.name }] : []);
3062
+ if (!files.length) return;
3063
+ fsClipboard = { action: 'copy', files };
3064
+ updatePasteUI();
3065
+ toast(`Copied ${files.length} item(s)`, 'success');
3066
+ };
3067
+ document.getElementById('ctx-cut').onclick = () => {
3068
+ const files = selectedFiles.length > 0
3069
+ ? selectedFiles.map(p => ({ path: p, name: p.split(/[\\/]/).pop() }))
3070
+ : (ctxTarget ? [{ path: ctxTarget.path, name: ctxTarget.name }] : []);
3071
+ if (!files.length) return;
3072
+ fsClipboard = { action: 'cut', files };
3073
+ updatePasteUI();
3074
+ toast(`Cut ${files.length} item(s)`, 'success');
3075
+ };
2783
3076
  document.getElementById('ctx-paste').onclick = () => { if (fsClipboard) pasteFile(currentPath); };
2784
3077
 
2785
3078
  function updatePasteUI() {
@@ -2794,38 +3087,44 @@ let _pasteBusy = false;
2794
3087
  async function pasteFile(destDir, conflictMode) {
2795
3088
  if (!fsClipboard || _pasteBusy) return;
2796
3089
  _pasteBusy = true;
2797
- const label = fsClipboard.action === 'copy' ? 'Copying' : 'Moving';
2798
- const toastEl = toast(label + '…', 'info');
2799
- const dest = joinPath(destDir, fsClipboard.name);
2800
- const ep = fsClipboard.action === 'copy' ? '/api/files/copy' : '/api/files/move';
2801
- const body = { source: fsClipboard.path, destination: dest };
2802
- if (conflictMode) body.conflict = conflictMode;
2803
- const r = await api(ep, {
2804
- method: 'POST',
2805
- headers: { 'Content-Type': 'application/json' },
2806
- body: JSON.stringify(body)
2807
- });
2808
- if (r.conflict) {
2809
- toastEl.remove();
2810
- _conflictResolve = { destDir, conflictMode: null };
2811
- document.getElementById('conflict-name').textContent = r.name;
2812
- document.getElementById('conflict-dir-hint').style.display = r.isDir ? '' : 'none';
2813
- document.getElementById('conflict-merge').style.display = r.isDir ? '' : 'none';
2814
- openOverlay('conflict-overlay');
2815
- return;
2816
- }
2817
- if (r.success) {
3090
+ try {
3091
+ // Support both old single-file and new multi-file format
3092
+ const files = fsClipboard.files || [{ path: fsClipboard.path, name: fsClipboard.name }];
3093
+ const label = fsClipboard.action === 'copy' ? 'Copying' : 'Moving';
3094
+ const toastEl = toast(label + '…', 'info');
3095
+ const ep = fsClipboard.action === 'copy' ? '/api/files/copy' : '/api/files/move';
3096
+ let succeeded = 0, failed = 0;
3097
+ for (const file of files) {
3098
+ const dest = joinPath(destDir, file.name);
3099
+ const body = { source: file.path, destination: dest };
3100
+ if (conflictMode) body.conflict = conflictMode;
3101
+ const r = await api(ep, {
3102
+ method: 'POST',
3103
+ headers: { 'Content-Type': 'application/json' },
3104
+ body: JSON.stringify(body)
3105
+ });
3106
+ if (r.conflict) {
3107
+ toastEl.remove();
3108
+ _conflictResolve = { destDir, conflictMode: null };
3109
+ document.getElementById('conflict-name').textContent = r.name;
3110
+ document.getElementById('conflict-dir-hint').style.display = r.isDir ? '' : 'none';
3111
+ document.getElementById('conflict-merge').style.display = r.isDir ? '' : 'none';
3112
+ openOverlay('conflict-overlay');
3113
+ return;
3114
+ }
3115
+ if (r.success) succeeded++;
3116
+ else failed++;
3117
+ }
2818
3118
  toastEl.remove();
2819
- const label = fsClipboard.action === 'copy' ? 'Pasted' : 'Moved';
2820
- if (r.skipped) toast(label + ' (skipped existing)', 'info');
2821
- else toast(label, 'success');
3119
+ const opLabel = fsClipboard.action === 'copy' ? 'Pasted' : 'Moved';
3120
+ if (failed === 0) toast(opLabel + ` (${succeeded} item(s))`, 'success');
3121
+ else if (succeeded === 0) toast(opLabel + ' failed', 'error');
3122
+ else toast(`${opLabel} ${succeeded}, ${failed} failed`, 'warning');
2822
3123
  if (fsClipboard.action === 'cut') { fsClipboard = null; updatePasteUI(); }
2823
3124
  refreshFiles();
2824
- } else {
2825
- toastEl.remove();
2826
- toast(r.error || 'Operation failed', 'error');
3125
+ } finally {
3126
+ _pasteBusy = false;
2827
3127
  }
2828
- _pasteBusy = false;
2829
3128
  }
2830
3129
 
2831
3130
  function resolveConflict(mode) {
@@ -2883,18 +3182,24 @@ async function deleteFile(file) {
2883
3182
  else toast(r.error, 'error');
2884
3183
  }
2885
3184
 
2886
- async function downloadFile(path) {
3185
+ async function downloadFile(filePath) {
2887
3186
  try {
2888
- const r = await fetch(`/api/files/download?path=${encodeURIComponent(path)}`, {
3187
+ const r = await fetch(`/api/files/download?path=${encodeURIComponent(filePath)}`, {
2889
3188
  headers: { 'x-pin-token': authToken }
2890
3189
  });
2891
3190
  if (!r.ok) { toast('Download failed', 'error'); return; }
3191
+ const ct = r.headers.get('content-type') || '';
2892
3192
  const blob = await r.blob();
2893
3193
  const url = URL.createObjectURL(blob);
2894
3194
  const a = document.createElement('a');
2895
3195
  a.href = url;
2896
- a.download = path.split(/[\\/]/).pop() || 'download';
3196
+ let name = filePath.split(/[\\/]/).pop() || 'download';
3197
+ // Server returns application/zip for directories — append .zip if missing
3198
+ if (ct.includes('zip') && !name.endsWith('.zip')) name += '.zip';
3199
+ a.download = name;
3200
+ document.body.appendChild(a);
2897
3201
  a.click();
3202
+ a.remove();
2898
3203
  URL.revokeObjectURL(url);
2899
3204
  } catch(e) { console.warn('Download failed:', e); toast('Download failed', 'error'); }
2900
3205
  }
@@ -3000,10 +3305,14 @@ async function deleteSelected() {
3000
3305
  }
3001
3306
 
3002
3307
  async function downloadSelected() {
3003
- for (const p of [...selectedFiles]) {
3004
- await downloadFile(p);
3308
+ const files = [...selectedFiles];
3309
+ if (!files.length) return;
3310
+ toast(`Downloading ${files.length} item(s)…`, 'info');
3311
+ for (let i = 0; i < files.length; i++) {
3312
+ await downloadFile(files[i]);
3313
+ // Small delay between downloads to avoid browser popup blocking
3314
+ if (i < files.length - 1) await new Promise(r => setTimeout(r, 300));
3005
3315
  }
3006
- toast('Downloads started', 'success');
3007
3316
  clearSelection();
3008
3317
  }
3009
3318
 
@@ -3644,7 +3953,7 @@ function loadSettings() {
3644
3953
  document.getElementById('s-scrollback').value = settings.scrollback;
3645
3954
  document.getElementById('s-font').value = settings.font;
3646
3955
  document.getElementById('s-cursor').value = settings.cursor;
3647
- syncToggle('blink'); syncToggle('bell'); syncToggle('mobilekeys'); syncToggle('confirmclose'); syncToggle('datasaver');
3956
+ syncToggle('blink'); syncToggle('bell'); syncToggle('mobilekeys'); syncToggle('confirmclose'); syncToggle('datasaver'); syncToggle('termRightClick');
3648
3957
  syncKeepAwakeUI();
3649
3958
  if (settings.keepAwake && hasWakeLock) { requestWakeLock(); }
3650
3959
  if (isElectron) {
@@ -4191,6 +4500,11 @@ function setupKeyboardShortcuts() {
4191
4500
  if (ctrl && e.shiftKey && e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); }
4192
4501
  if (ctrl && e.shiftKey && e.key === 'r') { e.preventDefault(); refreshPreview(); }
4193
4502
  if (e.key === 'Escape') {
4503
+ const termMenu = document.getElementById('term-ctx-menu');
4504
+ if (termMenu && termMenu.style.display !== 'none') {
4505
+ termMenu.style.display = 'none';
4506
+ return;
4507
+ }
4194
4508
  if (document.getElementById('ctx-menu').classList.contains('open')) {
4195
4509
  document.getElementById('ctx-menu').classList.remove('open');
4196
4510
  return;
@@ -4350,6 +4664,7 @@ function pasteToTerminal() {
4350
4664
  const buf = new Uint8Array(1 + enc.length);
4351
4665
  buf[0] = 0x00; buf.set(enc, 1);
4352
4666
  tab.ws.send(buf.buffer);
4667
+ tab.term?.focus();
4353
4668
  toast('Pasted to terminal', 'success');
4354
4669
  }
4355
4670
  };
@@ -4366,14 +4681,16 @@ function fallbackPaste(callback) {
4366
4681
  _pasteInput = document.createElement('textarea');
4367
4682
  _pasteInput.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1';
4368
4683
  document.body.appendChild(_pasteInput);
4369
- _pasteInput.addEventListener('input', () => {
4370
- callback(_pasteInput.value);
4371
- _pasteInput.value = '';
4684
+ _pasteInput.addEventListener('paste', () => {
4685
+ setTimeout(() => {
4686
+ callback(_pasteInput.value);
4687
+ _pasteInput.value = '';
4688
+ }, 0);
4372
4689
  });
4373
4690
  }
4374
4691
  _pasteInput.value = '';
4375
4692
  _pasteInput.focus();
4376
- toast('Tap & hold to paste', 'info');
4693
+ document.execCommand('paste');
4377
4694
  }
4378
4695
 
4379
4696
  function selectAllTerm() {
@@ -4915,9 +5232,8 @@ function toggleCmdLib() {
4915
5232
  panel.classList.toggle('open');
4916
5233
  document.getElementById('cmd-lib-toggle').classList.toggle('active', !isOpen);
4917
5234
  if (!isOpen) {
4918
- renderCmdLib();
4919
- document.getElementById('cmd-lib-search').value = '';
4920
- document.getElementById('cmd-lib-search').focus();
5235
+ loadHistMax();
5236
+ switchCmdTab('library');
4921
5237
  setTimeout(() => {
4922
5238
  document.addEventListener('click', closeCmdLibOnClickOutside, true);
4923
5239
  }, 0);
@@ -4999,7 +5315,13 @@ function renderCmdLib(filter) {
4999
5315
  item.appendChild(delBtn);
5000
5316
  }
5001
5317
 
5002
- item.addEventListener('click', () => runCmdLib(c.cmd));
5318
+ item.addEventListener('click', () => {
5319
+ const tab = getActiveTab();
5320
+ if (!tab?.term) return;
5321
+ tab.term.focus();
5322
+ document.execCommand('insertText', false, c.cmd);
5323
+ toggleCmdLib();
5324
+ });
5003
5325
  section.appendChild(item);
5004
5326
  });
5005
5327
  list.appendChild(section);
@@ -5023,6 +5345,7 @@ function runCmdLib(cmd) {
5023
5345
  buf.set(enc, 1);
5024
5346
  tab.ws.send(buf.buffer);
5025
5347
  tab.term?.focus();
5348
+ addToCmdHist(cmd);
5026
5349
  toast('Running: ' + cmd, 'info');
5027
5350
  }
5028
5351
 
@@ -5053,6 +5376,155 @@ function deleteCustomCmd(name, cmd) {
5053
5376
  toast('Command removed', 'info');
5054
5377
  }
5055
5378
 
5379
+ // ═══════════════════════════════════════════════════════
5380
+ // COMMAND HISTORY (server-side storage)
5381
+ // ═══════════════════════════════════════════════════════
5382
+ let cmdHistMax = 50;
5383
+ let _cmdHistCache = [];
5384
+
5385
+ function histHeaders() {
5386
+ return { 'Content-Type': 'application/json', 'x-pin-token': localStorage.getItem('wt-pin') || '' };
5387
+ }
5388
+ function histHeadersDelete() {
5389
+ return { 'x-pin-token': localStorage.getItem('wt-pin') || '' };
5390
+ }
5391
+ async function getCmdHist() {
5392
+ try {
5393
+ const r = await fetch('/api/history', { headers: histHeadersDelete() });
5394
+ if (!r.ok) return _cmdHistCache;
5395
+ const data = await r.json();
5396
+ _cmdHistCache = data.history || [];
5397
+ if (data.max) cmdHistMax = data.max;
5398
+ return _cmdHistCache;
5399
+ } catch { return _cmdHistCache; }
5400
+ }
5401
+ function loadHistMax() {
5402
+ getCmdHist().then(() => {
5403
+ const el = document.getElementById('cmd-hist-max');
5404
+ if (el) el.value = cmdHistMax;
5405
+ });
5406
+ }
5407
+ async function addToCmdHist(cmd) {
5408
+ if (!cmd || !cmd.trim()) return;
5409
+ // Strip ANSI escapes and control characters, keep only printable + space + tab
5410
+ const clean = cmd.replace(/\x1b\[[0-9;]*[a-zA-Z~]/g, '')
5411
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
5412
+ .replace(/[\x00-\x08\x0e-\x1f]/g, '')
5413
+ .trim();
5414
+ if (!clean) return;
5415
+ try {
5416
+ await fetch('/api/history', { method: 'POST', headers: histHeaders(), body: JSON.stringify({ cmd: clean, max: cmdHistMax }) });
5417
+ await getCmdHist();
5418
+ } catch {}
5419
+ }
5420
+ async function removeCmdHistItem(idx) {
5421
+ try {
5422
+ await fetch('/api/history/' + idx, { method: 'DELETE', headers: histHeadersDelete() });
5423
+ await getCmdHist();
5424
+ renderCmdHist();
5425
+ } catch {}
5426
+ }
5427
+ async function clearCmdHist() {
5428
+ try {
5429
+ await fetch('/api/history', { method: 'DELETE', headers: histHeadersDelete() });
5430
+ _cmdHistCache = [];
5431
+ renderCmdHist();
5432
+ toast('History cleared', 'info');
5433
+ } catch {}
5434
+ }
5435
+ async function updateHistMax(val) {
5436
+ cmdHistMax = Math.max(10, Math.min(500, val || 50));
5437
+ try {
5438
+ await fetch('/api/history', { method: 'POST', headers: histHeaders(), body: JSON.stringify({ cmd: '', max: cmdHistMax }) });
5439
+ } catch {}
5440
+ }
5441
+ function switchCmdTab(tab) {
5442
+ const libBtn = document.getElementById('cmd-tab-lib');
5443
+ const histBtn = document.getElementById('cmd-tab-hist');
5444
+ const libList = document.getElementById('cmd-lib-list');
5445
+ const histList = document.getElementById('cmd-hist-list');
5446
+ const histHeader = document.getElementById('cmd-hist-header');
5447
+ const footer = document.getElementById('cmd-lib-footer');
5448
+ const search = document.getElementById('cmd-lib-search');
5449
+ if (tab === 'library') {
5450
+ libBtn.classList.add('active');
5451
+ histBtn.classList.remove('active');
5452
+ libList.style.display = '';
5453
+ histList.style.display = 'none';
5454
+ histHeader.style.display = 'none';
5455
+ footer.style.display = '';
5456
+ search.placeholder = 'Filter commands…';
5457
+ search.oninput = () => filterCmdLib();
5458
+ filterCmdLib();
5459
+ } else {
5460
+ histBtn.classList.add('active');
5461
+ libBtn.classList.remove('active');
5462
+ histList.style.display = 'flex';
5463
+ libList.style.display = 'none';
5464
+ histHeader.style.display = '';
5465
+ footer.style.display = 'none';
5466
+ search.placeholder = 'Filter history…';
5467
+ search.oninput = () => renderCmdHist();
5468
+ renderCmdHist();
5469
+ }
5470
+ search.value = '';
5471
+ search.focus();
5472
+ }
5473
+ async function renderCmdHist() {
5474
+ const list = document.getElementById('cmd-hist-list');
5475
+ const hist = await getCmdHist();
5476
+ const q = (document.getElementById('cmd-lib-search').value || '').toLowerCase();
5477
+ const filtered = q ? hist.filter(h => h.cmd.toLowerCase().includes(q)) : hist;
5478
+
5479
+ list.innerHTML = '';
5480
+ if (!filtered.length) {
5481
+ list.innerHTML = '<div id="cmd-hist-empty">No commands run yet</div>';
5482
+ return;
5483
+ }
5484
+
5485
+ filtered.forEach((h, i) => {
5486
+ const realIdx = q ? hist.indexOf(h) : i;
5487
+ const item = document.createElement('div');
5488
+ item.className = 'cmd-hist-item';
5489
+
5490
+ const cmdSpan = document.createElement('span');
5491
+ cmdSpan.className = 'cmd-hist-cmd';
5492
+ cmdSpan.textContent = h.cmd;
5493
+ cmdSpan.title = h.cmd;
5494
+ item.appendChild(cmdSpan);
5495
+
5496
+ const timeSpan = document.createElement('span');
5497
+ timeSpan.className = 'cmd-hist-time';
5498
+ const ago = Date.now() - h.time;
5499
+ timeSpan.textContent = ago < 60000 ? 'just now' : ago < 3600000 ? Math.floor(ago / 60000) + 'm ago' : ago < 86400000 ? Math.floor(ago / 3600000) + 'h ago' : new Date(h.time).toLocaleDateString();
5500
+ if (h.count > 1) timeSpan.textContent = '×' + h.count + ' ' + timeSpan.textContent;
5501
+ item.appendChild(timeSpan);
5502
+
5503
+ const runBtn = document.createElement('button');
5504
+ runBtn.className = 'cmd-hist-run';
5505
+ runBtn.title = 'Run in terminal';
5506
+ runBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6,3 20,12 6,21"/></svg>';
5507
+ runBtn.addEventListener('click', e => { e.stopPropagation(); runCmdLib(h.cmd); });
5508
+ item.appendChild(runBtn);
5509
+
5510
+ const delBtn = document.createElement('button');
5511
+ delBtn.className = 'cmd-hist-del';
5512
+ delBtn.title = 'Remove';
5513
+ delBtn.textContent = '×';
5514
+ delBtn.addEventListener('click', e => { e.stopPropagation(); removeCmdHistItem(realIdx); });
5515
+ item.appendChild(delBtn);
5516
+
5517
+ item.addEventListener('click', () => {
5518
+ const tab = getActiveTab();
5519
+ if (!tab?.term) return;
5520
+ tab.term.focus();
5521
+ document.execCommand('insertText', false, h.cmd);
5522
+ toggleCmdLib();
5523
+ });
5524
+ list.appendChild(item);
5525
+ });
5526
+ }
5527
+
5056
5528
  // ═══════════════════════════════════════════════════════
5057
5529
  // SYSTEM STATS
5058
5530
  // ═══════════════════════════════════════════════════════
@@ -5112,6 +5584,31 @@ async function refreshSystemStats(showLoading) {
5112
5584
  document.getElementById('sys-uptime-val').textContent = fmtUptime(data.uptime);
5113
5585
  document.getElementById('sys-uptime-sub').textContent = data.hostname + ' · ' + data.platform;
5114
5586
 
5587
+ const gpuContainer = document.getElementById('sys-gpu-container');
5588
+ gpuContainer.innerHTML = '';
5589
+ const gpuList = data.gpus || (data.gpu ? [data.gpu] : []);
5590
+ if (gpuList.length) {
5591
+ gpuList.forEach((g, i) => {
5592
+ const card = document.createElement('div');
5593
+ card.className = 'sys-card';
5594
+ const label = gpuList.length > 1 ? `GPU ${i + 1}` : 'GPU';
5595
+ let sub = '';
5596
+ if (g.memTotal > 0) sub += formatSize(g.memTotal * 1024 * 1024) + ' VRAM';
5597
+ else if (g.memTotal === 0 && g.driver !== 'lspci') sub += 'Shared memory';
5598
+ if (g.utilization != null) sub += (sub ? ' · ' : '') + g.utilization + '% util';
5599
+ if (g.temp) sub += (sub ? ' · ' : '') + g.temp + '°C';
5600
+ if (g.driver && g.driver !== 'nvidia' && g.driver !== 'macos' && g.driver !== 'lspci') sub += (sub ? ' · ' : '') + 'Driver: ' + g.driver;
5601
+ let barHtml = '';
5602
+ if (g.memTotal > 0 && g.memUsed) {
5603
+ const pct = Math.round((g.memUsed / g.memTotal) * 100);
5604
+ const cls = pct > 80 ? ' danger' : pct > 50 ? ' warn' : '';
5605
+ barHtml = `<div class="sys-bar"><div class="sys-bar-fill${cls}" style="width:${Math.min(pct, 100)}%"></div></div>`;
5606
+ }
5607
+ card.innerHTML = `<h3>${label}</h3><div class="sys-val">${g.name}</div><div class="sys-sub">${sub || 'No details available'}</div>${barHtml}`;
5608
+ gpuContainer.appendChild(card);
5609
+ });
5610
+ }
5611
+
5115
5612
  const tbody = document.getElementById('sys-proc-body');
5116
5613
  tbody.innerHTML = '';
5117
5614
  if (data.processes) {
package/server.js CHANGED
@@ -48,7 +48,7 @@ const path = require('path');
48
48
  const os = require('os');
49
49
  const crypto = require('crypto');
50
50
  const { execSync, execFileSync, spawn } = require('child_process');
51
- const archiver = require('archiver');
51
+ const { ZipArchive } = require('archiver');
52
52
  const yauzl = require('yauzl');
53
53
  const https = require('https');
54
54
  const http = require('http');
@@ -127,6 +127,14 @@ const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WOR
127
127
  app.use(express.json({ limit: '50mb' }));
128
128
  app.use(express.static(path.join(__dirname, 'public')));
129
129
 
130
+ // Handle malformed JSON gracefully
131
+ app.use((err, req, res, next) => {
132
+ if (err.type === 'entity.parse.failed') {
133
+ return res.status(400).json({ error: 'Invalid JSON' });
134
+ }
135
+ next(err);
136
+ });
137
+
130
138
  // Security headers
131
139
  app.use((req, res, next) => {
132
140
  res.setHeader('X-Content-Type-Options', 'nosniff');
@@ -243,7 +251,7 @@ async function dirSize(dir) {
243
251
  function createZipArchive(entries, zipPath) {
244
252
  return new Promise((resolve, reject) => {
245
253
  const output = fs.createWriteStream(zipPath);
246
- const archive = archiver('zip', { zlib: { level: 6 } });
254
+ const archive = new ZipArchive({ zlib: { level: 6 } });
247
255
  output.on('close', () => resolve());
248
256
  output.on('error', reject);
249
257
  archive.on('error', reject);
@@ -262,7 +270,7 @@ function createZipArchive(entries, zipPath) {
262
270
  }
263
271
 
264
272
  function streamZipDirectory(dirPath, res) {
265
- const archive = archiver('zip', { zlib: { level: 6 } });
273
+ const archive = new ZipArchive({ zlib: { level: 6 } });
266
274
  archive.on('error', err => {
267
275
  if (!res.headersSent) res.status(500).json({ error: err.message });
268
276
  else res.end();
@@ -1205,6 +1213,65 @@ app.delete('/api/clipboard', checkPin, (req, res) => {
1205
1213
  res.json({ success: true });
1206
1214
  });
1207
1215
 
1216
+ // ── Command history (server-side, persists across sessions) ────────────
1217
+ const HISTORY_FILE = path.join(__dirname, '.cmdhist.json');
1218
+ let cmdHistory = [];
1219
+ let cmdHistMax = 50;
1220
+
1221
+ function loadCmdHistory() {
1222
+ try { cmdHistory = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8')); } catch { cmdHistory = []; }
1223
+ }
1224
+ function saveCmdHistory() {
1225
+ try { fs.writeFileSync(HISTORY_FILE, JSON.stringify(cmdHistory)); } catch {}
1226
+ }
1227
+ loadCmdHistory();
1228
+
1229
+ app.get('/api/history', checkPin, (req, res) => {
1230
+ res.json({ history: cmdHistory, max: cmdHistMax });
1231
+ });
1232
+
1233
+ app.post('/api/history', checkPin, (req, res) => {
1234
+ try {
1235
+ const { cmd, max } = req.body;
1236
+ if (typeof max === 'number' && max >= 10 && max <= 500) {
1237
+ cmdHistMax = max;
1238
+ }
1239
+ if (!cmd || typeof cmd !== 'string' || !cmd.trim()) {
1240
+ // No command — just updating max
1241
+ saveCmdHistory();
1242
+ return res.json({ success: true, history: cmdHistory, max: cmdHistMax });
1243
+ }
1244
+ const clean = cmd.trim();
1245
+ if (cmdHistory.length && cmdHistory[0].cmd === clean) {
1246
+ cmdHistory[0].time = Date.now();
1247
+ cmdHistory[0].count = (cmdHistory[0].count || 1) + 1;
1248
+ } else {
1249
+ cmdHistory.unshift({ cmd: clean, time: Date.now(), count: 1 });
1250
+ }
1251
+ if (cmdHistory.length > cmdHistMax) cmdHistory.length = cmdHistMax;
1252
+ saveCmdHistory();
1253
+ res.json({ success: true, history: cmdHistory });
1254
+ } catch (e) {
1255
+ res.status(500).json({ error: e.message });
1256
+ }
1257
+ });
1258
+
1259
+ app.delete('/api/history', checkPin, (req, res) => {
1260
+ cmdHistory = [];
1261
+ saveCmdHistory();
1262
+ res.json({ success: true });
1263
+ });
1264
+
1265
+ app.delete('/api/history/:index', checkPin, (req, res) => {
1266
+ const idx = parseInt(req.params.index);
1267
+ if (isNaN(idx) || idx < 0 || idx >= cmdHistory.length) {
1268
+ return res.status(400).json({ error: 'invalid index' });
1269
+ }
1270
+ cmdHistory.splice(idx, 1);
1271
+ saveCmdHistory();
1272
+ res.json({ success: true, history: cmdHistory });
1273
+ });
1274
+
1208
1275
  // ── Session persistence via tmux ──────────────────────────────────────
1209
1276
  const TMUX = (() => { try { return execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { return null; } })();
1210
1277
 
@@ -1538,12 +1605,69 @@ app.get('/api/system', checkPin, async (req, res) => {
1538
1605
  }
1539
1606
  } catch {}
1540
1607
 
1608
+ let gpus = [];
1609
+ try {
1610
+ if (os.platform() === 'linux') {
1611
+ // Try nvidia-smi first (NVIDIA GPUs — supports multi-GPU)
1612
+ try {
1613
+ const nvOut = await spawnRead('nvidia-smi', ['--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu', '--format=csv,noheader,nounits']);
1614
+ if (nvOut && nvOut.trim()) {
1615
+ for (const line of nvOut.trim().split('\n')) {
1616
+ const parts = line.split(',').map(s => s.trim());
1617
+ if (parts.length >= 6 && parts[0]) {
1618
+ gpus.push({ name: parts[0], memTotal: +parts[1] || 0, memUsed: +parts[2] || 0, memFree: +parts[3] || 0, utilization: +parts[4] || 0, temp: +parts[5] || 0, driver: 'nvidia' });
1619
+ }
1620
+ }
1621
+ }
1622
+ } catch {}
1623
+ // Fallback: lspci for any GPU (Intel, AMD, etc.)
1624
+ if (!gpus.length) {
1625
+ try {
1626
+ const lspciOut = await spawnRead('lspci', []);
1627
+ if (lspciOut && lspciOut.trim()) {
1628
+ const lines = lspciOut.split('\n').filter(l => /VGA|3D|Display/i.test(l));
1629
+ for (const line of lines) {
1630
+ const name = line.replace(/^[\da-f]+:[\da-f]+\.[\da-f]+\s+/, '').trim();
1631
+ if (name) gpus.push({ name, driver: 'lspci' });
1632
+ }
1633
+ }
1634
+ } catch {}
1635
+ }
1636
+ } else if (os.platform() === 'darwin') {
1637
+ const spOut = await spawnRead('system_profiler', ['SPDisplaysDataType']);
1638
+ if (spOut) {
1639
+ // Split by chipset sections to handle multiple GPUs
1640
+ const sections = spOut.split(/(?=Chipset Model:)/);
1641
+ for (const section of sections) {
1642
+ const chipMatch = section.match(/Chipset Model:\s*(.+)/);
1643
+ const vramMatch = section.match(/VRAM.*?:\s*(\d+)\s*MB/);
1644
+ if (chipMatch) {
1645
+ gpus.push({ name: chipMatch[1].trim(), memTotal: vramMatch ? +vramMatch[1] : 0, driver: 'macos' });
1646
+ }
1647
+ }
1648
+ }
1649
+ } else if (os.platform() === 'win32') {
1650
+ const psGpu = await spawnRead('powershell.exe', ['-Command', "Get-CimInstance -ClassName Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion | ConvertTo-Json"]);
1651
+ if (psGpu) {
1652
+ const data = JSON.parse(psGpu);
1653
+ const list = Array.isArray(data) ? data : [data];
1654
+ for (const d of list) {
1655
+ if (d && d.Name) {
1656
+ const vramBytes = d.AdapterRAM || 0;
1657
+ gpus.push({ name: d.Name, memTotal: Math.round(vramBytes / (1024 * 1024)), driver: d.DriverVersion || '' });
1658
+ }
1659
+ }
1660
+ }
1661
+ }
1662
+ } catch {}
1663
+
1541
1664
  res.json({
1542
1665
  hostname: os.hostname(),
1543
1666
  platform: os.platform(),
1544
1667
  uptime: os.uptime(),
1545
1668
  cpu: { model: cpuModel, count: cpuCount, usage: cpuUsage, loadAvg },
1546
1669
  memory: { total: totalMem, free: freeMem, used: usedMem, percent: memPercent },
1670
+ gpus,
1547
1671
  disk,
1548
1672
  processes
1549
1673
  });