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 +28 -3
- package/package.json +1 -1
- package/public/index.html +575 -78
- package/server.js +127 -3
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
|
|
46
|
-
- **
|
|
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
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:
|
|
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-
|
|
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
|
-
|
|
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
|
|
2323
|
+
// Scroll terminal on wheel
|
|
2150
2324
|
tab.wrapper.addEventListener('wheel', e => {
|
|
2151
2325
|
if (e.ctrlKey || e.metaKey) return;
|
|
2152
|
-
|
|
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())
|
|
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) {
|
|
2692
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2723
|
-
let
|
|
2724
|
-
|
|
2725
|
-
if (
|
|
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 = () =>
|
|
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
|
-
|
|
3020
|
+
const files = selectedFiles.length > 0 ? selectedFiles : (ctxTarget ? [ctxTarget.path] : []);
|
|
3021
|
+
if (!files.length) return;
|
|
2752
3022
|
const t = toast('Zipping…', 'info');
|
|
2753
|
-
|
|
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 = () =>
|
|
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 = () => {
|
|
2782
|
-
|
|
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
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
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
|
|
2820
|
-
if (
|
|
2821
|
-
else toast(
|
|
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
|
-
}
|
|
2825
|
-
|
|
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(
|
|
3185
|
+
async function downloadFile(filePath) {
|
|
2887
3186
|
try {
|
|
2888
|
-
const r = await fetch(`/api/files/download?path=${encodeURIComponent(
|
|
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
|
-
|
|
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
|
-
|
|
3004
|
-
|
|
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('
|
|
4370
|
-
|
|
4371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4919
|
-
|
|
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', () =>
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
});
|