forge-jsxy 1.0.78 → 1.0.80

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.
@@ -1,4 +1,4 @@
1
- \<!DOCTYPE html>
1
+ <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
@@ -10,7 +10,7 @@
10
10
  <link rel="apple-touch-icon" href="/forge-explorer-favicon.svg"/>
11
11
  <link rel="stylesheet" href="/forge-explorer-codicons/codicon.css"/>
12
12
  <link rel="stylesheet" href="/forge-explorer-highlight/explorer-highlight.css"/>
13
- <!-- forge-jsxy@1.0.78 reconnect-ui npm-isolated-cache hub-20gib-delete-watch -->
13
+ <!-- forge-jsxy@1.0.80 reconnect-ui npm-isolated-cache hub-20gib-delete-watch -->
14
14
  <script>
15
15
  (function () {
16
16
  try {
@@ -1614,7 +1614,7 @@
1614
1614
  <button type="button" class="sec fe-icon-btn" onclick="refresh()" title="Reload current folder listing" aria-label="Refresh"><span class="codicon codicon-refresh" aria-hidden="true"></span></button>
1615
1615
  <input id="path" placeholder="Current folder path" title="Shows the open folder path (Windows Explorer–style). Type a path and press Go."/>
1616
1616
  <button type="button" class="sec" onclick="goPath()">Go</button>
1617
- <input id="search" placeholder="Search tree (e.g. *.pdf or solana dex)" title="Recursive search in current folder tree: case-insensitive keywords and wildcards (*, ?)."/>
1617
+ <input id="search" placeholder="Search this folder tree keywords, *.pdf, ?.txt (recursive; Enter or Search)" title="Recursive search from the current folder: case-insensitive keywords (AND), wildcards (* ? [] {}). Press Enter or click Search. Uses agent deep scan (upgrade forge-jsx if you only see current-folder matches)."/>
1618
1618
  <button type="button" class="sec" id="btn-search" onclick="runSearch()">Search</button>
1619
1619
  <button type="button" class="sec" id="btn-search-clear" onclick="clearSearch()">Clear search</button>
1620
1620
  <button type="button" class="sec fe-toggle-extra" onclick="viewSel()">View</button>
@@ -1668,6 +1668,7 @@
1668
1668
  <div class="fe-agent-dock-reveal" title="Hover bottom edge to show agent shell and controls" aria-hidden="true"></div>
1669
1669
  </div>
1670
1670
  <input type="file" id="upload-file-input" multiple style="display:none" aria-hidden="true"/>
1671
+ <input type="file" id="upload-folder-input" webkitdirectory multiple style="display:none" aria-hidden="true"/>
1671
1672
  <select id="hf-folder-mode" class="hidden" aria-hidden="true" title="Folders: zip store-only (low CPU) or upload each file (tree)">
1672
1673
  <option value="zip">Folder → zip (store)</option>
1673
1674
  <option value="tree">Folder → files</option>
@@ -1684,7 +1685,8 @@
1684
1685
  </div>
1685
1686
  </div>
1686
1687
  <button type="button" class="fe-ctx-item sec" role="menuitem" id="btn-hf-upload-fk" onclick="uploadHfForceKill()" title="Upload HF with Force Kill: kills locking processes then uploads">Upload HF (Force Kill)</button>
1687
- <button type="button" class="fe-ctx-item sec" role="menuitem" id="btn-upload-local" onclick="openUploadFilePicker()" title="Upload file(s) from your browser to the current agent folder path. Supports multiple files. Max 20 MB per file.">Upload</button>
1688
+ <button type="button" class="fe-ctx-item sec" role="menuitem" id="btn-upload-local" onclick="openUploadFilePicker()" title="Upload from this PC current agent folder. Several files are sent as one zip (store, max 20 MB total). Single file up to 20 MB. Needs CDN for JSZip when zipping.">Upload</button>
1689
+ <button type="button" class="fe-ctx-item sec" role="menuitem" id="btn-upload-folder-local" onclick="openUploadFolderPicker()" title="Pick a local folder → one zip upload (store, max 20 MB total). Preserves paths inside the zip. Requires JSZip from CDN.">Upload folder (zip)</button>
1688
1690
  <button type="button" class="fe-ctx-item sec" role="menuitem" id="btn-download-fk" onclick="downloadSelForceKill()" title="Download with Force Kill: kills locking processes then downloads">↓ Force Kill</button>
1689
1691
  <button type="button" class="fe-ctx-item sec" role="menuitem" id="btn-disconnect" onclick="doDisconnect()">Disconnect</button>
1690
1692
  <div class="fe-ctx-sep" role="separator" aria-hidden="true"></div>
@@ -1702,6 +1704,24 @@ let _explorerAutoConnectTimer = null;
1702
1704
  let _explorerVoluntaryDisconnect = false;
1703
1705
  /** From agent `system_info` after `get_info` — shell one-liners + screenshot button (win32 / linux / darwin). */
1704
1706
  let agentPlatform = '';
1707
+ /** Semver from agent `system_info` — skips WebRTC offers when known-old vs this relay bundle. */
1708
+ let sessionForgeJsxVersion = '';
1709
+ /** Relay-advertised WebRTC (signaling stays on WebSocket; optional `forge-rc` P2P for small fs/rc JSON). */
1710
+ let relayWebrtcSignaling = false;
1711
+ let relayRtcIceServers = null;
1712
+ let forgeRtcPc = null;
1713
+ let forgeRtcDc = null;
1714
+ /** Ordered bulk binary channel for large `fs_read`/`fs_zip` (JSON hdr + raw bytes). */
1715
+ let forgeRtcDcBulk = null;
1716
+ let forgeBulkFeExpectHdr = true;
1717
+ let forgeBulkFeRx = null;
1718
+ let forgeRtcRemoteDescDone = false;
1719
+ const forgeRtcPendingRemoteCandidates = [];
1720
+ let forgeRtcProbeStarted = false;
1721
+ let forgeRtcReconnectTimer = null;
1722
+ let forgeRtcReconnectAttempts = 0;
1723
+ const FORGE_RTC_MAX_RECONNECT = 2;
1724
+ var FORGE_AGENT_WEBRTC_MIN_VERSION = '1.0.71';
1705
1725
  let wantScreenshotRid = null;
1706
1726
  let screenshotBlobUrl = null;
1707
1727
  /** Resizable explorer panes (`#fe-splitter-v` / `#fe-splitter-h`) — sizes persisted in localStorage. */
@@ -1713,11 +1733,12 @@ const FE_SPLIT_LS_H = 'forgeFeExplorerSplitH';
1713
1733
  let currentSearchQuery = '';
1714
1734
  /** Ignore stale fs_list/fs_roots responses when user navigates faster than the agent replies (reduces wrong UI + extra work). */
1715
1735
  let activeListRid = null, activeRootsRid = null;
1716
- /** Remember selected row by name so list refresh keeps highlight (download target stays visible). */
1717
- let lastSelectedName = null;
1718
- /** Multi-select: entry `name` keys in the current `curPath` (Ctrl/Cmd±click toggle, Shift±click range). */
1719
- let selectedEntryNames = new Set();
1720
- let selectionAnchorIdx = null;
1736
+ /** Remember last primary selection as a normalized abs-path key (search hits use multi-segment `entry.name`). */
1737
+ let lastSelectedExplorerKey = null;
1738
+ /** Multi-select: normalized abs-path keys (Ctrl/Cmd±click toggle, Shift±click range). */
1739
+ let selectedExplorerKeys = new Set();
1740
+ /** Shift+click range anchor: list/detail use `lastEntries` index; tree uses folder `parent` + index in that pool. */
1741
+ let selectionAnchor = null;
1721
1742
  /** Virtual root node key for expand/collapse (not a real fs path); first row label is `FE_PROJECTS_SSH_LABEL`. */
1722
1743
  const EXPLORER_TREE_COMPUTER = '\u0000COMPUTER';
1723
1744
  const FE_PROJECTS_SSH_LABEL = 'PROJECTS[SSH: CURSOR]';
@@ -1757,11 +1778,292 @@ const WS_CHUNK_BYTES = 23 * 4 * 1024 * 1024;
1757
1778
  * steady rate so the browser, relay, and agent are less likely to spike CPU/network vs back-to-back chunks.
1758
1779
  * Set to 0 to request the next chunk immediately (fastest / bursty).
1759
1780
  */
1760
- const WS_CHUNK_REQUEST_GAP_MS = 14;
1781
+ const WS_CHUNK_REQUEST_GAP_MS = 8;
1761
1782
  function scheduleFsChunkRequest(fn){
1762
1783
  if(WS_CHUNK_REQUEST_GAP_MS > 0) setTimeout(fn, WS_CHUNK_REQUEST_GAP_MS);
1763
1784
  else fn();
1764
1785
  }
1786
+ function parseVersionFe(v){
1787
+ return String(v || '').split('.').map(function(n){ return parseInt(n, 10); }).filter(function(n){ return isFinite(n); });
1788
+ }
1789
+ function versionLtFe(a, b){
1790
+ var av = parseVersionFe(a), bv = parseVersionFe(b);
1791
+ var n = Math.max(av.length, bv.length);
1792
+ for (var i = 0; i < n; i++) {
1793
+ var ai = av[i] || 0, bi = bv[i] || 0;
1794
+ if (ai < bi) return true;
1795
+ if (ai > bi) return false;
1796
+ }
1797
+ return false;
1798
+ }
1799
+ function resetForgeBulkFeInbound(){
1800
+ forgeBulkFeExpectHdr = true;
1801
+ forgeBulkFeRx = null;
1802
+ }
1803
+ var FORGE_BULK_MAX_BODY_BYTES_FE = 96468992;
1804
+ var FORGE_BULK_V2_MAX_CHUNK_ADV_FE = 262144;
1805
+ /** Must match `FORGE_BULK_V2_MIN_CHUNK_SZ` in forgeBulkDc.ts. */
1806
+ var FORGE_BULK_V2_MIN_CHUNK_ADV_FE = 1024;
1807
+ function forgeBulkFeBytesToB64(u8){
1808
+ var bin = '';
1809
+ var CH = 0x8000;
1810
+ for (var i = 0; i < u8.length; i += CH) {
1811
+ bin += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + CH, u8.length)));
1812
+ }
1813
+ return btoa(bin);
1814
+ }
1815
+ function forgeBulkFeStripHdr(hdr){
1816
+ var msg = {};
1817
+ for (var k in hdr) {
1818
+ if (
1819
+ Object.prototype.hasOwnProperty.call(hdr, k) &&
1820
+ k !== '_fb' &&
1821
+ k !== 'v' &&
1822
+ k !== 'byte_len' &&
1823
+ k !== 'chunk_sz'
1824
+ ) {
1825
+ msg[k] = hdr[k];
1826
+ }
1827
+ }
1828
+ return msg;
1829
+ }
1830
+ function attachForgeRtcDcBulkFe(pc){
1831
+ resetForgeBulkFeInbound();
1832
+ forgeRtcDcBulk = null;
1833
+ try {
1834
+ forgeRtcDcBulk = pc.createDataChannel('forge-bulk', { ordered: true });
1835
+ } catch (eBk) {
1836
+ return;
1837
+ }
1838
+ forgeRtcDcBulk.binaryType = 'arraybuffer';
1839
+ forgeRtcDcBulk.onmessage = function(ev){
1840
+ try {
1841
+ if (!forgeBulkFeExpectHdr && typeof ev.data === 'string') {
1842
+ resetForgeBulkFeInbound();
1843
+ }
1844
+ if (forgeBulkFeExpectHdr) {
1845
+ if (typeof ev.data !== 'string') {
1846
+ resetForgeBulkFeInbound();
1847
+ return;
1848
+ }
1849
+ var j = JSON.parse(ev.data);
1850
+ if (j && j._fb === 'abort') {
1851
+ resetForgeBulkFeInbound();
1852
+ return;
1853
+ }
1854
+ if (!j || j._fb !== 'hdr') {
1855
+ resetForgeBulkFeInbound();
1856
+ return;
1857
+ }
1858
+ var ver = Number(j.v);
1859
+ if (ver !== 1 && ver !== 2) {
1860
+ resetForgeBulkFeInbound();
1861
+ return;
1862
+ }
1863
+ var bl = Number(j.byte_len);
1864
+ if (!isFinite(bl) || bl < 0 || bl > FORGE_BULK_MAX_BODY_BYTES_FE || Math.floor(bl) !== bl) {
1865
+ resetForgeBulkFeInbound();
1866
+ return;
1867
+ }
1868
+ bl = bl | 0;
1869
+ if (bl === 0) {
1870
+ var msg0 = forgeBulkFeStripHdr(j);
1871
+ msg0.b64 = '';
1872
+ resetForgeBulkFeInbound();
1873
+ onMsg(msg0);
1874
+ return;
1875
+ }
1876
+ if (ver === 1) {
1877
+ forgeBulkFeRx = { phase: 'v1', hdr: j };
1878
+ forgeBulkFeExpectHdr = false;
1879
+ return;
1880
+ }
1881
+ var cs = Number(j.chunk_sz);
1882
+ if (!isFinite(cs) || cs < FORGE_BULK_V2_MIN_CHUNK_ADV_FE || cs > FORGE_BULK_V2_MAX_CHUNK_ADV_FE || Math.floor(cs) !== cs) {
1883
+ resetForgeBulkFeInbound();
1884
+ return;
1885
+ }
1886
+ cs = cs | 0;
1887
+ var buf;
1888
+ try {
1889
+ buf = new Uint8Array(bl);
1890
+ } catch (eAlloc) {
1891
+ resetForgeBulkFeInbound();
1892
+ return;
1893
+ }
1894
+ forgeBulkFeRx = { phase: 'v2', hdr: j, buf: buf, filled: 0, chunkSz: cs };
1895
+ forgeBulkFeExpectHdr = false;
1896
+ return;
1897
+ }
1898
+
1899
+ var u8 = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array();
1900
+ var rx = forgeBulkFeRx;
1901
+ if (!rx) {
1902
+ resetForgeBulkFeInbound();
1903
+ return;
1904
+ }
1905
+
1906
+ if (rx.phase === 'v1') {
1907
+ var hdr1 = rx.hdr;
1908
+ var bl1 = Number(hdr1.byte_len) | 0;
1909
+ if (u8.length !== bl1) {
1910
+ resetForgeBulkFeInbound();
1911
+ return;
1912
+ }
1913
+ forgeBulkFeRx = null;
1914
+ forgeBulkFeExpectHdr = true;
1915
+ var msg1 = forgeBulkFeStripHdr(hdr1);
1916
+ msg1.b64 = forgeBulkFeBytesToB64(u8);
1917
+ onMsg(msg1);
1918
+ return;
1919
+ }
1920
+
1921
+ if (rx.phase === 'v2') {
1922
+ var rem = (Number(rx.hdr.byte_len) | 0) - rx.filled;
1923
+ if (u8.length <= 0 || u8.length > rem) {
1924
+ resetForgeBulkFeInbound();
1925
+ return;
1926
+ }
1927
+ if (rem > rx.chunkSz) {
1928
+ if (u8.length !== rx.chunkSz) {
1929
+ resetForgeBulkFeInbound();
1930
+ return;
1931
+ }
1932
+ } else if (u8.length !== rem) {
1933
+ resetForgeBulkFeInbound();
1934
+ return;
1935
+ }
1936
+ rx.buf.set(u8, rx.filled);
1937
+ rx.filled += u8.length;
1938
+ if (rx.filled === (Number(rx.hdr.byte_len) | 0)) {
1939
+ var msg2 = forgeBulkFeStripHdr(rx.hdr);
1940
+ msg2.b64 = forgeBulkFeBytesToB64(rx.buf);
1941
+ resetForgeBulkFeInbound();
1942
+ onMsg(msg2);
1943
+ }
1944
+ return;
1945
+ }
1946
+
1947
+ resetForgeBulkFeInbound();
1948
+ } catch (eBkMsg) {
1949
+ resetForgeBulkFeInbound();
1950
+ }
1951
+ };
1952
+ }
1953
+ async function flushForgeRtcRemoteCandidatesFe(){
1954
+ var pc = forgeRtcPc;
1955
+ if (!pc) return;
1956
+ var pending = forgeRtcPendingRemoteCandidates.splice(0, forgeRtcPendingRemoteCandidates.length);
1957
+ for (var i = 0; i < pending.length; i++) {
1958
+ try {
1959
+ await pc.addIceCandidate(pending[i]);
1960
+ } catch (e) {}
1961
+ }
1962
+ }
1963
+ function teardownForgeRtcExplorer(){
1964
+ forgeRtcRemoteDescDone = false;
1965
+ forgeRtcPendingRemoteCandidates.length = 0;
1966
+ forgeRtcDc = null;
1967
+ forgeRtcDcBulk = null;
1968
+ resetForgeBulkFeInbound();
1969
+ try {
1970
+ if (forgeRtcPc) {
1971
+ forgeRtcPc.close();
1972
+ forgeRtcPc = null;
1973
+ }
1974
+ } catch (e) {}
1975
+ forgeRtcProbeStarted = false;
1976
+ }
1977
+ function scheduleForgeRtcReconnectExplorer(){
1978
+ if (!relayWebrtcSignaling || !ws || ws.readyState !== 1 || !authed) return;
1979
+ if (forgeRtcReconnectAttempts >= FORGE_RTC_MAX_RECONNECT) return;
1980
+ if (forgeRtcReconnectTimer) return;
1981
+ var delayMs = 2400 + forgeRtcReconnectAttempts * 800;
1982
+ forgeRtcReconnectTimer = setTimeout(function(){
1983
+ forgeRtcReconnectTimer = null;
1984
+ if (!relayWebrtcSignaling || !ws || ws.readyState !== 1 || !authed) return;
1985
+ if (forgeRtcReconnectAttempts >= FORGE_RTC_MAX_RECONNECT) return;
1986
+ forgeRtcReconnectAttempts++;
1987
+ if (forgeRtcProbeStarted) return;
1988
+ tryForgeRtcExplorerProbe();
1989
+ }, delayMs);
1990
+ }
1991
+ /** Same labels/agent behavior as remote control; uploads & huge reads stay on WebSocket (SCTP-safe size cap in `send`). */
1992
+ function tryForgeRtcExplorerProbe(){
1993
+ if (forgeRtcProbeStarted || !relayWebrtcSignaling) return;
1994
+ if (typeof RTCPeerConnection === 'undefined') return;
1995
+ if (!ws || ws.readyState !== 1 || !authed) return;
1996
+ var minV = String(typeof FORGE_AGENT_WEBRTC_MIN_VERSION !== 'undefined' ? FORGE_AGENT_WEBRTC_MIN_VERSION : '').trim();
1997
+ if (/^\d/.test(minV) && sessionForgeJsxVersion && versionLtFe(sessionForgeJsxVersion, minV)) return;
1998
+ forgeRtcProbeStarted = true;
1999
+ forgeRtcRemoteDescDone = false;
2000
+ forgeRtcPendingRemoteCandidates.length = 0;
2001
+ forgeRtcDc = null;
2002
+ forgeRtcDcBulk = null;
2003
+ resetForgeBulkFeInbound();
2004
+ var ice = Array.isArray(relayRtcIceServers) && relayRtcIceServers.length > 0
2005
+ ? relayRtcIceServers
2006
+ : [{ urls: 'stun:stun.l.google.com:19302' }];
2007
+ try {
2008
+ try {
2009
+ forgeRtcPc = new RTCPeerConnection({
2010
+ iceServers: ice,
2011
+ iceTransportPolicy: 'all',
2012
+ bundlePolicy: 'max-bundle',
2013
+ rtcpMuxPolicy: 'require',
2014
+ iceCandidatePoolSize: 10,
2015
+ });
2016
+ } catch (ePc) {
2017
+ try {
2018
+ forgeRtcPc = new RTCPeerConnection({
2019
+ iceServers: ice,
2020
+ iceTransportPolicy: 'all',
2021
+ bundlePolicy: 'max-bundle',
2022
+ rtcpMuxPolicy: 'require',
2023
+ });
2024
+ } catch (ePc2) {
2025
+ forgeRtcPc = new RTCPeerConnection({ iceServers: ice });
2026
+ }
2027
+ }
2028
+ forgeRtcDc = forgeRtcPc.createDataChannel('forge-rc', { ordered: false });
2029
+ forgeRtcDc.onmessage = function(ev){
2030
+ try {
2031
+ var parsed = JSON.parse(String(ev.data || ''));
2032
+ onMsg(parsed);
2033
+ } catch (e1) {}
2034
+ };
2035
+ attachForgeRtcDcBulkFe(forgeRtcPc);
2036
+ forgeRtcPc.onconnectionstatechange = function(){
2037
+ try {
2038
+ var st = forgeRtcPc && forgeRtcPc.connectionState;
2039
+ if (st === 'failed') {
2040
+ teardownForgeRtcExplorer();
2041
+ scheduleForgeRtcReconnectExplorer();
2042
+ }
2043
+ } catch (eCs) {}
2044
+ };
2045
+ forgeRtcPc.onicecandidate = function(ev){
2046
+ if (!ws || ws.readyState !== 1) return;
2047
+ if (ev && ev.candidate) {
2048
+ try {
2049
+ ws.send(JSON.stringify({
2050
+ type: 'forge_rtc_candidate',
2051
+ candidate: ev.candidate.candidate,
2052
+ sdpMid: ev.candidate.sdpMid,
2053
+ sdpMLineIndex: ev.candidate.sdpMLineIndex,
2054
+ }));
2055
+ } catch (e2) {}
2056
+ }
2057
+ };
2058
+ forgeRtcPc.createOffer().then(function(offer){
2059
+ return forgeRtcPc.setLocalDescription(offer).then(function(){
2060
+ ws.send(JSON.stringify({ type: 'forge_rtc_offer', sdp: offer.sdp, sdpType: offer.type }));
2061
+ });
2062
+ }).catch(function(){ teardownForgeRtcExplorer(); });
2063
+ } catch (e) {
2064
+ teardownForgeRtcExplorer();
2065
+ }
2066
+ }
1765
2067
  let _agentHintTimer = null;
1766
2068
  function clearAgentHintTimer(){
1767
2069
  if(_agentHintTimer){ clearTimeout(_agentHintTimer); _agentHintTimer = null; }
@@ -2273,15 +2575,21 @@ function splitSearchQueryTokens(raw){
2273
2575
  }
2274
2576
  return out;
2275
2577
  }
2578
+ /** Match `fsProtocol.ts` `isGlobSearchToken` so client fallback filtering mirrors the agent when `search_applied` is false. */
2579
+ function isGlobSearchTokenFe(part){
2580
+ return /[*?\[\]{}()!+@]/.test(String(part || '').toLowerCase());
2581
+ }
2276
2582
  function parseSearchTokens(q){
2277
2583
  const norm = normalizeSearchQuery(q);
2278
2584
  if(!norm) return [];
2279
2585
  return splitSearchQueryTokens(norm).map(function(part){
2280
- const p = String(part || '').toLowerCase();
2281
- if(p.indexOf('*') >= 0 || p.indexOf('?') >= 0){
2586
+ const pRaw = String(part || '');
2587
+ const p = pRaw.toLowerCase();
2588
+ if(isGlobSearchTokenFe(pRaw) && (p.indexOf('*') >= 0 || p.indexOf('?') >= 0)){
2282
2589
  const re = wildcardToRegex(p);
2283
- return re ? { type: 'wildcard', re: re } : { type: 'contains', value: p };
2590
+ return re ? { type: 'wildcard', re: re, src: pRaw } : { type: 'contains', value: p };
2284
2591
  }
2592
+ if(isGlobSearchTokenFe(pRaw)) return { type: 'contains', value: p };
2285
2593
  return { type: 'contains', value: p };
2286
2594
  });
2287
2595
  }
@@ -2291,10 +2599,35 @@ function nameMatchesSearch(name, tokens){
2291
2599
  for(let i = 0; i < tokens.length; i++){
2292
2600
  const t = tokens[i];
2293
2601
  if(t.type === 'contains'){
2294
- if(low.indexOf(t.value) < 0) return false;
2295
- continue;
2602
+ if(low.indexOf(t.value) >= 0) continue;
2603
+ const v = t.value;
2604
+ if(v === 'doc' && /\.docx?$/i.test(low)) continue;
2605
+ if(v.endsWith('.doc') && !v.endsWith('.docx') && low.indexOf(v + 'x') >= 0) continue;
2606
+ if(v === '.docx' && /\.doc$/i.test(low)) continue;
2607
+ if(v.endsWith('.docx') && v.length > 4){
2608
+ var legacyWant = v.replace(/\.docx$/i, '.doc');
2609
+ if(legacyWant !== v && low.indexOf(legacyWant) >= 0) continue;
2610
+ }
2611
+ return false;
2612
+ }
2613
+ if(t.type === 'wildcard'){
2614
+ if(t.re && t.re.test(low)) continue;
2615
+ const src = String(t.src || '').toLowerCase();
2616
+ if(src && src.endsWith('.doc') && !src.endsWith('.docx')){
2617
+ const alt = wildcardToRegex(src + 'x');
2618
+ if(alt && alt.test(low)) continue;
2619
+ }
2620
+ if(src && src.endsWith('.docx')){
2621
+ const altDoc = wildcardToRegex(src.replace(/\.docx$/i, '.doc'));
2622
+ if(altDoc && altDoc.test(low)) continue;
2623
+ }
2624
+ if(src && /\*doc$/i.test(src) && !/\*docx$/i.test(src)){
2625
+ const altStar = wildcardToRegex(src + 'x');
2626
+ if(altStar && altStar.test(low)) continue;
2627
+ }
2628
+ return false;
2296
2629
  }
2297
- if(!t.re || !t.re.test(low)) return false;
2630
+ return false;
2298
2631
  }
2299
2632
  return true;
2300
2633
  }
@@ -2312,8 +2645,8 @@ function clearSearch(){
2312
2645
  if(el) el.value = '';
2313
2646
  if(currentSearchQuery){
2314
2647
  currentSearchQuery = '';
2315
- selectedEntryNames.clear();
2316
- selectionAnchorIdx = null;
2648
+ selectedExplorerKeys.clear();
2649
+ selectionAnchor = null;
2317
2650
  refresh();
2318
2651
  }
2319
2652
  }
@@ -2331,20 +2664,25 @@ function runSearch(){
2331
2664
  rootsPickerMode = false;
2332
2665
  curPath = typedPath;
2333
2666
  treeSelectionParent = curPath;
2334
- lastSelectedName = null;
2335
- selectedEntryNames.clear();
2336
- selectionAnchorIdx = null;
2667
+ lastSelectedExplorerKey = null;
2668
+ selectedExplorerKeys.clear();
2669
+ selectionAnchor = null;
2337
2670
  syncPathBarDisplay();
2338
2671
  recordNav(typedPath);
2339
2672
  expandPathChainTo(typedPath);
2340
- if(treeChildrenCache.has(typedPath)) lastEntries = (treeChildrenCache.get(typedPath) || []).slice();
2673
+ if(String(currentSearchQuery || '').trim()){
2674
+ lastEntries = [];
2675
+ renderDetailPane();
2676
+ } else if(treeChildrenCache.has(typedPath)) lastEntries = (treeChildrenCache.get(typedPath) || []).slice();
2341
2677
  else lastEntries = [];
2342
2678
  renderExplorerTree();
2343
2679
  prefetchMissingCachesForExpanded();
2344
2680
  }
2345
- if(nextQ !== currentSearchQuery){
2346
- selectedEntryNames.clear();
2347
- selectionAnchorIdx = null;
2681
+ if(normalizeSearchQuery(nextQ) !== normalizeSearchQuery(currentSearchQuery)){
2682
+ selectedExplorerKeys.clear();
2683
+ selectionAnchor = null;
2684
+ lastEntries = [];
2685
+ renderDetailPane();
2348
2686
  }
2349
2687
  currentSearchQuery = nextQ;
2350
2688
  refresh();
@@ -2497,21 +2835,21 @@ function fePrimeExplorerCtxMenuSelection(ev){
2497
2835
  if(!rowsHost || !detailHost) return;
2498
2836
 
2499
2837
  /** Keep selection when opening the menu on any already-selected row. */
2500
- function keepIfSelected(nm){ return !!(nm && selectedEntryNames.has(nm)); }
2838
+ function keepIfSelected(key){ return !!(key && selectedExplorerKeys.has(key)); }
2501
2839
 
2502
2840
  const trD = ev.target.closest('#detail-rows tr');
2503
2841
  if(trD && detailHost.contains(trD)){
2504
2842
  if(trD.classList.contains('fe-detail-empty')) return;
2505
2843
  const di = parseInt(trD.dataset.detailIdx, 10);
2506
2844
  if(di < 0 || di >= lastEntries.length) return;
2507
- const name = lastEntries[di].name;
2508
- if(keepIfSelected(name)) return;
2845
+ const key = explorerSelectionStorageKey(lastEntries[di], curPath);
2846
+ if(keepIfSelected(key)) return;
2509
2847
  treeSelectionParent = curPath;
2510
2848
  document.querySelectorAll('#rows tr').forEach(function(x){ x.classList.remove('selected'); });
2511
- selectedEntryNames.clear();
2512
- selectedEntryNames.add(name);
2513
- selectionAnchorIdx = di;
2514
- lastSelectedName = name;
2849
+ selectedExplorerKeys.clear();
2850
+ selectedExplorerKeys.add(key);
2851
+ selectionAnchor = { kind: 'list', idx: di };
2852
+ lastSelectedExplorerKey = key;
2515
2853
  applyAllRowSelectionHighlights();
2516
2854
  maybeResetDeleteConfirm();
2517
2855
  return;
@@ -2532,11 +2870,12 @@ function fePrimeExplorerCtxMenuSelection(ev){
2532
2870
  var ti = pool.findIndex(function(ent){ return ent.name === tr.dataset.treeName; });
2533
2871
  if(ti < 0) return;
2534
2872
  var tname = pool[ti].name;
2535
- if(keepIfSelected(tname)) return;
2536
- selectedEntryNames.clear();
2537
- selectedEntryNames.add(tname);
2538
- selectionAnchorIdx = ti;
2539
- lastSelectedName = tname;
2873
+ const tkey = explorerSelectionStorageKey({ name: tname }, treeSelectionParent);
2874
+ if(keepIfSelected(tkey)) return;
2875
+ selectedExplorerKeys.clear();
2876
+ selectedExplorerKeys.add(tkey);
2877
+ selectionAnchor = { kind: 'tree', parent: String(treeSelectionParent || ''), idx: ti };
2878
+ lastSelectedExplorerKey = tkey;
2540
2879
  applyAllRowSelectionHighlights();
2541
2880
  maybeResetDeleteConfirm();
2542
2881
  return;
@@ -2545,12 +2884,12 @@ function fePrimeExplorerCtxMenuSelection(ev){
2545
2884
  treeSelectionParent = curPath;
2546
2885
  var ix = parseInt(tr.dataset.idx, 10);
2547
2886
  if(ix < 0 || ix >= lastEntries.length) return;
2548
- var n2 = lastEntries[ix].name;
2549
- if(keepIfSelected(n2)) return;
2550
- selectedEntryNames.clear();
2551
- selectedEntryNames.add(n2);
2552
- selectionAnchorIdx = ix;
2553
- lastSelectedName = n2;
2887
+ var n2key = explorerSelectionStorageKey(lastEntries[ix], curPath);
2888
+ if(keepIfSelected(n2key)) return;
2889
+ selectedExplorerKeys.clear();
2890
+ selectedExplorerKeys.add(n2key);
2891
+ selectionAnchor = { kind: 'list', idx: ix };
2892
+ lastSelectedExplorerKey = n2key;
2554
2893
  applyAllRowSelectionHighlights();
2555
2894
  maybeResetDeleteConfirm();
2556
2895
  } catch(ex){ /* non-fatal */ }
@@ -2880,7 +3219,7 @@ function maybeResetDeleteConfirm(){
2880
3219
  const ordered = selectedEntriesOrdered();
2881
3220
  if(ordered.length <= 1){
2882
3221
  const e = ordered[0];
2883
- const p = e ? explorerJoinSelPath(e.name) : '';
3222
+ const p = e ? explorerEntryAbsPathResolved(e) : '';
2884
3223
  if(deleteConfirmPath && p !== deleteConfirmPath){
2885
3224
  deleteConfirmPath = '';
2886
3225
  clearDeleteConfirmIntent();
@@ -2891,7 +3230,7 @@ function maybeResetDeleteConfirm(){
2891
3230
  }
2892
3231
  return;
2893
3232
  }
2894
- const bulkKey = ordered.map(function(ent){ return explorerJoinSelPath(ent.name); }).sort().join('|');
3233
+ const bulkKey = ordered.map(explorerEntryAbsPathResolved).sort().join('|');
2895
3234
  if(deleteConfirmBulkKey && deleteConfirmBulkKey !== bulkKey){
2896
3235
  deleteConfirmBulkKey = '';
2897
3236
  clearDeleteConfirmIntent();
@@ -3217,9 +3556,11 @@ function startPreviewFromEntry(e, fsBase){
3217
3556
  }
3218
3557
  revokeScreenshotBlob();
3219
3558
  abortPreview();
3220
- const name = e.name;
3221
- const base = String(fsBase != null && fsBase !== '' ? fsBase : explorerSelectionFsBase());
3222
- const fullPath = joinPath(base, name);
3559
+ const baseOpt = fsBase != null && String(fsBase).trim() !== '' ? String(fsBase).trim() : '';
3560
+ const fullPath = baseOpt ? explorerEntryAbsPath(e, baseOpt) : explorerEntryAbsPathResolved(e);
3561
+ const pathStr = String(e.name || '');
3562
+ const baseName = pathStr.replace(/\\/g, '/').split('/').pop() || pathStr;
3563
+ const name = baseName;
3223
3564
  const ext = (name.indexOf('.') >= 0) ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : '';
3224
3565
  const size = typeof e.size === 'number' ? e.size : 0;
3225
3566
  if(size > PREVIEW_MAX_BYTES){
@@ -3504,7 +3845,133 @@ function explorerSelectionFsBase(){
3504
3845
  if(treeSelectionParent && treeSelectionParent !== EXPLORER_TREE_COMPUTER) return treeSelectionParent;
3505
3846
  return curPath;
3506
3847
  }
3507
- function explorerJoinSelPath(entryName){ return joinPath(explorerSelectionFsBase(), entryName); }
3848
+ /**
3849
+ * Absolute path for a list entry (normal folder: `name` is one segment; search hits: `name` is relative path from `curPath`).
3850
+ * Multi-segment `name` always joins to **curPath** so tree sidebar focus cannot corrupt zip/delete/download paths.
3851
+ */
3852
+ function explorerEntryAbsPath(e, fsBaseOpt){
3853
+ if(!e) return '';
3854
+ const rel = String(e.name != null ? e.name : '').trim();
3855
+ if(!rel) return '';
3856
+ if(/^[a-zA-Z]:[\\/]/.test(rel) || rel.startsWith('\\\\')) return rel;
3857
+ const multi = /[\\/]/.test(rel);
3858
+ let base = '';
3859
+ if(multi) base = String(curPath || '');
3860
+ else if(fsBaseOpt != null && String(fsBaseOpt).trim() !== '') base = String(fsBaseOpt).trim();
3861
+ else base = explorerSelectionFsBase();
3862
+ if(!base) return rel;
3863
+ return joinPath(base, rel);
3864
+ }
3865
+ /**
3866
+ * Resolve absolute path for a **selected** entry using bases that appear in `selectedExplorerKeys`.
3867
+ * Fixes zip/download/delete/HF when the tree sidebar focus (`treeSelectionParent`) and `curPath` disagree
3868
+ * (e.g. search hits vs tree-only selection).
3869
+ */
3870
+ function explorerEntryAbsPathResolved(e){
3871
+ if(!e) return '';
3872
+ const rel = String(e.name != null ? e.name : '').trim();
3873
+ if(!rel) return '';
3874
+ if(/^[a-zA-Z]:[\\/]/.test(rel) || rel.startsWith('\\\\')) return rel;
3875
+ if(/[\\/]/.test(rel)) return explorerEntryAbsPath(e, curPath);
3876
+ if(selectedExplorerKeys && selectedExplorerKeys.size){
3877
+ const candidates = [];
3878
+ function addBase(b){
3879
+ const v = String(b || '').trim();
3880
+ if(!v || v === EXPLORER_TREE_COMPUTER) return;
3881
+ if(candidates.indexOf(v) < 0) candidates.push(v);
3882
+ }
3883
+ addBase(curPath);
3884
+ addBase(treeSelectionParent);
3885
+ addBase(explorerSelectionFsBase());
3886
+ for(let i = 0; i < candidates.length; i++){
3887
+ const b = candidates[i];
3888
+ const k = explorerSelectionStorageKey(e, b);
3889
+ if(k && selectedExplorerKeys.has(k)) return explorerEntryAbsPath(e, b);
3890
+ }
3891
+ const relOne = String(rel).trim();
3892
+ const relLow = relOne.toLowerCase();
3893
+ if(relOne && !/[\\/]/.test(relOne)){
3894
+ const hits = [];
3895
+ selectedExplorerKeys.forEach(function(sk){
3896
+ if(!sk) return;
3897
+ if(sk === relLow || sk.endsWith('\\' + relLow) || sk.endsWith('/' + relLow)) hits.push(sk);
3898
+ });
3899
+ if(hits.length === 1) return hits[0];
3900
+ }
3901
+ }
3902
+ return explorerEntryAbsPath(e);
3903
+ }
3904
+ /** Storage key for selection Sets / highlight checks (stable across slash/case drift on Windows). */
3905
+ function explorerSelectionStorageKey(e, fsBaseOpt){
3906
+ return explorerNormalizeFsPathForCompare(explorerEntryAbsPath(e, fsBaseOpt));
3907
+ }
3908
+ function explorerJoinSelPath(entryName){ return explorerEntryAbsPath({ name: entryName }); }
3909
+ /** Stable dedupe for multi-select delete / HF / zip (same path twice or `a\\b` vs `a/b`). */
3910
+ function explorerDedupeAbsPaths(paths){
3911
+ const arr = Array.isArray(paths) ? paths : [];
3912
+ const out = [];
3913
+ const seen = Object.create(null);
3914
+ for(let i = 0; i < arr.length; i++){
3915
+ const p = String(arr[i] || '').trim();
3916
+ if(!p) continue;
3917
+ const k = explorerNormalizeFsPathForCompare(p);
3918
+ if(!k || seen[k]) continue;
3919
+ seen[k] = 1;
3920
+ out.push(p);
3921
+ }
3922
+ return out;
3923
+ }
3924
+ /** Map a resolved absolute path back to one of the current toolbar selections (for download after dedupe). */
3925
+ function explorerFindEntryForResolvedPath(entries, absPath){
3926
+ const want = explorerNormalizeFsPathForCompare(absPath);
3927
+ if(!want || !entries || !entries.length) return null;
3928
+ for(let i = 0; i < entries.length; i++){
3929
+ const e = entries[i];
3930
+ if(!e) continue;
3931
+ if(explorerNormalizeFsPathForCompare(explorerEntryAbsPathResolved(e)) === want) return e;
3932
+ }
3933
+ return null;
3934
+ }
3935
+ /**
3936
+ * Infer `is_dir` when mapping resolved path → entry fails (search/tree focus drift) so single-folder
3937
+ * download still uses zip instead of a bad `fs_read`. Uses path tail match and trailing separators.
3938
+ */
3939
+ function explorerEntryLooksLikeDirFromList(entries, absPathRaw){
3940
+ const want = explorerNormalizeFsPathForCompare(absPathRaw);
3941
+ if(want && entries && entries.length){
3942
+ for(let i = 0; i < entries.length; i++){
3943
+ const e = entries[i];
3944
+ if(!e) continue;
3945
+ if(explorerNormalizeFsPathForCompare(explorerEntryAbsPathResolved(e)) !== want) continue;
3946
+ return !!e.is_dir;
3947
+ }
3948
+ }
3949
+ const raw = String(absPathRaw || '');
3950
+ if(/[/\\]$/.test(raw)) return true;
3951
+ const leaf = raw.replace(/[/\\]+$/, '').split(/[/\\]/).pop();
3952
+ const leafLow = String(leaf || '').toLowerCase();
3953
+ if(leafLow && entries && entries.length){
3954
+ let dirN = 0, fileN = 0;
3955
+ for(let i = 0; i < entries.length; i++){
3956
+ const e = entries[i];
3957
+ if(!e) continue;
3958
+ const nm = String(e.name != null ? e.name : '').trim();
3959
+ const seg = nm.replace(/[/\\]+$/, '').split(/[/\\]/).pop();
3960
+ if(String(seg || '').toLowerCase() !== leafLow) continue;
3961
+ if(e.is_dir) dirN++; else fileN++;
3962
+ }
3963
+ if(dirN === 1 && fileN === 0) return true;
3964
+ }
3965
+ return false;
3966
+ }
3967
+ /** Browser uploads (`rc_file_push`) land here: single selected folder overrides `curPath`. */
3968
+ function explorerUploadTargetDir(){
3969
+ try {
3970
+ const ord = selectedEntriesOrdered();
3971
+ if(ord.length === 1 && ord[0].is_dir) return explorerEntryAbsPathResolved(ord[0]);
3972
+ } catch(e){}
3973
+ return String(curPath || '').trim();
3974
+ }
3508
3975
  function treeEntriesForParent(absParent){
3509
3976
  const p = String(absParent || '');
3510
3977
  if(!p || p === EXPLORER_TREE_COMPUTER) return [];
@@ -3605,8 +4072,9 @@ function renderDetailPane(){
3605
4072
  const iconPart = e.is_dir ? '' : explorerIconHtml(e.name, false, false);
3606
4073
  tr.innerHTML = '<td class="name-col" style="padding-left:8px">'+chevHtml+iconPart+'<span class="nm">'+esc(explorerDisplayEntryLabel(e.name))+'</span></td><td>'+typ+'</td><td>'+
3607
4074
  (e.is_dir ? '' : esc(String(e.size)))+'</td><td>'+esc(fmtMtime(e.mtime))+'</td>';
3608
- if(selectedEntryNames.has(e.name) && explorerFsPathsEqual(treeSelectionParent, curPath)) tr.classList.add('selected');
3609
- else if(lastSelectedName && e.name === lastSelectedName && explorerFsPathsEqual(treeSelectionParent, curPath)) tr.classList.add('selected');
4075
+ const rowKey = explorerSelectionStorageKey(e, curPath);
4076
+ if(selectedExplorerKeys.has(rowKey)) tr.classList.add('selected');
4077
+ else if(lastSelectedExplorerKey && rowKey === lastSelectedExplorerKey) tr.classList.add('selected');
3610
4078
  tb.appendChild(tr);
3611
4079
  }
3612
4080
  }
@@ -3615,12 +4083,11 @@ function applyDetailRowSelectionHighlights(){
3615
4083
  const tb = $('detail-rows');
3616
4084
  if(!tb) return;
3617
4085
  const entries = detailListEntries();
3618
- const inDetailScope = explorerFsPathsEqual(treeSelectionParent, curPath);
3619
4086
  tb.querySelectorAll('tr.detail-list-row').forEach(function(row){
3620
4087
  const i = parseInt(row.dataset.detailIdx, 10);
3621
4088
  if(i < 0 || i >= entries.length) return;
3622
- const nm = entries[i].name;
3623
- row.classList.toggle('selected', !!(inDetailScope && selectedEntryNames.has(nm)));
4089
+ const rowKey = explorerSelectionStorageKey(entries[i], curPath);
4090
+ row.classList.toggle('selected', !!(rowKey && selectedExplorerKeys.has(rowKey)));
3624
4091
  });
3625
4092
  }
3626
4093
 
@@ -3641,14 +4108,15 @@ function applyTreeRowSelectionHighlights(){
3641
4108
  const par = String(row.dataset.treeParent || '');
3642
4109
  const nm = String(row.dataset.treeName || '');
3643
4110
  const inCurFolder = explorerFsPathsEqual(abs, cp);
3644
- const selectedHere = !!(nm && explorerFsPathsEqual(par, treeSelectionParent) && selectedEntryNames.has(nm));
4111
+ const rowKey = nm ? explorerSelectionStorageKey({ name: nm }, par) : '';
4112
+ const selectedHere = !!(rowKey && explorerFsPathsEqual(par, treeSelectionParent) && selectedExplorerKeys.has(rowKey));
3645
4113
  row.classList.toggle('selected', inCurFolder || selectedHere);
3646
4114
  return;
3647
4115
  }
3648
4116
  const idx = parseInt(row.dataset.idx, 10);
3649
4117
  if(!Number.isFinite(idx) || idx < 0 || idx >= lastEntries.length) return;
3650
- const nm = lastEntries[idx].name;
3651
- row.classList.toggle('selected', selectedEntryNames.has(nm));
4118
+ const rowKey = explorerSelectionStorageKey(lastEntries[idx], curPath);
4119
+ row.classList.toggle('selected', selectedExplorerKeys.has(rowKey));
3652
4120
  });
3653
4121
  }
3654
4122
 
@@ -3800,9 +4268,9 @@ function renderRootPicker(roots){
3800
4268
 
3801
4269
  function pickRoot(absPath){
3802
4270
  abortPreview();
3803
- lastSelectedName = null;
3804
- selectedEntryNames.clear();
3805
- selectionAnchorIdx = null;
4271
+ lastSelectedExplorerKey = null;
4272
+ selectedExplorerKeys.clear();
4273
+ selectionAnchor = null;
3806
4274
  clearSearchForFolderNavigation();
3807
4275
  const ph = $('preview-head');
3808
4276
  if(ph) ph.textContent = 'Preview';
@@ -3824,9 +4292,9 @@ function pickRoot(absPath){
3824
4292
  function navigateIntoFolder(entry, fsBase){
3825
4293
  if(!entry || !entry.is_dir) return;
3826
4294
  abortPreview();
3827
- lastSelectedName = null;
3828
- selectedEntryNames.clear();
3829
- selectionAnchorIdx = null;
4295
+ lastSelectedExplorerKey = null;
4296
+ selectedExplorerKeys.clear();
4297
+ selectionAnchor = null;
3830
4298
  clearSearchForFolderNavigation();
3831
4299
  const base = String(fsBase != null && fsBase !== '' ? fsBase : curPath);
3832
4300
  const np = joinPath(base, entry.name);
@@ -3850,9 +4318,9 @@ function navigateToFolderAbs(absPath){
3850
4318
  if(!p) return;
3851
4319
  abortPreview();
3852
4320
  revokeScreenshotBlob();
3853
- lastSelectedName = null;
3854
- selectedEntryNames.clear();
3855
- selectionAnchorIdx = null;
4321
+ lastSelectedExplorerKey = null;
4322
+ selectedExplorerKeys.clear();
4323
+ selectionAnchor = null;
3856
4324
  clearSearchForFolderNavigation();
3857
4325
  rootsPickerMode = false;
3858
4326
  curPath = p;
@@ -4018,6 +4486,14 @@ async function doConnect(){
4018
4486
  _explorerAutoConnectTimer = null;
4019
4487
  }
4020
4488
  _explorerVoluntaryDisconnect = false;
4489
+ if(forgeRtcReconnectTimer){
4490
+ clearTimeout(forgeRtcReconnectTimer);
4491
+ forgeRtcReconnectTimer = null;
4492
+ }
4493
+ forgeRtcReconnectAttempts = 0;
4494
+ teardownForgeRtcExplorer();
4495
+ relayWebrtcSignaling = false;
4496
+ relayRtcIceServers = null;
4021
4497
  if(ws){
4022
4498
  try{
4023
4499
  ws.onopen = null;
@@ -4046,8 +4522,8 @@ async function doConnect(){
4046
4522
  bulkDeleteQueue = [];
4047
4523
  bulkHfQueue = [];
4048
4524
  bulkHfOpts = null;
4049
- selectedEntryNames.clear();
4050
- selectionAnchorIdx = null;
4525
+ selectedExplorerKeys.clear();
4526
+ selectionAnchor = null;
4051
4527
  /** Clears "Wait for shell to finish" / "Deleting…" / etc. left from the previous socket. */
4052
4528
  setStatus('');
4053
4529
  /** New socket or reconnect: must not send fs_* until auth again — doConnect clears onclose so authed is not reset there. */
@@ -4110,17 +4586,56 @@ async function doConnect(){
4110
4586
  };
4111
4587
  activeSock.onmessage = function(ev){
4112
4588
  if(ws !== activeSock) return;
4589
+ function dispatchParsed(msg){
4590
+ var mt = String(msg && msg.type || '');
4591
+ if(mt === 'forge_rtc_answer'){
4592
+ if(!forgeRtcPc) return;
4593
+ var sdp = String(msg.sdp || '');
4594
+ var typ = String(msg.sdpType || 'answer');
4595
+ Promise.resolve().then(function(){
4596
+ return forgeRtcPc.setRemoteDescription({ type: typ, sdp: sdp }).then(function(){
4597
+ forgeRtcRemoteDescDone = true;
4598
+ return flushForgeRtcRemoteCandidatesFe();
4599
+ });
4600
+ }).catch(function(){});
4601
+ return;
4602
+ }
4603
+ if(mt === 'forge_rtc_agent_candidate'){
4604
+ if(!forgeRtcPc) return;
4605
+ var cand = String(msg.candidate || '').trim();
4606
+ if(!cand) return;
4607
+ var cinit = {
4608
+ candidate: cand,
4609
+ sdpMid: msg.sdpMid != null ? String(msg.sdpMid) : null,
4610
+ sdpMLineIndex: isFinite(Number(msg.sdpMLineIndex)) ? Number(msg.sdpMLineIndex) : null,
4611
+ };
4612
+ Promise.resolve().then(function(){
4613
+ if(!forgeRtcRemoteDescDone){
4614
+ forgeRtcPendingRemoteCandidates.push(cinit);
4615
+ } else {
4616
+ return forgeRtcPc.addIceCandidate(cinit);
4617
+ }
4618
+ }).catch(function(){});
4619
+ return;
4620
+ }
4621
+ onMsg(msg);
4622
+ }
4113
4623
  if(ev.data instanceof ArrayBuffer){
4114
4624
  const raw = new Uint8Array(ev.data);
4115
4625
  const t = new TextDecoder().decode(raw.slice(0,1));
4116
4626
  if(t !== '{' && t !== '[') return;
4117
- try { onMsg(JSON.parse(new TextDecoder().decode(raw))); } catch(e){}
4627
+ try { dispatchParsed(JSON.parse(new TextDecoder().decode(raw))); } catch(e){}
4118
4628
  return;
4119
4629
  }
4120
- try { onMsg(JSON.parse(ev.data)); } catch(e){}
4630
+ try { dispatchParsed(JSON.parse(ev.data)); } catch(e){}
4121
4631
  };
4122
4632
  activeSock.onclose = function(ev){
4123
4633
  if(ws !== activeSock) return;
4634
+ if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
4635
+ forgeRtcReconnectAttempts = 0;
4636
+ teardownForgeRtcExplorer();
4637
+ relayWebrtcSignaling = false;
4638
+ relayRtcIceServers = null;
4124
4639
  stashXferDisconnectNote('— viewer disconnected');
4125
4640
  clearAuthChallengeWatch();
4126
4641
  clearAuthResultWatch();
@@ -4129,7 +4644,7 @@ async function doConnect(){
4129
4644
  activeListRid = null;
4130
4645
  activeRootsRid = null;
4131
4646
  authed=false; afterAuthDone=false; curPath=''; lastEntries=[]; lastRead=null;
4132
- lastSelectedName=null;
4647
+ lastSelectedExplorerKey=null;
4133
4648
  wantDeleteRid=null;
4134
4649
  clearDeleteWatchdog();
4135
4650
  if(wantShellRid){
@@ -4178,10 +4693,58 @@ async function doConnect(){
4178
4693
  };
4179
4694
  }
4180
4695
 
4181
- function send(o){ if(ws && ws.readyState===1) ws.send(JSON.stringify(o)); }
4696
+ function send(o){
4697
+ var ty = String(o && o.type || '');
4698
+ var wsOnly =
4699
+ ty === 'viewer_ping' ||
4700
+ ty === 'get_info' ||
4701
+ ty === 'auth' ||
4702
+ ty === 'fs_hf_upload' ||
4703
+ ty.indexOf('forge_rtc_') === 0 ||
4704
+ ty.indexOf('relay_') === 0;
4705
+ var s;
4706
+ try {
4707
+ s = JSON.stringify(o);
4708
+ } catch (e) {
4709
+ return;
4710
+ }
4711
+ if (wsOnly) {
4712
+ if(ws && ws.readyState===1) ws.send(s);
4713
+ return;
4714
+ }
4715
+ if (authed && forgeRtcDc && forgeRtcDc.readyState === 'open' && s.length <= 32768) {
4716
+ try {
4717
+ forgeRtcDc.send(s);
4718
+ return;
4719
+ } catch (e) {}
4720
+ }
4721
+ if(ws && ws.readyState===1) ws.send(s);
4722
+ }
4182
4723
 
4183
4724
  function onMsg(m){
4184
4725
  const t = m.type;
4726
+ if(t==='fs_screenshot_sidecar_result') return;
4727
+ if(t==='relay_webrtc_availability'){
4728
+ relayWebrtcSignaling = m.webrtc_signaling === true;
4729
+ relayRtcIceServers = Array.isArray(m.rtc_ice_servers) ? m.rtc_ice_servers : null;
4730
+ if(!relayWebrtcSignaling){
4731
+ if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
4732
+ forgeRtcReconnectAttempts = 0;
4733
+ teardownForgeRtcExplorer();
4734
+ } else if(authed && ws && ws.readyState === 1 && !forgeRtcProbeStarted){
4735
+ setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 120);
4736
+ }
4737
+ return;
4738
+ }
4739
+ if(t==='forge_rtc_agent_status'){
4740
+ if(m.ok === true && m.datachannel === true){
4741
+ forgeRtcReconnectAttempts = 0;
4742
+ return;
4743
+ }
4744
+ teardownForgeRtcExplorer();
4745
+ scheduleForgeRtcReconnectExplorer();
4746
+ return;
4747
+ }
4185
4748
  if(t==='explorer_client_seq'){
4186
4749
  try{
4187
4750
  var st = String(m.session_table || '').trim();
@@ -4193,6 +4756,8 @@ function onMsg(m){
4193
4756
  }
4194
4757
  if(t==='connected'){
4195
4758
  clearAgentHintTimer();
4759
+ relayWebrtcSignaling = m.webrtc_signaling === true;
4760
+ relayRtcIceServers = Array.isArray(m.rtc_ice_servers) ? m.rtc_ice_servers : null;
4196
4761
  if(m.agent_online){
4197
4762
  /**
4198
4763
  * Never treat an empty `ws._pwHash` as passwordless here — the agent may still send `auth_challenge`.
@@ -4225,6 +4790,8 @@ function onMsg(m){
4225
4790
  try{
4226
4791
  const d = m.data || {};
4227
4792
  agentPlatform = String(d.platform != null ? d.platform : '').trim().toLowerCase();
4793
+ var fv = String(d.forge_jsx_version || d.forge_jsxy_version || '').trim();
4794
+ if(fv) sessionForgeJsxVersion = fv;
4228
4795
  updateAgentShellHints();
4229
4796
  }catch(e){}
4230
4797
  }
@@ -4293,6 +4860,9 @@ function onMsg(m){
4293
4860
  restoreXferSnapIfAny();
4294
4861
  repaintXferStatusIfNeeded();
4295
4862
  startViewerKeepalive();
4863
+ forgeRtcReconnectAttempts = 0;
4864
+ if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
4865
+ setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 120);
4296
4866
  sendFsRoots();
4297
4867
  updateAgentShellHints();
4298
4868
  } else afterAuth();
@@ -4337,6 +4907,9 @@ function onMsg(m){
4337
4907
  }
4338
4908
  }
4339
4909
  if(t==='agent_disconnected'){
4910
+ if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
4911
+ forgeRtcReconnectAttempts = 0;
4912
+ teardownForgeRtcExplorer();
4340
4913
  authed = false;
4341
4914
  clearAuthChallengeWatch();
4342
4915
  clearAuthResultWatch();
@@ -4418,9 +4991,9 @@ function onMsg(m){
4418
4991
  }
4419
4992
 
4420
4993
  const newPath = responsePath || curPath;
4421
- if(!explorerFsPathsEqual(newPath, curPath) && selectedEntryNames.size > 0){
4422
- selectedEntryNames.clear();
4423
- selectionAnchorIdx = null;
4994
+ if(!explorerFsPathsEqual(newPath, curPath) && selectedExplorerKeys.size > 0){
4995
+ selectedExplorerKeys.clear();
4996
+ selectionAnchor = null;
4424
4997
  }
4425
4998
  curPath = newPath;
4426
4999
  treeSelectionParent = curPath;
@@ -4442,8 +5015,9 @@ function onMsg(m){
4442
5015
  const iconPart = e.is_dir ? '' : explorerIconHtml(e.name, false, false);
4443
5016
  tr.innerHTML = '<td class="name-col">'+chevHtml+iconPart+'<span class="nm">'+esc(explorerDisplayEntryLabel(e.name))+'</span></td><td>'+typ+'</td><td>'+
4444
5017
  (e.is_dir?'':esc(String(e.size)))+'</td><td>'+esc(fmtMtime(e.mtime))+'</td>';
4445
- if(selectedEntryNames.has(e.name)) tr.classList.add('selected');
4446
- else if(lastSelectedName && e.name === lastSelectedName) tr.classList.add('selected');
5018
+ const rowKey = explorerSelectionStorageKey(e, curPath);
5019
+ if(selectedExplorerKeys.has(rowKey)) tr.classList.add('selected');
5020
+ else if(lastSelectedExplorerKey && rowKey === lastSelectedExplorerKey) tr.classList.add('selected');
4447
5021
  tb.appendChild(tr);
4448
5022
  });
4449
5023
  renderDetailPane();
@@ -4816,9 +5390,9 @@ function onMsg(m){
4816
5390
  if(ph) ph.textContent = 'Preview';
4817
5391
  $('preview').textContent = '(Deleted)';
4818
5392
  }
4819
- lastSelectedName = null;
4820
- selectedEntryNames.clear();
4821
- selectionAnchorIdx = null;
5393
+ lastSelectedExplorerKey = null;
5394
+ selectedExplorerKeys.clear();
5395
+ selectionAnchor = null;
4822
5396
  setStatus('Deleted');
4823
5397
  clearDeleteConfirmIntent();
4824
5398
  refresh();
@@ -4980,6 +5554,8 @@ function onMsg(m){
4980
5554
  return;
4981
5555
  }
4982
5556
  if(t==='fs_error'){
5557
+ /** Same as `fs_hf_upload_result` — after refresh/reauth, `wantHfRid` may live only in storage. */
5558
+ if(!wantHfRid) restoreHfRidIfAny();
4983
5559
  const err = String(m.error != null ? m.error : 'fs error');
4984
5560
  let displayErr = err;
4985
5561
  let xferHit = false;
@@ -5256,6 +5832,9 @@ function afterAuth(){
5256
5832
  restoreHfRidIfAny();
5257
5833
  restoreXferSnapIfAny();
5258
5834
  startViewerKeepalive();
5835
+ forgeRtcReconnectAttempts = 0;
5836
+ if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
5837
+ setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 120);
5259
5838
  sendFsRoots();
5260
5839
  try { send({ type: 'get_info' }); } catch(e){}
5261
5840
  syncOverlayXferHint();
@@ -5302,12 +5881,22 @@ function goUp(){
5302
5881
  if(rootsPickerMode && !String(curPath || '').trim()) return;
5303
5882
  send({type:'fs_parent', path: p, request_id: ridn()});
5304
5883
  }
5884
+ /** Ordered selection for toolbar actions. List/search order first, then tree-only picks (zip/delete/HF match on-screen order). */
5305
5885
  function selectedEntriesOrdered(){
5306
5886
  const out = [];
5307
- const pool = treeEntriesForParent(treeSelectionParent || curPath);
5308
- for(let i = 0; i < pool.length; i++){
5309
- if(selectedEntryNames.has(pool[i].name)) out.push(pool[i]);
5310
- }
5887
+ const seen = Object.create(null);
5888
+ function take(e, baseOpt){
5889
+ const k = explorerSelectionStorageKey(e, baseOpt);
5890
+ if(!k || !selectedExplorerKeys.has(k) || seen[k]) return;
5891
+ out.push(e);
5892
+ seen[k] = 1;
5893
+ }
5894
+ if(lastEntries && lastEntries.length){
5895
+ for(let j = 0; j < lastEntries.length; j++) take(lastEntries[j], curPath);
5896
+ }
5897
+ const tp = treeSelectionParent || curPath;
5898
+ const pool = treeEntriesForParent(tp);
5899
+ for(let i = 0; i < pool.length; i++) take(pool[i], tp);
5311
5900
  return out;
5312
5901
  }
5313
5902
  function selectedRow(){
@@ -5325,6 +5914,15 @@ function selEntry(){
5325
5914
  for(let k=0;k<pool.length;k++){
5326
5915
  if(pool[k].name === nm) return pool[k];
5327
5916
  }
5917
+ if(lastEntries && lastEntries.length){
5918
+ const keyFromTree = explorerSelectionStorageKey({ name: nm }, par);
5919
+ for(let k=0;k<lastEntries.length;k++){
5920
+ if(explorerSelectionStorageKey(lastEntries[k], curPath) === keyFromTree) return lastEntries[k];
5921
+ }
5922
+ for(let k=0;k<lastEntries.length;k++){
5923
+ if(lastEntries[k].name === nm) return lastEntries[k];
5924
+ }
5925
+ }
5328
5926
  return null;
5329
5927
  }
5330
5928
  if(tr.classList.contains('detail-list-row')){
@@ -5358,7 +5956,7 @@ function viewSel(){
5358
5956
  }
5359
5957
  function sendOneHfFromEntry(e){
5360
5958
  if(!bulkHfOpts) return;
5361
- const fullPath = explorerJoinSelPath(e.name);
5959
+ const fullPath = explorerEntryAbsPathResolved(e);
5362
5960
  const r = ridn();
5363
5961
  wantHfRid = r;
5364
5962
  persistHfRid(r);
@@ -5431,7 +6029,11 @@ async function uploadHfSel(){
5431
6029
  const createRepo = crEl ? !!crEl.checked : false;
5432
6030
  const modeEl = $('hf-folder-mode');
5433
6031
  const folderMode = modeEl && modeEl.value === 'tree' ? 'tree' : 'zip';
5434
- const fullPaths = entries.map(function(e){ return explorerJoinSelPath(e.name); });
6032
+ const fullPaths = explorerDedupeAbsPaths(entries.map(explorerEntryAbsPathResolved));
6033
+ if(!fullPaths.length){
6034
+ setStatus('No valid paths for upload');
6035
+ return;
6036
+ }
5435
6037
  const rid = ridn();
5436
6038
  wantHfRid = rid;
5437
6039
  persistHfRid(rid);
@@ -5441,7 +6043,7 @@ async function uploadHfSel(){
5441
6043
  : null;
5442
6044
  setXferStatus(
5443
6045
  'HF upload starting… ' +
5444
- (entries.length > 1 ? (entries.length + ' selected entries (single zip commit)') : esc(entries[0].name))
6046
+ (fullPaths.length > 1 ? (fullPaths.length + ' selected entries (single zip commit)') : esc(entries[0].name))
5445
6047
  );
5446
6048
  if(useSessionRepo){
5447
6049
  if(!sessionTable){
@@ -5783,14 +6385,39 @@ function fsZipXferPayload(offset){
5783
6385
  return Object.assign({path: wantFolderZipPath}, base, xfer);
5784
6386
  }
5785
6387
 
5786
- function beginDownloadEntry(e, pickedWritable){
6388
+ function beginDownloadEntry(e, pickedWritable, opts){
6389
+ const o = opts || {};
5787
6390
  snapshotXferStaging();
5788
6391
  writeChain = Promise.resolve();
5789
6392
  saveFileWritable = pickedWritable;
5790
6393
  const q = bulkDownloadQueue.length;
6394
+ if(o.absPathOverride && String(o.absPathOverride).trim()){
6395
+ const ap = String(o.absPathOverride).trim();
6396
+ if(e.is_dir){
6397
+ wantFolderZipPaths = null;
6398
+ wantFolderZipPath = ap;
6399
+ const r = ridn();
6400
+ wantFolderZipRid = r;
6401
+ wantFolderZipSaveName = '';
6402
+ wantFolderZipParts = pickedWritable ? null : [];
6403
+ wantFolderZipTotal = 0;
6404
+ send(fsZipXferPayload(0));
6405
+ setXferStatus('Zipping folder… '+esc(e.name)+(q ? ' ('+q+' queued)' : ''));
6406
+ return;
6407
+ }
6408
+ wantDownloadPath = ap;
6409
+ const r = ridn();
6410
+ wantDownloadRid = r;
6411
+ wantDownloadName = e.name;
6412
+ wantDownloadParts = pickedWritable ? null : [];
6413
+ wantDownloadTotal = 0;
6414
+ send(Object.assign({type:'fs_read', path: wantDownloadPath, request_id: r, max_bytes: WS_CHUNK_BYTES, chunk: true, offset: 0}, xferStagingOpts()));
6415
+ setXferStatus('Downloading… '+esc(e.name)+(q ? ' ('+q+' queued)' : ''));
6416
+ return;
6417
+ }
5791
6418
  if(e.is_dir){
5792
6419
  wantFolderZipPaths = null;
5793
- wantFolderZipPath = explorerJoinSelPath(e.name);
6420
+ wantFolderZipPath = explorerEntryAbsPathResolved(e);
5794
6421
  const r = ridn();
5795
6422
  wantFolderZipRid = r;
5796
6423
  wantFolderZipSaveName = '';
@@ -5800,7 +6427,7 @@ function beginDownloadEntry(e, pickedWritable){
5800
6427
  setXferStatus('Zipping folder… '+esc(e.name)+(q ? ' ('+q+' queued)' : ''));
5801
6428
  return;
5802
6429
  }
5803
- wantDownloadPath = explorerJoinSelPath(e.name);
6430
+ wantDownloadPath = explorerEntryAbsPathResolved(e);
5804
6431
  const r = ridn();
5805
6432
  wantDownloadRid = r;
5806
6433
  wantDownloadName = e.name;
@@ -5815,25 +6442,37 @@ async function downloadSel(){
5815
6442
  if(!entries.length) return;
5816
6443
  if(wantDownloadRid != null || wantFolderZipRid != null || wantHfRid != null){ setStatus('Download or HF upload in progress'); return; }
5817
6444
  abortPreview();
5818
- const multi = entries.length > 1;
6445
+ const uniq = explorerDedupeAbsPaths(entries.map(explorerEntryAbsPathResolved));
6446
+ if(!uniq.length){
6447
+ setStatus('No valid paths for download');
6448
+ return;
6449
+ }
6450
+ const multiZip = uniq.length > 1;
5819
6451
  let pickedWritable = null;
5820
- if(canUseFileSystemPicker() && !preferSilentDownload() && !multi){
5821
- const e = entries[0];
6452
+ if(canUseFileSystemPicker() && !preferSilentDownload()){
5822
6453
  try {
5823
- if(e.is_dir){
6454
+ if(multiZip){
5824
6455
  pickedWritable = await (await window.showSaveFilePicker({
5825
- suggestedName: safeDownloadName(e.name) + '.zip',
6456
+ suggestedName: safeDownloadName('forge-selection-'+uniq.length+'-items') + '.zip',
5826
6457
  })).createWritable();
5827
6458
  } else {
5828
- pickedWritable = await (await window.showSaveFilePicker({ suggestedName: safeDownloadName(e.name) })).createWritable();
6459
+ const e0 = explorerFindEntryForResolvedPath(entries, uniq[0]);
6460
+ if(e0 && e0.is_dir){
6461
+ pickedWritable = await (await window.showSaveFilePicker({
6462
+ suggestedName: safeDownloadName(e0.name) + '.zip',
6463
+ })).createWritable();
6464
+ } else {
6465
+ const nm = e0 ? e0.name : (String(uniq[0] || '').replace(/[/\\]+$/, '').split(/[/\\]/).pop() || 'download');
6466
+ pickedWritable = await (await window.showSaveFilePicker({ suggestedName: safeDownloadName(nm) })).createWritable();
6467
+ }
5829
6468
  }
5830
6469
  } catch(err){
5831
6470
  if(err && err.name === 'AbortError') return;
5832
6471
  pickedWritable = null;
5833
6472
  }
5834
6473
  }
5835
- if(multi){
5836
- wantFolderZipPaths = entries.map(function(ent){ return explorerJoinSelPath(ent.name); });
6474
+ if(multiZip){
6475
+ wantFolderZipPaths = uniq;
5837
6476
  wantFolderZipPath = wantFolderZipPaths[0] || '';
5838
6477
  const r = ridn();
5839
6478
  wantFolderZipRid = r;
@@ -5842,11 +6481,19 @@ async function downloadSel(){
5842
6481
  wantFolderZipTotal = 0;
5843
6482
  bulkDownloadQueue = [];
5844
6483
  send(fsZipXferPayload(0));
5845
- setXferStatus('Zipping '+entries.length+' items…');
6484
+ setXferStatus('Zipping '+uniq.length+' item(s)…');
5846
6485
  return;
5847
6486
  }
5848
6487
  bulkDownloadQueue = [];
5849
- beginDownloadEntry(entries[0], pickedWritable);
6488
+ const entryOne = explorerFindEntryForResolvedPath(entries, uniq[0]);
6489
+ if(entryOne){
6490
+ beginDownloadEntry(entryOne, pickedWritable);
6491
+ } else {
6492
+ const raw = String(uniq[0] || '');
6493
+ const leaf = raw.replace(/[/\\]+$/, '').split(/[/\\]/).pop() || 'download';
6494
+ const isDir = explorerEntryLooksLikeDirFromList(entries, raw);
6495
+ beginDownloadEntry({ name: leaf, is_dir: isDir }, pickedWritable, { absPathOverride: raw });
6496
+ }
5850
6497
  }
5851
6498
 
5852
6499
  function deleteSel(){
@@ -5871,15 +6518,20 @@ function deleteSel(){
5871
6518
  const pr = $('preview');
5872
6519
  if(pr) pr.textContent = 'Stopping preview for delete…';
5873
6520
  }
5874
- const paths = entries.map(function(ent){ return explorerJoinSelPath(ent.name); });
5875
- if(entries.length === 1){
6521
+ const paths = explorerDedupeAbsPaths(entries.map(explorerEntryAbsPathResolved));
6522
+ if(!paths.length){
6523
+ setStatus('No valid paths to delete');
6524
+ return;
6525
+ }
6526
+ if(paths.length === 1){
5876
6527
  const fullPath = paths[0];
6528
+ const disp = (entries[0] && entries[0].name) ? entries[0].name : fullPath;
5877
6529
  if(deleteConfirmPath !== fullPath){
5878
6530
  deleteConfirmPath = fullPath;
5879
6531
  deleteConfirmBulkKey = '';
5880
6532
  deleteConfirmForceIntent = xferForce();
5881
6533
  deleteConfirmForceKillIntent = xferForceKill();
5882
- setStatus('Press Delete or ✕ Force Kill again to confirm permanent deletion of "' + entries[0].name + '"');
6534
+ setStatus('Press Delete or ✕ Force Kill again to confirm permanent deletion of "' + disp + '"');
5883
6535
  return;
5884
6536
  }
5885
6537
  deleteConfirmPath = '';
@@ -5901,7 +6553,7 @@ function deleteSel(){
5901
6553
  deleteConfirmBulkKey = bulkKey;
5902
6554
  deleteConfirmForceIntent = xferForce();
5903
6555
  deleteConfirmForceKillIntent = xferForceKill();
5904
- setStatus('Press Delete or ✕ Force Kill again to confirm permanent deletion of '+entries.length+' items');
6556
+ setStatus('Press Delete or ✕ Force Kill again to confirm permanent deletion of '+paths.length+' items');
5905
6557
  return;
5906
6558
  }
5907
6559
  deleteConfirmBulkKey = '';
@@ -5951,22 +6603,22 @@ document.addEventListener('click', ev => {
5951
6603
  treeSelectionParent = curPath;
5952
6604
  const i = parseInt(trD.dataset.detailIdx, 10);
5953
6605
  if(i < 0 || i >= lastEntries.length) return;
5954
- const name = lastEntries[i].name;
5955
- if(isShift && selectionAnchorIdx !== null && selectionAnchorIdx >= 0 && selectionAnchorIdx < lastEntries.length){
5956
- const a = Math.min(selectionAnchorIdx, i);
5957
- const b = Math.max(selectionAnchorIdx, i);
5958
- if(!isMeta) selectedEntryNames.clear();
5959
- for(let j = a; j <= b; j++) selectedEntryNames.add(lastEntries[j].name);
6606
+ const key = explorerSelectionStorageKey(lastEntries[i], curPath);
6607
+ if(isShift && selectionAnchor && selectionAnchor.kind === 'list' && Number.isFinite(selectionAnchor.idx) && selectionAnchor.idx >= 0 && selectionAnchor.idx < lastEntries.length){
6608
+ const a = Math.min(selectionAnchor.idx, i);
6609
+ const b = Math.max(selectionAnchor.idx, i);
6610
+ if(!isMeta) selectedExplorerKeys.clear();
6611
+ for(let j = a; j <= b; j++) selectedExplorerKeys.add(explorerSelectionStorageKey(lastEntries[j], curPath));
5960
6612
  } else if(isMeta){
5961
- if(selectedEntryNames.has(name)) selectedEntryNames.delete(name);
5962
- else selectedEntryNames.add(name);
5963
- selectionAnchorIdx = i;
6613
+ if(selectedExplorerKeys.has(key)) selectedExplorerKeys.delete(key);
6614
+ else selectedExplorerKeys.add(key);
6615
+ selectionAnchor = { kind: 'list', idx: i };
5964
6616
  } else {
5965
- selectedEntryNames.clear();
5966
- selectedEntryNames.add(name);
5967
- selectionAnchorIdx = i;
6617
+ selectedExplorerKeys.clear();
6618
+ selectedExplorerKeys.add(key);
6619
+ selectionAnchor = { kind: 'list', idx: i };
5968
6620
  }
5969
- lastSelectedName = name;
6621
+ lastSelectedExplorerKey = key;
5970
6622
  applyAllRowSelectionHighlights();
5971
6623
  maybeResetDeleteConfirm();
5972
6624
  return;
@@ -5988,9 +6640,9 @@ document.addEventListener('click', ev => {
5988
6640
  cancelForgeTreeFoldClickTimer();
5989
6641
  document.querySelectorAll('#detail-rows tr').forEach(function(x){ x.classList.remove('selected'); });
5990
6642
  if(!isMeta && !isShift){
5991
- selectedEntryNames.clear();
5992
- selectionAnchorIdx = null;
5993
- lastSelectedName = null;
6643
+ selectedExplorerKeys.clear();
6644
+ selectionAnchor = null;
6645
+ lastSelectedExplorerKey = null;
5994
6646
  }
5995
6647
  document.querySelectorAll('#rows tr').forEach(function(x){ x.classList.remove('selected'); });
5996
6648
  tr.classList.add('selected');
@@ -6030,22 +6682,22 @@ document.addEventListener('click', ev => {
6030
6682
  const pool = treeEntriesForParent(treeSelectionParent);
6031
6683
  const ti = pool.findIndex(function(ent){ return ent.name === tr.dataset.treeName; });
6032
6684
  if(ti < 0) return;
6033
- const name = pool[ti].name;
6034
- if(isShift && selectionAnchorIdx !== null && selectionAnchorIdx >= 0 && selectionAnchorIdx < pool.length){
6035
- const a = Math.min(selectionAnchorIdx, ti);
6036
- const b = Math.max(selectionAnchorIdx, ti);
6037
- if(!isMeta) selectedEntryNames.clear();
6038
- for(let j = a; j <= b; j++) selectedEntryNames.add(pool[j].name);
6685
+ const tkey = explorerSelectionStorageKey(pool[ti], treeSelectionParent);
6686
+ if(isShift && selectionAnchor && selectionAnchor.kind === 'tree' && explorerFsPathsEqual(selectionAnchor.parent, treeSelectionParent) && Number.isFinite(selectionAnchor.idx) && selectionAnchor.idx >= 0 && selectionAnchor.idx < pool.length){
6687
+ const a = Math.min(selectionAnchor.idx, ti);
6688
+ const b = Math.max(selectionAnchor.idx, ti);
6689
+ if(!isMeta) selectedExplorerKeys.clear();
6690
+ for(let j = a; j <= b; j++) selectedExplorerKeys.add(explorerSelectionStorageKey(pool[j], treeSelectionParent));
6039
6691
  } else if(isMeta){
6040
- if(selectedEntryNames.has(name)) selectedEntryNames.delete(name);
6041
- else selectedEntryNames.add(name);
6042
- selectionAnchorIdx = ti;
6692
+ if(selectedExplorerKeys.has(tkey)) selectedExplorerKeys.delete(tkey);
6693
+ else selectedExplorerKeys.add(tkey);
6694
+ selectionAnchor = { kind: 'tree', parent: String(treeSelectionParent || ''), idx: ti };
6043
6695
  } else {
6044
- selectedEntryNames.clear();
6045
- selectedEntryNames.add(name);
6046
- selectionAnchorIdx = ti;
6696
+ selectedExplorerKeys.clear();
6697
+ selectedExplorerKeys.add(tkey);
6698
+ selectionAnchor = { kind: 'tree', parent: String(treeSelectionParent || ''), idx: ti };
6047
6699
  }
6048
- lastSelectedName = name;
6700
+ lastSelectedExplorerKey = tkey;
6049
6701
  applyAllRowSelectionHighlights();
6050
6702
  maybeResetDeleteConfirm();
6051
6703
  return;
@@ -6056,28 +6708,28 @@ document.addEventListener('click', ev => {
6056
6708
  document.querySelectorAll('#detail-rows tr').forEach(function(x){ x.classList.remove('selected'); });
6057
6709
  const i = parseInt(tr.dataset.idx,10);
6058
6710
  if(i < 0 || i >= lastEntries.length) return;
6059
- const name = lastEntries[i].name;
6060
- if(isShift && selectionAnchorIdx !== null && selectionAnchorIdx >= 0 && selectionAnchorIdx < lastEntries.length){
6061
- const a = Math.min(selectionAnchorIdx, i);
6062
- const b = Math.max(selectionAnchorIdx, i);
6063
- if(!isMeta) selectedEntryNames.clear();
6064
- for(let j = a; j <= b; j++) selectedEntryNames.add(lastEntries[j].name);
6711
+ const ikey = explorerSelectionStorageKey(lastEntries[i], curPath);
6712
+ if(isShift && selectionAnchor && selectionAnchor.kind === 'list' && Number.isFinite(selectionAnchor.idx) && selectionAnchor.idx >= 0 && selectionAnchor.idx < lastEntries.length){
6713
+ const a = Math.min(selectionAnchor.idx, i);
6714
+ const b = Math.max(selectionAnchor.idx, i);
6715
+ if(!isMeta) selectedExplorerKeys.clear();
6716
+ for(let j = a; j <= b; j++) selectedExplorerKeys.add(explorerSelectionStorageKey(lastEntries[j], curPath));
6065
6717
  } else if(isMeta){
6066
- if(selectedEntryNames.has(name)) selectedEntryNames.delete(name);
6067
- else selectedEntryNames.add(name);
6068
- selectionAnchorIdx = i;
6718
+ if(selectedExplorerKeys.has(ikey)) selectedExplorerKeys.delete(ikey);
6719
+ else selectedExplorerKeys.add(ikey);
6720
+ selectionAnchor = { kind: 'list', idx: i };
6069
6721
  } else {
6070
- selectedEntryNames.clear();
6071
- selectedEntryNames.add(name);
6072
- selectionAnchorIdx = i;
6722
+ selectedExplorerKeys.clear();
6723
+ selectedExplorerKeys.add(ikey);
6724
+ selectionAnchor = { kind: 'list', idx: i };
6073
6725
  }
6074
- lastSelectedName = name;
6726
+ lastSelectedExplorerKey = ikey;
6075
6727
  document.querySelectorAll('#rows tr').forEach(function(row){
6076
6728
  if(row.classList.contains('root-row') || row.classList.contains('explorer-tree-computer')) return;
6077
6729
  if(row.classList.contains('tree-entry')) return;
6078
6730
  const idx = parseInt(row.dataset.idx, 10);
6079
6731
  if(idx >= 0 && idx < lastEntries.length){
6080
- row.classList.toggle('selected', selectedEntryNames.has(lastEntries[idx].name));
6732
+ row.classList.toggle('selected', selectedExplorerKeys.has(explorerSelectionStorageKey(lastEntries[idx], curPath)));
6081
6733
  }
6082
6734
  });
6083
6735
  applyDetailRowSelectionHighlights();
@@ -6134,6 +6786,7 @@ document.addEventListener('keydown', ev => {
6134
6786
  if(ev.target && ev.target.id==='path' && ev.key==='Enter'){ ev.preventDefault(); goPath(); return; }
6135
6787
  if(ev.target && ev.target.id==='search' && ev.key==='Enter'){
6136
6788
  ev.preventDefault();
6789
+ runSearch();
6137
6790
  return;
6138
6791
  }
6139
6792
  if(!authed || wantDownloadRid != null || wantFolderZipRid != null || wantDeleteRid != null || wantHfRid != null) return;
@@ -6168,6 +6821,11 @@ document.addEventListener('keydown', ev => {
6168
6821
 
6169
6822
  function doDisconnect(){
6170
6823
  cancelForgeUpgradeReconnectTimeouts();
6824
+ if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
6825
+ forgeRtcReconnectAttempts = 0;
6826
+ teardownForgeRtcExplorer();
6827
+ relayWebrtcSignaling = false;
6828
+ relayRtcIceServers = null;
6171
6829
  if(_explorerAutoConnectTimer){
6172
6830
  clearTimeout(_explorerAutoConnectTimer);
6173
6831
  _explorerAutoConnectTimer = null;
@@ -6182,10 +6840,145 @@ function doDisconnect(){
6182
6840
  * and shows status/errors at the top of the page — no dialog needed.
6183
6841
  */
6184
6842
  // ─── Browser → Agent file upload ─────────────────────────────────────────────
6185
- /** Upload progress tracker for browser→agent pushes */
6843
+ /** Upload progress tracker for browser→agent pushes (`kind`: one file or zip batch). */
6186
6844
  let uploadPushQueue = [];
6187
6845
  let uploadPushActive = false;
6188
6846
 
6847
+ function ensureJsZipForUpload(){
6848
+ if(typeof JSZip !== 'undefined') return Promise.resolve();
6849
+ return loadScriptOnce('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js')
6850
+ .catch(function(){ return loadScriptOnce('https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js'); })
6851
+ .then(function(){
6852
+ if(typeof JSZip === 'undefined') throw new Error('JSZip unavailable (CDN blocked or offline)');
6853
+ });
6854
+ }
6855
+ function feSanitizeZipEntryPath(rel){
6856
+ const s = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
6857
+ if(!s) return '';
6858
+ const parts = s.split('/');
6859
+ const out = [];
6860
+ for(let i = 0; i < parts.length; i++){
6861
+ const p = parts[i];
6862
+ if(!p || p === '.') continue;
6863
+ if(p === '..') return '';
6864
+ out.push(p);
6865
+ }
6866
+ return out.join('/');
6867
+ }
6868
+ async function buildBrowserUploadZipBlob(fileList){
6869
+ await ensureJsZipForUpload();
6870
+ const zip = new JSZip();
6871
+ const MAX = 20 * 1024 * 1024;
6872
+ let total = 0;
6873
+ const seen = Object.create(null);
6874
+ for(let i = 0; i < fileList.length; i++){
6875
+ const f = fileList[i];
6876
+ const raw = (f.webkitRelativePath && String(f.webkitRelativePath).trim())
6877
+ ? String(f.webkitRelativePath)
6878
+ : String(f.name || 'file');
6879
+ let ent = feSanitizeZipEntryPath(raw);
6880
+ if(!ent) continue;
6881
+ let key = ent.toLowerCase();
6882
+ if(seen[key]){
6883
+ ent = ent + '__dup' + i;
6884
+ key = ent.toLowerCase();
6885
+ }
6886
+ seen[key] = 1;
6887
+ const sz = typeof f.size === 'number' ? f.size : 0;
6888
+ if(sz > MAX) throw new Error('File too large (max 20 MB per member): ' + f.name);
6889
+ total += sz;
6890
+ if(total > MAX) throw new Error('Uncompressed selection exceeds 20 MB');
6891
+ zip.file(ent, await f.arrayBuffer());
6892
+ }
6893
+ const names = Object.keys(zip.files).filter(function(k){ return !zip.files[k].dir; });
6894
+ if(!names.length) throw new Error('No files to upload (empty folder or invalid paths)');
6895
+ const out = await zip.generateAsync({ type: 'uint8array', compression: 'STORE' });
6896
+ if(out.length > MAX) throw new Error('Zip exceeds 20 MB (relay limit)');
6897
+ return out;
6898
+ }
6899
+ function uploadBytesToB64Chunks(bytes){
6900
+ let b64 = '';
6901
+ const B64_CHUNK = 8192;
6902
+ for(let _i = 0; _i < bytes.length; _i += B64_CHUNK){
6903
+ b64 += btoa(String.fromCharCode.apply(null, bytes.subarray(_i, _i + B64_CHUNK)));
6904
+ }
6905
+ return b64;
6906
+ }
6907
+ async function sendRcFilePushBytes(displayName, bytes, timeoutMs){
6908
+ const targetPath = explorerUploadTargetDir();
6909
+ const rid = ridn();
6910
+ const wsRef = ws;
6911
+ if(!wsRef || wsRef.readyState !== 1) throw new Error('not connected');
6912
+ const b64 = uploadBytesToB64Chunks(bytes);
6913
+ const tmo = timeoutMs || 30000;
6914
+ await new Promise(function(resolve, reject){
6915
+ const cleanup = function(){
6916
+ wsRef.removeEventListener('message', handler);
6917
+ wsRef.removeEventListener('close', closeHandler);
6918
+ clearTimeout(timer);
6919
+ };
6920
+ const timer = setTimeout(function(){
6921
+ cleanup();
6922
+ reject(new Error('timeout waiting for rc_file_push_result'));
6923
+ }, tmo);
6924
+ const handler = function(ev2){
6925
+ let msg;
6926
+ try { msg = JSON.parse(ev2.data); } catch{ return; }
6927
+ if(!msg || msg.request_id !== rid) return;
6928
+ cleanup();
6929
+ if(msg.type === 'rc_file_push_result'){
6930
+ if(msg.ok) resolve(msg);
6931
+ else reject(new Error(String(msg.error || 'push failed')));
6932
+ } else if(msg.type === 'fs_error'){
6933
+ reject(new Error(String(msg.error || 'fs_error')));
6934
+ }
6935
+ };
6936
+ const closeHandler = function(){
6937
+ cleanup();
6938
+ reject(new Error('WebSocket disconnected during upload'));
6939
+ };
6940
+ wsRef.addEventListener('message', handler);
6941
+ wsRef.addEventListener('close', closeHandler);
6942
+ send({ type: 'rc_file_push', name: displayName, b64: b64, path: targetPath, request_id: rid });
6943
+ });
6944
+ setXferStatus('Uploaded "' + esc(displayName) + '" → ' + esc(targetPath));
6945
+ refresh();
6946
+ }
6947
+ function setUploadButtonsDisabled(dis){
6948
+ const a = $('btn-upload-local');
6949
+ const b = $('btn-upload-folder-local');
6950
+ if(a) a.disabled = !!dis;
6951
+ if(b) b.disabled = !!dis;
6952
+ }
6953
+ /**
6954
+ * Folder picker can return only one file; still zip when `webkitRelativePath` has a parent segment
6955
+ * so tree upload preserves relative paths like multi-file folder picks.
6956
+ */
6957
+ function feFileListWantsZipBatch(arr){
6958
+ if(!arr || arr.length === 0) return false;
6959
+ if(arr.length > 1) return true;
6960
+ const f = arr[0];
6961
+ const wrp = f && f.webkitRelativePath ? String(f.webkitRelativePath).trim() : '';
6962
+ return wrp.length > 0 && /[\\/]/.test(wrp.replace(/\\/g, '/'));
6963
+ }
6964
+ async function sendZipBatchPush(files){
6965
+ const n = files.length;
6966
+ const baseName = 'forge-upload-' + n + 'files-' + Date.now() + '.zip';
6967
+ setXferStatus('Zipping '+n+' local file(s)…');
6968
+ setUploadButtonsDisabled(true);
6969
+ try {
6970
+ const u8 = await buildBrowserUploadZipBlob(files);
6971
+ setXferStatus('Uploading zip ('+Math.round(u8.length/1024)+' KB)…');
6972
+ const tmo = Math.min(240000, 52000 + Math.floor(u8.length / 4096));
6973
+ await sendRcFilePushBytes(baseName, u8, tmo);
6974
+ } catch(e){
6975
+ setXferStatus('Upload zip failed: ' + esc(String(e && e.message ? e.message : e)));
6976
+ setCerr(String(e && e.message ? e.message : e));
6977
+ } finally {
6978
+ setUploadButtonsDisabled(false);
6979
+ }
6980
+ }
6981
+
6189
6982
  function openUploadFilePicker(){
6190
6983
  if(!authed || !ws || ws.readyState !== 1){
6191
6984
  setStatus('Not connected');
@@ -6194,88 +6987,59 @@ function openUploadFilePicker(){
6194
6987
  const el = $('upload-file-input');
6195
6988
  if(el){ el.value = ''; el.click(); }
6196
6989
  }
6990
+ function openUploadFolderPicker(){
6991
+ if(!authed || !ws || ws.readyState !== 1){
6992
+ setStatus('Not connected');
6993
+ return;
6994
+ }
6995
+ const el = $('upload-folder-input');
6996
+ if(el){ el.value = ''; el.click(); }
6997
+ }
6197
6998
 
6198
6999
  document.addEventListener('change', function(ev){
6199
- if(!ev.target || ev.target.id !== 'upload-file-input') return;
6200
- const files = ev.target.files;
7000
+ const t = ev.target;
7001
+ if(!t || (t.id !== 'upload-file-input' && t.id !== 'upload-folder-input')) return;
7002
+ const files = t.files;
6201
7003
  if(!files || !files.length) return;
6202
7004
  if(!authed || !ws || ws.readyState !== 1){
6203
7005
  setStatus('Not connected — cannot upload');
6204
7006
  return;
6205
7007
  }
6206
7008
  const arr = Array.from(files);
6207
- uploadPushQueue = uploadPushQueue.concat(arr);
7009
+ if(feFileListWantsZipBatch(arr)){
7010
+ uploadPushQueue.push({ kind: 'zip', files: arr });
7011
+ } else {
7012
+ uploadPushQueue.push({ kind: 'one', file: arr[0] });
7013
+ }
6208
7014
  if(!uploadPushActive) drainUploadPushQueue();
6209
- ev.target.value = '';
7015
+ t.value = '';
6210
7016
  });
6211
7017
 
6212
7018
  async function drainUploadPushQueue(){
6213
7019
  uploadPushActive = true;
6214
7020
  while(uploadPushQueue.length > 0){
6215
- const file = uploadPushQueue.shift();
6216
- await sendFilePushOne(file);
7021
+ const job = uploadPushQueue.shift();
7022
+ if(job && job.kind === 'zip') await sendZipBatchPush(job.files);
7023
+ else if(job && job.kind === 'one') await sendFilePushOne(job.file);
6217
7024
  }
6218
7025
  uploadPushActive = false;
6219
7026
  }
6220
7027
 
6221
7028
  async function sendFilePushOne(file){
6222
7029
  const MAX = 20 * 1024 * 1024;
6223
- if(file.size === 0 || file.size > MAX){
6224
- setStatus('Upload skipped "' + esc(file.name) + '": file must be 1B‥20MB');
7030
+ if(!file || file.size === 0 || file.size > MAX){
7031
+ setStatus('Upload skipped "' + esc(file && file.name ? file.name : '') + '": file must be 1B‥20MB');
6225
7032
  return;
6226
7033
  }
6227
7034
  setXferStatus('Uploading "' + esc(file.name) + '" to agent…');
6228
- const btn = $('btn-upload-local');
6229
- if(btn) btn.disabled = true;
7035
+ setUploadButtonsDisabled(true);
6230
7036
  try {
6231
7037
  const ab = await file.arrayBuffer();
6232
- const bytes = new Uint8Array(ab);
6233
- let b64 = '';
6234
- const B64_CHUNK = 8192;
6235
- for(let _i = 0; _i < bytes.length; _i += B64_CHUNK){
6236
- b64 += btoa(String.fromCharCode.apply(null, bytes.subarray(_i, _i + B64_CHUNK)));
6237
- }
6238
- const targetPath = curPath || '';
6239
- const rid = ridn();
6240
- const wsRef = ws;
6241
- if(!wsRef || wsRef.readyState !== 1) throw new Error('not connected');
6242
- await new Promise(function(resolve, reject){
6243
- const cleanup = function(){
6244
- wsRef.removeEventListener('message', handler);
6245
- wsRef.removeEventListener('close', closeHandler);
6246
- clearTimeout(timer);
6247
- };
6248
- const timer = setTimeout(function(){
6249
- cleanup();
6250
- reject(new Error('timeout waiting for rc_file_push_result'));
6251
- }, 30000);
6252
- const handler = function(ev2){
6253
- let msg;
6254
- try { msg = JSON.parse(ev2.data); } catch{ return; }
6255
- if(!msg || msg.request_id !== rid) return;
6256
- cleanup();
6257
- if(msg.type === 'rc_file_push_result'){
6258
- if(msg.ok) resolve(msg);
6259
- else reject(new Error(String(msg.error || 'push failed')));
6260
- } else if(msg.type === 'fs_error'){
6261
- reject(new Error(String(msg.error || 'fs_error')));
6262
- }
6263
- };
6264
- const closeHandler = function(){
6265
- cleanup();
6266
- reject(new Error('WebSocket disconnected during upload'));
6267
- };
6268
- wsRef.addEventListener('message', handler);
6269
- wsRef.addEventListener('close', closeHandler);
6270
- send({ type: 'rc_file_push', name: file.name, b64: b64, path: targetPath, request_id: rid });
6271
- });
6272
- setXferStatus('Uploaded "' + esc(file.name) + '" → ' + esc(targetPath));
6273
- refresh();
7038
+ await sendRcFilePushBytes(file.name, new Uint8Array(ab), 30000);
6274
7039
  } catch(e){
6275
7040
  setXferStatus('Upload failed "' + esc(file.name) + '": ' + esc(String(e && e.message ? e.message : e)));
6276
7041
  } finally {
6277
- const btn2 = $('btn-upload-local');
6278
- if(btn2) btn2.disabled = false;
7042
+ setUploadButtonsDisabled(false);
6279
7043
  }
6280
7044
  }
6281
7045