webtun 1.4.5 → 1.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.4.5",
3
+ "version": "1.4.6",
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()">
@@ -1198,12 +1271,37 @@ svg { display: block; }
1198
1271
  </div>
1199
1272
 
1200
1273
  <!-- 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">
1274
+ <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">
1275
+ <!-- Clipboard -->
1202
1276
  <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
1277
  <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>
1278
+ <div class="ctx-sep"></div>
1204
1279
  <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>
1280
+ <!-- Tab / Path -->
1281
+ <div class="ctx-sep"></div>
1282
+ <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>
1283
+ <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>
1284
+ <!-- Control -->
1205
1285
  <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>
1286
+ <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>
1287
+ <!-- More Options -->
1288
+ <div class="ctx-sep"></div>
1289
+ <div class="ctx-submenu-wrap">
1290
+ <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>
1291
+ <div class="ctx-submenu-items">
1292
+ <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>
1293
+ <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>
1294
+ <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>
1295
+ <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>
1296
+ <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>
1297
+ <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>
1298
+ <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>
1299
+ <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>
1300
+ </div>
1301
+ </div>
1302
+ <!-- Danger -->
1303
+ <div class="ctx-sep"></div>
1304
+ <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
1305
  </div>
1208
1306
 
1209
1307
  <!-- Fuzzy Finder Modal -->
@@ -1804,6 +1902,25 @@ function connectWebSocket(tab, isReconnect = false) {
1804
1902
 
1805
1903
  const sendInput = data => {
1806
1904
  if (ws.readyState !== WebSocket.OPEN) return;
1905
+ // On Enter, read command from terminal screen buffer (avoids echo-back pollution)
1906
+ for (let i = 0; i < data.length; i++) {
1907
+ const ch = data.charCodeAt(i);
1908
+ if (ch === 13 || ch === 10) { // Enter
1909
+ try {
1910
+ const buf = tab.term.buffer.active;
1911
+ const line = buf.getLine(buf.baseY + buf.cursorY);
1912
+ if (line) {
1913
+ let text = line.translateToString(true).trim();
1914
+ // Strip prompt prefix: find last $ # or % and take text after it
1915
+ const promptEnd = Math.max(text.lastIndexOf('$'), text.lastIndexOf('#'), text.lastIndexOf('%'));
1916
+ if (promptEnd >= 0 && promptEnd < text.length - 1) {
1917
+ text = text.substring(promptEnd + 1).trim();
1918
+ }
1919
+ if (text) addToCmdHist(text);
1920
+ }
1921
+ } catch {}
1922
+ }
1923
+ }
1807
1924
  const enc = new TextEncoder().encode(data);
1808
1925
  const buf = new Uint8Array(1 + enc.length);
1809
1926
  buf[0] = 0x00; buf.set(enc, 1);
@@ -2097,9 +2214,23 @@ function initTerminal(tab) {
2097
2214
  const menu = document.getElementById('term-ctx-menu');
2098
2215
  const sel = term.getSelection();
2099
2216
  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';
2217
+ // Show first to measure actual dimensions
2102
2218
  menu.style.display = 'block';
2219
+ const mw = menu.offsetWidth;
2220
+ const mh = menu.offsetHeight;
2221
+ const vw = window.innerWidth;
2222
+ const vh = window.innerHeight;
2223
+ let left = e.clientX;
2224
+ let top = e.clientY;
2225
+ // Flip horizontally if overflowing right
2226
+ if (left + mw > vw) left = Math.max(0, e.clientX - mw);
2227
+ // Flip vertically if overflowing bottom
2228
+ if (top + mh > vh) top = Math.max(0, e.clientY - mh);
2229
+ // Clamp to viewport
2230
+ left = Math.max(0, Math.min(left, vw - mw));
2231
+ top = Math.max(0, Math.min(top, vh - mh));
2232
+ menu.style.left = left + 'px';
2233
+ menu.style.top = top + 'px';
2103
2234
  });
2104
2235
 
2105
2236
  // Touch-to-mouse translation for TUI apps (mobile)
@@ -2686,24 +2817,115 @@ function renderBreadcrumb(fullPath) {
2686
2817
  let fsClipboard = null;
2687
2818
 
2688
2819
  // Terminal context menu actions (registered once)
2820
+ function hideTermCtxMenu() {
2821
+ document.getElementById('term-ctx-menu').style.display = 'none';
2822
+ document.querySelectorAll('#term-ctx-menu .ctx-submenu-wrap.open').forEach(el => el.classList.remove('open'));
2823
+ }
2689
2824
  document.getElementById('term-ctx-copy').addEventListener('click', () => {
2690
2825
  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';
2826
+ if (t?.term) {
2827
+ const sel = t.term.getSelection();
2828
+ if (sel) {
2829
+ navigator.clipboard.writeText(sel).then(() => {
2830
+ toast('Copied to clipboard', 'success');
2831
+ }).catch(() => {
2832
+ document.execCommand('copy');
2833
+ toast('Copied to clipboard', 'success');
2834
+ });
2835
+ }
2836
+ }
2837
+ hideTermCtxMenu();
2693
2838
  });
2694
2839
  document.getElementById('term-ctx-paste').addEventListener('click', () => {
2695
2840
  pasteToTerminal();
2696
- document.getElementById('term-ctx-menu').style.display = 'none';
2841
+ hideTermCtxMenu();
2697
2842
  });
2698
2843
  document.getElementById('term-ctx-select-all').addEventListener('click', () => {
2699
2844
  const t = getActiveTab();
2700
2845
  if (t?.term) t.term.selectAll();
2701
- document.getElementById('term-ctx-menu').style.display = 'none';
2846
+ hideTermCtxMenu();
2847
+ });
2848
+ document.getElementById('term-ctx-select-line').addEventListener('click', () => {
2849
+ selectTermLine();
2850
+ hideTermCtxMenu();
2851
+ });
2852
+ document.getElementById('term-ctx-find').addEventListener('click', () => {
2853
+ toggleSearch();
2854
+ hideTermCtxMenu();
2855
+ });
2856
+ document.getElementById('term-ctx-zoom-in').addEventListener('click', () => {
2857
+ applyFontSize((settings.fontSize || 14) + 1);
2858
+ hideTermCtxMenu();
2859
+ });
2860
+ document.getElementById('term-ctx-zoom-out').addEventListener('click', () => {
2861
+ applyFontSize((settings.fontSize || 14) - 1);
2862
+ hideTermCtxMenu();
2863
+ });
2864
+ document.getElementById('term-ctx-new-tab').addEventListener('click', () => {
2865
+ const t = getActiveTab();
2866
+ newTab(null, null, t?.cwd || currentPath);
2867
+ hideTermCtxMenu();
2868
+ });
2869
+ document.getElementById('term-ctx-copy-cwd').addEventListener('click', () => {
2870
+ const t = getActiveTab();
2871
+ const cwd = t?.cwd || currentPath;
2872
+ if (cwd) {
2873
+ navigator.clipboard.writeText(cwd).then(() => {
2874
+ toast('Path copied', 'success');
2875
+ }).catch(() => {
2876
+ document.execCommand('copy');
2877
+ toast('Path copied', 'success');
2878
+ });
2879
+ }
2880
+ hideTermCtxMenu();
2881
+ });
2882
+ document.getElementById('term-ctx-bookmark').addEventListener('click', () => {
2883
+ const t = getActiveTab();
2884
+ const cwd = t?.cwd || currentPath;
2885
+ if (cwd) {
2886
+ const bm = getBookmarks();
2887
+ if (bm.some(b => b.path === cwd)) { toast('Already bookmarked', 'info'); }
2888
+ else {
2889
+ const sep = cwd.includes('\\') ? '\\' : '/';
2890
+ bm.push({ name: cwd.split(sep).filter(Boolean).pop() || cwd, path: cwd });
2891
+ saveBookmarks(bm);
2892
+ toast('Bookmarked', 'success');
2893
+ }
2894
+ }
2895
+ hideTermCtxMenu();
2896
+ });
2897
+ document.getElementById('term-ctx-interrupt').addEventListener('click', () => {
2898
+ sendKey('\x03');
2899
+ hideTermCtxMenu();
2900
+ });
2901
+ document.getElementById('term-ctx-rename').addEventListener('click', () => {
2902
+ const t = getActiveTab();
2903
+ if (t?.el) {
2904
+ const span = t.el.querySelector('.tab-title');
2905
+ if (span) span.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
2906
+ }
2907
+ hideTermCtxMenu();
2908
+ });
2909
+ document.getElementById('term-ctx-scroll-top').addEventListener('click', () => {
2910
+ const t = getActiveTab();
2911
+ if (t?.term) t.term.scrollToTop();
2912
+ hideTermCtxMenu();
2913
+ });
2914
+ document.getElementById('term-ctx-scroll-bottom').addEventListener('click', () => {
2915
+ const t = getActiveTab();
2916
+ if (t?.term) t.term.scrollToBottom();
2917
+ hideTermCtxMenu();
2702
2918
  });
2703
2919
  document.getElementById('term-ctx-clear').addEventListener('click', () => {
2704
2920
  const t = getActiveTab();
2705
2921
  if (t?.term) t.term.clear();
2706
- document.getElementById('term-ctx-menu').style.display = 'none';
2922
+ hideTermCtxMenu();
2923
+ });
2924
+ // Submenu toggle on click
2925
+ document.querySelector('#term-ctx-menu .ctx-submenu-wrap > .ctx-item').addEventListener('click', e => {
2926
+ e.stopPropagation();
2927
+ const wrap = e.currentTarget.closest('.ctx-submenu-wrap');
2928
+ wrap.classList.toggle('open');
2707
2929
  });
2708
2930
 
2709
2931
  function showCtxMenu(e, file) {
@@ -2718,24 +2940,21 @@ function showCtxMenu(e, file) {
2718
2940
  document.getElementById('ctx-zip').style.display = '';
2719
2941
  document.getElementById('ctx-folder-size').style.display = file.isDir ? '' : 'none';
2720
2942
  document.getElementById('ctx-extract').style.display = file.isDir ? 'none' : (file.ext === '.zip' ? '' : 'none');
2943
+ // Measure after content is set
2721
2944
  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;
2945
+ const vw = window.innerWidth, vh = window.innerHeight;
2946
+ let left = e.clientX, top = e.clientY;
2947
+ // Flip horizontally if overflowing right
2948
+ if (left + mw > vw) left = Math.max(0, e.clientX - mw);
2949
+ // Flip vertically if overflowing bottom
2950
+ if (top + mh > vh) top = Math.max(0, e.clientY - mh);
2951
+ // Clamp to viewport
2952
+ left = Math.max(0, Math.min(left, vw - mw));
2953
+ top = Math.max(0, Math.min(top, vh - mh));
2726
2954
  menu.style.left = left + 'px';
2727
2955
  menu.style.top = top + 'px';
2728
2956
  menu.classList.add('open');
2729
2957
  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
2958
  }
2740
2959
 
2741
2960
  document.addEventListener('click', () => {
@@ -4191,6 +4410,11 @@ function setupKeyboardShortcuts() {
4191
4410
  if (ctrl && e.shiftKey && e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); }
4192
4411
  if (ctrl && e.shiftKey && e.key === 'r') { e.preventDefault(); refreshPreview(); }
4193
4412
  if (e.key === 'Escape') {
4413
+ const termMenu = document.getElementById('term-ctx-menu');
4414
+ if (termMenu && termMenu.style.display !== 'none') {
4415
+ termMenu.style.display = 'none';
4416
+ return;
4417
+ }
4194
4418
  if (document.getElementById('ctx-menu').classList.contains('open')) {
4195
4419
  document.getElementById('ctx-menu').classList.remove('open');
4196
4420
  return;
@@ -4350,6 +4574,7 @@ function pasteToTerminal() {
4350
4574
  const buf = new Uint8Array(1 + enc.length);
4351
4575
  buf[0] = 0x00; buf.set(enc, 1);
4352
4576
  tab.ws.send(buf.buffer);
4577
+ tab.term?.focus();
4353
4578
  toast('Pasted to terminal', 'success');
4354
4579
  }
4355
4580
  };
@@ -4366,14 +4591,16 @@ function fallbackPaste(callback) {
4366
4591
  _pasteInput = document.createElement('textarea');
4367
4592
  _pasteInput.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1';
4368
4593
  document.body.appendChild(_pasteInput);
4369
- _pasteInput.addEventListener('input', () => {
4370
- callback(_pasteInput.value);
4371
- _pasteInput.value = '';
4594
+ _pasteInput.addEventListener('paste', () => {
4595
+ setTimeout(() => {
4596
+ callback(_pasteInput.value);
4597
+ _pasteInput.value = '';
4598
+ }, 0);
4372
4599
  });
4373
4600
  }
4374
4601
  _pasteInput.value = '';
4375
4602
  _pasteInput.focus();
4376
- toast('Tap & hold to paste', 'info');
4603
+ document.execCommand('paste');
4377
4604
  }
4378
4605
 
4379
4606
  function selectAllTerm() {
@@ -4915,9 +5142,8 @@ function toggleCmdLib() {
4915
5142
  panel.classList.toggle('open');
4916
5143
  document.getElementById('cmd-lib-toggle').classList.toggle('active', !isOpen);
4917
5144
  if (!isOpen) {
4918
- renderCmdLib();
4919
- document.getElementById('cmd-lib-search').value = '';
4920
- document.getElementById('cmd-lib-search').focus();
5145
+ loadHistMax();
5146
+ switchCmdTab('library');
4921
5147
  setTimeout(() => {
4922
5148
  document.addEventListener('click', closeCmdLibOnClickOutside, true);
4923
5149
  }, 0);
@@ -4999,7 +5225,13 @@ function renderCmdLib(filter) {
4999
5225
  item.appendChild(delBtn);
5000
5226
  }
5001
5227
 
5002
- item.addEventListener('click', () => runCmdLib(c.cmd));
5228
+ item.addEventListener('click', () => {
5229
+ const tab = getActiveTab();
5230
+ if (!tab?.term) return;
5231
+ tab.term.focus();
5232
+ document.execCommand('insertText', false, c.cmd);
5233
+ toggleCmdLib();
5234
+ });
5003
5235
  section.appendChild(item);
5004
5236
  });
5005
5237
  list.appendChild(section);
@@ -5023,6 +5255,7 @@ function runCmdLib(cmd) {
5023
5255
  buf.set(enc, 1);
5024
5256
  tab.ws.send(buf.buffer);
5025
5257
  tab.term?.focus();
5258
+ addToCmdHist(cmd);
5026
5259
  toast('Running: ' + cmd, 'info');
5027
5260
  }
5028
5261
 
@@ -5053,6 +5286,155 @@ function deleteCustomCmd(name, cmd) {
5053
5286
  toast('Command removed', 'info');
5054
5287
  }
5055
5288
 
5289
+ // ═══════════════════════════════════════════════════════
5290
+ // COMMAND HISTORY (server-side storage)
5291
+ // ═══════════════════════════════════════════════════════
5292
+ let cmdHistMax = 50;
5293
+ let _cmdHistCache = [];
5294
+
5295
+ function histHeaders() {
5296
+ return { 'Content-Type': 'application/json', 'x-pin-token': localStorage.getItem('wt-pin') || '' };
5297
+ }
5298
+ function histHeadersDelete() {
5299
+ return { 'x-pin-token': localStorage.getItem('wt-pin') || '' };
5300
+ }
5301
+ async function getCmdHist() {
5302
+ try {
5303
+ const r = await fetch('/api/history', { headers: histHeadersDelete() });
5304
+ if (!r.ok) return _cmdHistCache;
5305
+ const data = await r.json();
5306
+ _cmdHistCache = data.history || [];
5307
+ if (data.max) cmdHistMax = data.max;
5308
+ return _cmdHistCache;
5309
+ } catch { return _cmdHistCache; }
5310
+ }
5311
+ function loadHistMax() {
5312
+ getCmdHist().then(() => {
5313
+ const el = document.getElementById('cmd-hist-max');
5314
+ if (el) el.value = cmdHistMax;
5315
+ });
5316
+ }
5317
+ async function addToCmdHist(cmd) {
5318
+ if (!cmd || !cmd.trim()) return;
5319
+ // Strip ANSI escapes and control characters, keep only printable + space + tab
5320
+ const clean = cmd.replace(/\x1b\[[0-9;]*[a-zA-Z~]/g, '')
5321
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
5322
+ .replace(/[\x00-\x08\x0e-\x1f]/g, '')
5323
+ .trim();
5324
+ if (!clean) return;
5325
+ try {
5326
+ await fetch('/api/history', { method: 'POST', headers: histHeaders(), body: JSON.stringify({ cmd: clean, max: cmdHistMax }) });
5327
+ await getCmdHist();
5328
+ } catch {}
5329
+ }
5330
+ async function removeCmdHistItem(idx) {
5331
+ try {
5332
+ await fetch('/api/history/' + idx, { method: 'DELETE', headers: histHeadersDelete() });
5333
+ await getCmdHist();
5334
+ renderCmdHist();
5335
+ } catch {}
5336
+ }
5337
+ async function clearCmdHist() {
5338
+ try {
5339
+ await fetch('/api/history', { method: 'DELETE', headers: histHeadersDelete() });
5340
+ _cmdHistCache = [];
5341
+ renderCmdHist();
5342
+ toast('History cleared', 'info');
5343
+ } catch {}
5344
+ }
5345
+ async function updateHistMax(val) {
5346
+ cmdHistMax = Math.max(10, Math.min(500, val || 50));
5347
+ try {
5348
+ await fetch('/api/history', { method: 'POST', headers: histHeaders(), body: JSON.stringify({ cmd: '', max: cmdHistMax }) });
5349
+ } catch {}
5350
+ }
5351
+ function switchCmdTab(tab) {
5352
+ const libBtn = document.getElementById('cmd-tab-lib');
5353
+ const histBtn = document.getElementById('cmd-tab-hist');
5354
+ const libList = document.getElementById('cmd-lib-list');
5355
+ const histList = document.getElementById('cmd-hist-list');
5356
+ const histHeader = document.getElementById('cmd-hist-header');
5357
+ const footer = document.getElementById('cmd-lib-footer');
5358
+ const search = document.getElementById('cmd-lib-search');
5359
+ if (tab === 'library') {
5360
+ libBtn.classList.add('active');
5361
+ histBtn.classList.remove('active');
5362
+ libList.style.display = '';
5363
+ histList.style.display = 'none';
5364
+ histHeader.style.display = 'none';
5365
+ footer.style.display = '';
5366
+ search.placeholder = 'Filter commands…';
5367
+ search.oninput = () => filterCmdLib();
5368
+ filterCmdLib();
5369
+ } else {
5370
+ histBtn.classList.add('active');
5371
+ libBtn.classList.remove('active');
5372
+ histList.style.display = 'flex';
5373
+ libList.style.display = 'none';
5374
+ histHeader.style.display = '';
5375
+ footer.style.display = 'none';
5376
+ search.placeholder = 'Filter history…';
5377
+ search.oninput = () => renderCmdHist();
5378
+ renderCmdHist();
5379
+ }
5380
+ search.value = '';
5381
+ search.focus();
5382
+ }
5383
+ async function renderCmdHist() {
5384
+ const list = document.getElementById('cmd-hist-list');
5385
+ const hist = await getCmdHist();
5386
+ const q = (document.getElementById('cmd-lib-search').value || '').toLowerCase();
5387
+ const filtered = q ? hist.filter(h => h.cmd.toLowerCase().includes(q)) : hist;
5388
+
5389
+ list.innerHTML = '';
5390
+ if (!filtered.length) {
5391
+ list.innerHTML = '<div id="cmd-hist-empty">No commands run yet</div>';
5392
+ return;
5393
+ }
5394
+
5395
+ filtered.forEach((h, i) => {
5396
+ const realIdx = q ? hist.indexOf(h) : i;
5397
+ const item = document.createElement('div');
5398
+ item.className = 'cmd-hist-item';
5399
+
5400
+ const cmdSpan = document.createElement('span');
5401
+ cmdSpan.className = 'cmd-hist-cmd';
5402
+ cmdSpan.textContent = h.cmd;
5403
+ cmdSpan.title = h.cmd;
5404
+ item.appendChild(cmdSpan);
5405
+
5406
+ const timeSpan = document.createElement('span');
5407
+ timeSpan.className = 'cmd-hist-time';
5408
+ const ago = Date.now() - h.time;
5409
+ 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();
5410
+ if (h.count > 1) timeSpan.textContent = '×' + h.count + ' ' + timeSpan.textContent;
5411
+ item.appendChild(timeSpan);
5412
+
5413
+ const runBtn = document.createElement('button');
5414
+ runBtn.className = 'cmd-hist-run';
5415
+ runBtn.title = 'Run in terminal';
5416
+ 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>';
5417
+ runBtn.addEventListener('click', e => { e.stopPropagation(); runCmdLib(h.cmd); });
5418
+ item.appendChild(runBtn);
5419
+
5420
+ const delBtn = document.createElement('button');
5421
+ delBtn.className = 'cmd-hist-del';
5422
+ delBtn.title = 'Remove';
5423
+ delBtn.textContent = '×';
5424
+ delBtn.addEventListener('click', e => { e.stopPropagation(); removeCmdHistItem(realIdx); });
5425
+ item.appendChild(delBtn);
5426
+
5427
+ item.addEventListener('click', () => {
5428
+ const tab = getActiveTab();
5429
+ if (!tab?.term) return;
5430
+ tab.term.focus();
5431
+ document.execCommand('insertText', false, h.cmd);
5432
+ toggleCmdLib();
5433
+ });
5434
+ list.appendChild(item);
5435
+ });
5436
+ }
5437
+
5056
5438
  // ═══════════════════════════════════════════════════════
5057
5439
  // SYSTEM STATS
5058
5440
  // ═══════════════════════════════════════════════════════
package/server.js CHANGED
@@ -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');
@@ -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