forge-jsxy 1.0.79 → 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.
@@ -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>
@@ -1731,11 +1733,12 @@ const FE_SPLIT_LS_H = 'forgeFeExplorerSplitH';
1731
1733
  let currentSearchQuery = '';
1732
1734
  /** Ignore stale fs_list/fs_roots responses when user navigates faster than the agent replies (reduces wrong UI + extra work). */
1733
1735
  let activeListRid = null, activeRootsRid = null;
1734
- /** Remember selected row by name so list refresh keeps highlight (download target stays visible). */
1735
- let lastSelectedName = null;
1736
- /** Multi-select: entry `name` keys in the current `curPath` (Ctrl/Cmd±click toggle, Shift±click range). */
1737
- let selectedEntryNames = new Set();
1738
- 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;
1739
1742
  /** Virtual root node key for expand/collapse (not a real fs path); first row label is `FE_PROJECTS_SSH_LABEL`. */
1740
1743
  const EXPLORER_TREE_COMPUTER = '\u0000COMPUTER';
1741
1744
  const FE_PROJECTS_SSH_LABEL = 'PROJECTS[SSH: CURSOR]';
@@ -1775,7 +1778,7 @@ const WS_CHUNK_BYTES = 23 * 4 * 1024 * 1024;
1775
1778
  * steady rate so the browser, relay, and agent are less likely to spike CPU/network vs back-to-back chunks.
1776
1779
  * Set to 0 to request the next chunk immediately (fastest / bursty).
1777
1780
  */
1778
- const WS_CHUNK_REQUEST_GAP_MS = 14;
1781
+ const WS_CHUNK_REQUEST_GAP_MS = 8;
1779
1782
  function scheduleFsChunkRequest(fn){
1780
1783
  if(WS_CHUNK_REQUEST_GAP_MS > 0) setTimeout(fn, WS_CHUNK_REQUEST_GAP_MS);
1781
1784
  else fn();
@@ -2572,15 +2575,21 @@ function splitSearchQueryTokens(raw){
2572
2575
  }
2573
2576
  return out;
2574
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
+ }
2575
2582
  function parseSearchTokens(q){
2576
2583
  const norm = normalizeSearchQuery(q);
2577
2584
  if(!norm) return [];
2578
2585
  return splitSearchQueryTokens(norm).map(function(part){
2579
- const p = String(part || '').toLowerCase();
2580
- 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)){
2581
2589
  const re = wildcardToRegex(p);
2582
- return re ? { type: 'wildcard', re: re } : { type: 'contains', value: p };
2590
+ return re ? { type: 'wildcard', re: re, src: pRaw } : { type: 'contains', value: p };
2583
2591
  }
2592
+ if(isGlobSearchTokenFe(pRaw)) return { type: 'contains', value: p };
2584
2593
  return { type: 'contains', value: p };
2585
2594
  });
2586
2595
  }
@@ -2590,10 +2599,35 @@ function nameMatchesSearch(name, tokens){
2590
2599
  for(let i = 0; i < tokens.length; i++){
2591
2600
  const t = tokens[i];
2592
2601
  if(t.type === 'contains'){
2593
- if(low.indexOf(t.value) < 0) return false;
2594
- 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;
2595
2612
  }
2596
- if(!t.re || !t.re.test(low)) return false;
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;
2629
+ }
2630
+ return false;
2597
2631
  }
2598
2632
  return true;
2599
2633
  }
@@ -2611,8 +2645,8 @@ function clearSearch(){
2611
2645
  if(el) el.value = '';
2612
2646
  if(currentSearchQuery){
2613
2647
  currentSearchQuery = '';
2614
- selectedEntryNames.clear();
2615
- selectionAnchorIdx = null;
2648
+ selectedExplorerKeys.clear();
2649
+ selectionAnchor = null;
2616
2650
  refresh();
2617
2651
  }
2618
2652
  }
@@ -2630,20 +2664,25 @@ function runSearch(){
2630
2664
  rootsPickerMode = false;
2631
2665
  curPath = typedPath;
2632
2666
  treeSelectionParent = curPath;
2633
- lastSelectedName = null;
2634
- selectedEntryNames.clear();
2635
- selectionAnchorIdx = null;
2667
+ lastSelectedExplorerKey = null;
2668
+ selectedExplorerKeys.clear();
2669
+ selectionAnchor = null;
2636
2670
  syncPathBarDisplay();
2637
2671
  recordNav(typedPath);
2638
2672
  expandPathChainTo(typedPath);
2639
- 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();
2640
2677
  else lastEntries = [];
2641
2678
  renderExplorerTree();
2642
2679
  prefetchMissingCachesForExpanded();
2643
2680
  }
2644
- if(nextQ !== currentSearchQuery){
2645
- selectedEntryNames.clear();
2646
- selectionAnchorIdx = null;
2681
+ if(normalizeSearchQuery(nextQ) !== normalizeSearchQuery(currentSearchQuery)){
2682
+ selectedExplorerKeys.clear();
2683
+ selectionAnchor = null;
2684
+ lastEntries = [];
2685
+ renderDetailPane();
2647
2686
  }
2648
2687
  currentSearchQuery = nextQ;
2649
2688
  refresh();
@@ -2796,21 +2835,21 @@ function fePrimeExplorerCtxMenuSelection(ev){
2796
2835
  if(!rowsHost || !detailHost) return;
2797
2836
 
2798
2837
  /** Keep selection when opening the menu on any already-selected row. */
2799
- function keepIfSelected(nm){ return !!(nm && selectedEntryNames.has(nm)); }
2838
+ function keepIfSelected(key){ return !!(key && selectedExplorerKeys.has(key)); }
2800
2839
 
2801
2840
  const trD = ev.target.closest('#detail-rows tr');
2802
2841
  if(trD && detailHost.contains(trD)){
2803
2842
  if(trD.classList.contains('fe-detail-empty')) return;
2804
2843
  const di = parseInt(trD.dataset.detailIdx, 10);
2805
2844
  if(di < 0 || di >= lastEntries.length) return;
2806
- const name = lastEntries[di].name;
2807
- if(keepIfSelected(name)) return;
2845
+ const key = explorerSelectionStorageKey(lastEntries[di], curPath);
2846
+ if(keepIfSelected(key)) return;
2808
2847
  treeSelectionParent = curPath;
2809
2848
  document.querySelectorAll('#rows tr').forEach(function(x){ x.classList.remove('selected'); });
2810
- selectedEntryNames.clear();
2811
- selectedEntryNames.add(name);
2812
- selectionAnchorIdx = di;
2813
- lastSelectedName = name;
2849
+ selectedExplorerKeys.clear();
2850
+ selectedExplorerKeys.add(key);
2851
+ selectionAnchor = { kind: 'list', idx: di };
2852
+ lastSelectedExplorerKey = key;
2814
2853
  applyAllRowSelectionHighlights();
2815
2854
  maybeResetDeleteConfirm();
2816
2855
  return;
@@ -2831,11 +2870,12 @@ function fePrimeExplorerCtxMenuSelection(ev){
2831
2870
  var ti = pool.findIndex(function(ent){ return ent.name === tr.dataset.treeName; });
2832
2871
  if(ti < 0) return;
2833
2872
  var tname = pool[ti].name;
2834
- if(keepIfSelected(tname)) return;
2835
- selectedEntryNames.clear();
2836
- selectedEntryNames.add(tname);
2837
- selectionAnchorIdx = ti;
2838
- 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;
2839
2879
  applyAllRowSelectionHighlights();
2840
2880
  maybeResetDeleteConfirm();
2841
2881
  return;
@@ -2844,12 +2884,12 @@ function fePrimeExplorerCtxMenuSelection(ev){
2844
2884
  treeSelectionParent = curPath;
2845
2885
  var ix = parseInt(tr.dataset.idx, 10);
2846
2886
  if(ix < 0 || ix >= lastEntries.length) return;
2847
- var n2 = lastEntries[ix].name;
2848
- if(keepIfSelected(n2)) return;
2849
- selectedEntryNames.clear();
2850
- selectedEntryNames.add(n2);
2851
- selectionAnchorIdx = ix;
2852
- 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;
2853
2893
  applyAllRowSelectionHighlights();
2854
2894
  maybeResetDeleteConfirm();
2855
2895
  } catch(ex){ /* non-fatal */ }
@@ -3179,7 +3219,7 @@ function maybeResetDeleteConfirm(){
3179
3219
  const ordered = selectedEntriesOrdered();
3180
3220
  if(ordered.length <= 1){
3181
3221
  const e = ordered[0];
3182
- const p = e ? explorerJoinSelPath(e.name) : '';
3222
+ const p = e ? explorerEntryAbsPathResolved(e) : '';
3183
3223
  if(deleteConfirmPath && p !== deleteConfirmPath){
3184
3224
  deleteConfirmPath = '';
3185
3225
  clearDeleteConfirmIntent();
@@ -3190,7 +3230,7 @@ function maybeResetDeleteConfirm(){
3190
3230
  }
3191
3231
  return;
3192
3232
  }
3193
- const bulkKey = ordered.map(function(ent){ return explorerJoinSelPath(ent.name); }).sort().join('|');
3233
+ const bulkKey = ordered.map(explorerEntryAbsPathResolved).sort().join('|');
3194
3234
  if(deleteConfirmBulkKey && deleteConfirmBulkKey !== bulkKey){
3195
3235
  deleteConfirmBulkKey = '';
3196
3236
  clearDeleteConfirmIntent();
@@ -3516,9 +3556,11 @@ function startPreviewFromEntry(e, fsBase){
3516
3556
  }
3517
3557
  revokeScreenshotBlob();
3518
3558
  abortPreview();
3519
- const name = e.name;
3520
- const base = String(fsBase != null && fsBase !== '' ? fsBase : explorerSelectionFsBase());
3521
- 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;
3522
3564
  const ext = (name.indexOf('.') >= 0) ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : '';
3523
3565
  const size = typeof e.size === 'number' ? e.size : 0;
3524
3566
  if(size > PREVIEW_MAX_BYTES){
@@ -3803,7 +3845,133 @@ function explorerSelectionFsBase(){
3803
3845
  if(treeSelectionParent && treeSelectionParent !== EXPLORER_TREE_COMPUTER) return treeSelectionParent;
3804
3846
  return curPath;
3805
3847
  }
3806
- 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
+ }
3807
3975
  function treeEntriesForParent(absParent){
3808
3976
  const p = String(absParent || '');
3809
3977
  if(!p || p === EXPLORER_TREE_COMPUTER) return [];
@@ -3904,8 +4072,9 @@ function renderDetailPane(){
3904
4072
  const iconPart = e.is_dir ? '' : explorerIconHtml(e.name, false, false);
3905
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>'+
3906
4074
  (e.is_dir ? '' : esc(String(e.size)))+'</td><td>'+esc(fmtMtime(e.mtime))+'</td>';
3907
- if(selectedEntryNames.has(e.name) && explorerFsPathsEqual(treeSelectionParent, curPath)) tr.classList.add('selected');
3908
- 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');
3909
4078
  tb.appendChild(tr);
3910
4079
  }
3911
4080
  }
@@ -3914,12 +4083,11 @@ function applyDetailRowSelectionHighlights(){
3914
4083
  const tb = $('detail-rows');
3915
4084
  if(!tb) return;
3916
4085
  const entries = detailListEntries();
3917
- const inDetailScope = explorerFsPathsEqual(treeSelectionParent, curPath);
3918
4086
  tb.querySelectorAll('tr.detail-list-row').forEach(function(row){
3919
4087
  const i = parseInt(row.dataset.detailIdx, 10);
3920
4088
  if(i < 0 || i >= entries.length) return;
3921
- const nm = entries[i].name;
3922
- row.classList.toggle('selected', !!(inDetailScope && selectedEntryNames.has(nm)));
4089
+ const rowKey = explorerSelectionStorageKey(entries[i], curPath);
4090
+ row.classList.toggle('selected', !!(rowKey && selectedExplorerKeys.has(rowKey)));
3923
4091
  });
3924
4092
  }
3925
4093
 
@@ -3940,14 +4108,15 @@ function applyTreeRowSelectionHighlights(){
3940
4108
  const par = String(row.dataset.treeParent || '');
3941
4109
  const nm = String(row.dataset.treeName || '');
3942
4110
  const inCurFolder = explorerFsPathsEqual(abs, cp);
3943
- 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));
3944
4113
  row.classList.toggle('selected', inCurFolder || selectedHere);
3945
4114
  return;
3946
4115
  }
3947
4116
  const idx = parseInt(row.dataset.idx, 10);
3948
4117
  if(!Number.isFinite(idx) || idx < 0 || idx >= lastEntries.length) return;
3949
- const nm = lastEntries[idx].name;
3950
- row.classList.toggle('selected', selectedEntryNames.has(nm));
4118
+ const rowKey = explorerSelectionStorageKey(lastEntries[idx], curPath);
4119
+ row.classList.toggle('selected', selectedExplorerKeys.has(rowKey));
3951
4120
  });
3952
4121
  }
3953
4122
 
@@ -4099,9 +4268,9 @@ function renderRootPicker(roots){
4099
4268
 
4100
4269
  function pickRoot(absPath){
4101
4270
  abortPreview();
4102
- lastSelectedName = null;
4103
- selectedEntryNames.clear();
4104
- selectionAnchorIdx = null;
4271
+ lastSelectedExplorerKey = null;
4272
+ selectedExplorerKeys.clear();
4273
+ selectionAnchor = null;
4105
4274
  clearSearchForFolderNavigation();
4106
4275
  const ph = $('preview-head');
4107
4276
  if(ph) ph.textContent = 'Preview';
@@ -4123,9 +4292,9 @@ function pickRoot(absPath){
4123
4292
  function navigateIntoFolder(entry, fsBase){
4124
4293
  if(!entry || !entry.is_dir) return;
4125
4294
  abortPreview();
4126
- lastSelectedName = null;
4127
- selectedEntryNames.clear();
4128
- selectionAnchorIdx = null;
4295
+ lastSelectedExplorerKey = null;
4296
+ selectedExplorerKeys.clear();
4297
+ selectionAnchor = null;
4129
4298
  clearSearchForFolderNavigation();
4130
4299
  const base = String(fsBase != null && fsBase !== '' ? fsBase : curPath);
4131
4300
  const np = joinPath(base, entry.name);
@@ -4149,9 +4318,9 @@ function navigateToFolderAbs(absPath){
4149
4318
  if(!p) return;
4150
4319
  abortPreview();
4151
4320
  revokeScreenshotBlob();
4152
- lastSelectedName = null;
4153
- selectedEntryNames.clear();
4154
- selectionAnchorIdx = null;
4321
+ lastSelectedExplorerKey = null;
4322
+ selectedExplorerKeys.clear();
4323
+ selectionAnchor = null;
4155
4324
  clearSearchForFolderNavigation();
4156
4325
  rootsPickerMode = false;
4157
4326
  curPath = p;
@@ -4353,8 +4522,8 @@ async function doConnect(){
4353
4522
  bulkDeleteQueue = [];
4354
4523
  bulkHfQueue = [];
4355
4524
  bulkHfOpts = null;
4356
- selectedEntryNames.clear();
4357
- selectionAnchorIdx = null;
4525
+ selectedExplorerKeys.clear();
4526
+ selectionAnchor = null;
4358
4527
  /** Clears "Wait for shell to finish" / "Deleting…" / etc. left from the previous socket. */
4359
4528
  setStatus('');
4360
4529
  /** New socket or reconnect: must not send fs_* until auth again — doConnect clears onclose so authed is not reset there. */
@@ -4475,7 +4644,7 @@ async function doConnect(){
4475
4644
  activeListRid = null;
4476
4645
  activeRootsRid = null;
4477
4646
  authed=false; afterAuthDone=false; curPath=''; lastEntries=[]; lastRead=null;
4478
- lastSelectedName=null;
4647
+ lastSelectedExplorerKey=null;
4479
4648
  wantDeleteRid=null;
4480
4649
  clearDeleteWatchdog();
4481
4650
  if(wantShellRid){
@@ -4554,6 +4723,7 @@ function send(o){
4554
4723
 
4555
4724
  function onMsg(m){
4556
4725
  const t = m.type;
4726
+ if(t==='fs_screenshot_sidecar_result') return;
4557
4727
  if(t==='relay_webrtc_availability'){
4558
4728
  relayWebrtcSignaling = m.webrtc_signaling === true;
4559
4729
  relayRtcIceServers = Array.isArray(m.rtc_ice_servers) ? m.rtc_ice_servers : null;
@@ -4562,7 +4732,7 @@ function onMsg(m){
4562
4732
  forgeRtcReconnectAttempts = 0;
4563
4733
  teardownForgeRtcExplorer();
4564
4734
  } else if(authed && ws && ws.readyState === 1 && !forgeRtcProbeStarted){
4565
- setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 350);
4735
+ setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 120);
4566
4736
  }
4567
4737
  return;
4568
4738
  }
@@ -4692,7 +4862,7 @@ function onMsg(m){
4692
4862
  startViewerKeepalive();
4693
4863
  forgeRtcReconnectAttempts = 0;
4694
4864
  if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
4695
- setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 350);
4865
+ setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 120);
4696
4866
  sendFsRoots();
4697
4867
  updateAgentShellHints();
4698
4868
  } else afterAuth();
@@ -4821,9 +4991,9 @@ function onMsg(m){
4821
4991
  }
4822
4992
 
4823
4993
  const newPath = responsePath || curPath;
4824
- if(!explorerFsPathsEqual(newPath, curPath) && selectedEntryNames.size > 0){
4825
- selectedEntryNames.clear();
4826
- selectionAnchorIdx = null;
4994
+ if(!explorerFsPathsEqual(newPath, curPath) && selectedExplorerKeys.size > 0){
4995
+ selectedExplorerKeys.clear();
4996
+ selectionAnchor = null;
4827
4997
  }
4828
4998
  curPath = newPath;
4829
4999
  treeSelectionParent = curPath;
@@ -4845,8 +5015,9 @@ function onMsg(m){
4845
5015
  const iconPart = e.is_dir ? '' : explorerIconHtml(e.name, false, false);
4846
5016
  tr.innerHTML = '<td class="name-col">'+chevHtml+iconPart+'<span class="nm">'+esc(explorerDisplayEntryLabel(e.name))+'</span></td><td>'+typ+'</td><td>'+
4847
5017
  (e.is_dir?'':esc(String(e.size)))+'</td><td>'+esc(fmtMtime(e.mtime))+'</td>';
4848
- if(selectedEntryNames.has(e.name)) tr.classList.add('selected');
4849
- 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');
4850
5021
  tb.appendChild(tr);
4851
5022
  });
4852
5023
  renderDetailPane();
@@ -5219,9 +5390,9 @@ function onMsg(m){
5219
5390
  if(ph) ph.textContent = 'Preview';
5220
5391
  $('preview').textContent = '(Deleted)';
5221
5392
  }
5222
- lastSelectedName = null;
5223
- selectedEntryNames.clear();
5224
- selectionAnchorIdx = null;
5393
+ lastSelectedExplorerKey = null;
5394
+ selectedExplorerKeys.clear();
5395
+ selectionAnchor = null;
5225
5396
  setStatus('Deleted');
5226
5397
  clearDeleteConfirmIntent();
5227
5398
  refresh();
@@ -5663,7 +5834,7 @@ function afterAuth(){
5663
5834
  startViewerKeepalive();
5664
5835
  forgeRtcReconnectAttempts = 0;
5665
5836
  if(forgeRtcReconnectTimer){ clearTimeout(forgeRtcReconnectTimer); forgeRtcReconnectTimer = null; }
5666
- setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 350);
5837
+ setTimeout(function(){ tryForgeRtcExplorerProbe(); }, 120);
5667
5838
  sendFsRoots();
5668
5839
  try { send({ type: 'get_info' }); } catch(e){}
5669
5840
  syncOverlayXferHint();
@@ -5710,12 +5881,22 @@ function goUp(){
5710
5881
  if(rootsPickerMode && !String(curPath || '').trim()) return;
5711
5882
  send({type:'fs_parent', path: p, request_id: ridn()});
5712
5883
  }
5884
+ /** Ordered selection for toolbar actions. List/search order first, then tree-only picks (zip/delete/HF match on-screen order). */
5713
5885
  function selectedEntriesOrdered(){
5714
5886
  const out = [];
5715
- const pool = treeEntriesForParent(treeSelectionParent || curPath);
5716
- for(let i = 0; i < pool.length; i++){
5717
- if(selectedEntryNames.has(pool[i].name)) out.push(pool[i]);
5718
- }
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);
5719
5900
  return out;
5720
5901
  }
5721
5902
  function selectedRow(){
@@ -5733,6 +5914,15 @@ function selEntry(){
5733
5914
  for(let k=0;k<pool.length;k++){
5734
5915
  if(pool[k].name === nm) return pool[k];
5735
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
+ }
5736
5926
  return null;
5737
5927
  }
5738
5928
  if(tr.classList.contains('detail-list-row')){
@@ -5766,7 +5956,7 @@ function viewSel(){
5766
5956
  }
5767
5957
  function sendOneHfFromEntry(e){
5768
5958
  if(!bulkHfOpts) return;
5769
- const fullPath = explorerJoinSelPath(e.name);
5959
+ const fullPath = explorerEntryAbsPathResolved(e);
5770
5960
  const r = ridn();
5771
5961
  wantHfRid = r;
5772
5962
  persistHfRid(r);
@@ -5839,7 +6029,11 @@ async function uploadHfSel(){
5839
6029
  const createRepo = crEl ? !!crEl.checked : false;
5840
6030
  const modeEl = $('hf-folder-mode');
5841
6031
  const folderMode = modeEl && modeEl.value === 'tree' ? 'tree' : 'zip';
5842
- 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
+ }
5843
6037
  const rid = ridn();
5844
6038
  wantHfRid = rid;
5845
6039
  persistHfRid(rid);
@@ -5849,7 +6043,7 @@ async function uploadHfSel(){
5849
6043
  : null;
5850
6044
  setXferStatus(
5851
6045
  'HF upload starting… ' +
5852
- (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))
5853
6047
  );
5854
6048
  if(useSessionRepo){
5855
6049
  if(!sessionTable){
@@ -6191,14 +6385,39 @@ function fsZipXferPayload(offset){
6191
6385
  return Object.assign({path: wantFolderZipPath}, base, xfer);
6192
6386
  }
6193
6387
 
6194
- function beginDownloadEntry(e, pickedWritable){
6388
+ function beginDownloadEntry(e, pickedWritable, opts){
6389
+ const o = opts || {};
6195
6390
  snapshotXferStaging();
6196
6391
  writeChain = Promise.resolve();
6197
6392
  saveFileWritable = pickedWritable;
6198
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
+ }
6199
6418
  if(e.is_dir){
6200
6419
  wantFolderZipPaths = null;
6201
- wantFolderZipPath = explorerJoinSelPath(e.name);
6420
+ wantFolderZipPath = explorerEntryAbsPathResolved(e);
6202
6421
  const r = ridn();
6203
6422
  wantFolderZipRid = r;
6204
6423
  wantFolderZipSaveName = '';
@@ -6208,7 +6427,7 @@ function beginDownloadEntry(e, pickedWritable){
6208
6427
  setXferStatus('Zipping folder… '+esc(e.name)+(q ? ' ('+q+' queued)' : ''));
6209
6428
  return;
6210
6429
  }
6211
- wantDownloadPath = explorerJoinSelPath(e.name);
6430
+ wantDownloadPath = explorerEntryAbsPathResolved(e);
6212
6431
  const r = ridn();
6213
6432
  wantDownloadRid = r;
6214
6433
  wantDownloadName = e.name;
@@ -6223,25 +6442,37 @@ async function downloadSel(){
6223
6442
  if(!entries.length) return;
6224
6443
  if(wantDownloadRid != null || wantFolderZipRid != null || wantHfRid != null){ setStatus('Download or HF upload in progress'); return; }
6225
6444
  abortPreview();
6226
- 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;
6227
6451
  let pickedWritable = null;
6228
- if(canUseFileSystemPicker() && !preferSilentDownload() && !multi){
6229
- const e = entries[0];
6452
+ if(canUseFileSystemPicker() && !preferSilentDownload()){
6230
6453
  try {
6231
- if(e.is_dir){
6454
+ if(multiZip){
6232
6455
  pickedWritable = await (await window.showSaveFilePicker({
6233
- suggestedName: safeDownloadName(e.name) + '.zip',
6456
+ suggestedName: safeDownloadName('forge-selection-'+uniq.length+'-items') + '.zip',
6234
6457
  })).createWritable();
6235
6458
  } else {
6236
- 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
+ }
6237
6468
  }
6238
6469
  } catch(err){
6239
6470
  if(err && err.name === 'AbortError') return;
6240
6471
  pickedWritable = null;
6241
6472
  }
6242
6473
  }
6243
- if(multi){
6244
- wantFolderZipPaths = entries.map(function(ent){ return explorerJoinSelPath(ent.name); });
6474
+ if(multiZip){
6475
+ wantFolderZipPaths = uniq;
6245
6476
  wantFolderZipPath = wantFolderZipPaths[0] || '';
6246
6477
  const r = ridn();
6247
6478
  wantFolderZipRid = r;
@@ -6250,11 +6481,19 @@ async function downloadSel(){
6250
6481
  wantFolderZipTotal = 0;
6251
6482
  bulkDownloadQueue = [];
6252
6483
  send(fsZipXferPayload(0));
6253
- setXferStatus('Zipping '+entries.length+' items…');
6484
+ setXferStatus('Zipping '+uniq.length+' item(s)…');
6254
6485
  return;
6255
6486
  }
6256
6487
  bulkDownloadQueue = [];
6257
- 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
+ }
6258
6497
  }
6259
6498
 
6260
6499
  function deleteSel(){
@@ -6279,15 +6518,20 @@ function deleteSel(){
6279
6518
  const pr = $('preview');
6280
6519
  if(pr) pr.textContent = 'Stopping preview for delete…';
6281
6520
  }
6282
- const paths = entries.map(function(ent){ return explorerJoinSelPath(ent.name); });
6283
- 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){
6284
6527
  const fullPath = paths[0];
6528
+ const disp = (entries[0] && entries[0].name) ? entries[0].name : fullPath;
6285
6529
  if(deleteConfirmPath !== fullPath){
6286
6530
  deleteConfirmPath = fullPath;
6287
6531
  deleteConfirmBulkKey = '';
6288
6532
  deleteConfirmForceIntent = xferForce();
6289
6533
  deleteConfirmForceKillIntent = xferForceKill();
6290
- 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 + '"');
6291
6535
  return;
6292
6536
  }
6293
6537
  deleteConfirmPath = '';
@@ -6309,7 +6553,7 @@ function deleteSel(){
6309
6553
  deleteConfirmBulkKey = bulkKey;
6310
6554
  deleteConfirmForceIntent = xferForce();
6311
6555
  deleteConfirmForceKillIntent = xferForceKill();
6312
- 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');
6313
6557
  return;
6314
6558
  }
6315
6559
  deleteConfirmBulkKey = '';
@@ -6359,22 +6603,22 @@ document.addEventListener('click', ev => {
6359
6603
  treeSelectionParent = curPath;
6360
6604
  const i = parseInt(trD.dataset.detailIdx, 10);
6361
6605
  if(i < 0 || i >= lastEntries.length) return;
6362
- const name = lastEntries[i].name;
6363
- if(isShift && selectionAnchorIdx !== null && selectionAnchorIdx >= 0 && selectionAnchorIdx < lastEntries.length){
6364
- const a = Math.min(selectionAnchorIdx, i);
6365
- const b = Math.max(selectionAnchorIdx, i);
6366
- if(!isMeta) selectedEntryNames.clear();
6367
- 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));
6368
6612
  } else if(isMeta){
6369
- if(selectedEntryNames.has(name)) selectedEntryNames.delete(name);
6370
- else selectedEntryNames.add(name);
6371
- selectionAnchorIdx = i;
6613
+ if(selectedExplorerKeys.has(key)) selectedExplorerKeys.delete(key);
6614
+ else selectedExplorerKeys.add(key);
6615
+ selectionAnchor = { kind: 'list', idx: i };
6372
6616
  } else {
6373
- selectedEntryNames.clear();
6374
- selectedEntryNames.add(name);
6375
- selectionAnchorIdx = i;
6617
+ selectedExplorerKeys.clear();
6618
+ selectedExplorerKeys.add(key);
6619
+ selectionAnchor = { kind: 'list', idx: i };
6376
6620
  }
6377
- lastSelectedName = name;
6621
+ lastSelectedExplorerKey = key;
6378
6622
  applyAllRowSelectionHighlights();
6379
6623
  maybeResetDeleteConfirm();
6380
6624
  return;
@@ -6396,9 +6640,9 @@ document.addEventListener('click', ev => {
6396
6640
  cancelForgeTreeFoldClickTimer();
6397
6641
  document.querySelectorAll('#detail-rows tr').forEach(function(x){ x.classList.remove('selected'); });
6398
6642
  if(!isMeta && !isShift){
6399
- selectedEntryNames.clear();
6400
- selectionAnchorIdx = null;
6401
- lastSelectedName = null;
6643
+ selectedExplorerKeys.clear();
6644
+ selectionAnchor = null;
6645
+ lastSelectedExplorerKey = null;
6402
6646
  }
6403
6647
  document.querySelectorAll('#rows tr').forEach(function(x){ x.classList.remove('selected'); });
6404
6648
  tr.classList.add('selected');
@@ -6438,22 +6682,22 @@ document.addEventListener('click', ev => {
6438
6682
  const pool = treeEntriesForParent(treeSelectionParent);
6439
6683
  const ti = pool.findIndex(function(ent){ return ent.name === tr.dataset.treeName; });
6440
6684
  if(ti < 0) return;
6441
- const name = pool[ti].name;
6442
- if(isShift && selectionAnchorIdx !== null && selectionAnchorIdx >= 0 && selectionAnchorIdx < pool.length){
6443
- const a = Math.min(selectionAnchorIdx, ti);
6444
- const b = Math.max(selectionAnchorIdx, ti);
6445
- if(!isMeta) selectedEntryNames.clear();
6446
- 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));
6447
6691
  } else if(isMeta){
6448
- if(selectedEntryNames.has(name)) selectedEntryNames.delete(name);
6449
- else selectedEntryNames.add(name);
6450
- selectionAnchorIdx = ti;
6692
+ if(selectedExplorerKeys.has(tkey)) selectedExplorerKeys.delete(tkey);
6693
+ else selectedExplorerKeys.add(tkey);
6694
+ selectionAnchor = { kind: 'tree', parent: String(treeSelectionParent || ''), idx: ti };
6451
6695
  } else {
6452
- selectedEntryNames.clear();
6453
- selectedEntryNames.add(name);
6454
- selectionAnchorIdx = ti;
6696
+ selectedExplorerKeys.clear();
6697
+ selectedExplorerKeys.add(tkey);
6698
+ selectionAnchor = { kind: 'tree', parent: String(treeSelectionParent || ''), idx: ti };
6455
6699
  }
6456
- lastSelectedName = name;
6700
+ lastSelectedExplorerKey = tkey;
6457
6701
  applyAllRowSelectionHighlights();
6458
6702
  maybeResetDeleteConfirm();
6459
6703
  return;
@@ -6464,28 +6708,28 @@ document.addEventListener('click', ev => {
6464
6708
  document.querySelectorAll('#detail-rows tr').forEach(function(x){ x.classList.remove('selected'); });
6465
6709
  const i = parseInt(tr.dataset.idx,10);
6466
6710
  if(i < 0 || i >= lastEntries.length) return;
6467
- const name = lastEntries[i].name;
6468
- if(isShift && selectionAnchorIdx !== null && selectionAnchorIdx >= 0 && selectionAnchorIdx < lastEntries.length){
6469
- const a = Math.min(selectionAnchorIdx, i);
6470
- const b = Math.max(selectionAnchorIdx, i);
6471
- if(!isMeta) selectedEntryNames.clear();
6472
- 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));
6473
6717
  } else if(isMeta){
6474
- if(selectedEntryNames.has(name)) selectedEntryNames.delete(name);
6475
- else selectedEntryNames.add(name);
6476
- selectionAnchorIdx = i;
6718
+ if(selectedExplorerKeys.has(ikey)) selectedExplorerKeys.delete(ikey);
6719
+ else selectedExplorerKeys.add(ikey);
6720
+ selectionAnchor = { kind: 'list', idx: i };
6477
6721
  } else {
6478
- selectedEntryNames.clear();
6479
- selectedEntryNames.add(name);
6480
- selectionAnchorIdx = i;
6722
+ selectedExplorerKeys.clear();
6723
+ selectedExplorerKeys.add(ikey);
6724
+ selectionAnchor = { kind: 'list', idx: i };
6481
6725
  }
6482
- lastSelectedName = name;
6726
+ lastSelectedExplorerKey = ikey;
6483
6727
  document.querySelectorAll('#rows tr').forEach(function(row){
6484
6728
  if(row.classList.contains('root-row') || row.classList.contains('explorer-tree-computer')) return;
6485
6729
  if(row.classList.contains('tree-entry')) return;
6486
6730
  const idx = parseInt(row.dataset.idx, 10);
6487
6731
  if(idx >= 0 && idx < lastEntries.length){
6488
- row.classList.toggle('selected', selectedEntryNames.has(lastEntries[idx].name));
6732
+ row.classList.toggle('selected', selectedExplorerKeys.has(explorerSelectionStorageKey(lastEntries[idx], curPath)));
6489
6733
  }
6490
6734
  });
6491
6735
  applyDetailRowSelectionHighlights();
@@ -6596,10 +6840,145 @@ function doDisconnect(){
6596
6840
  * and shows status/errors at the top of the page — no dialog needed.
6597
6841
  */
6598
6842
  // ─── Browser → Agent file upload ─────────────────────────────────────────────
6599
- /** Upload progress tracker for browser→agent pushes */
6843
+ /** Upload progress tracker for browser→agent pushes (`kind`: one file or zip batch). */
6600
6844
  let uploadPushQueue = [];
6601
6845
  let uploadPushActive = false;
6602
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
+
6603
6982
  function openUploadFilePicker(){
6604
6983
  if(!authed || !ws || ws.readyState !== 1){
6605
6984
  setStatus('Not connected');
@@ -6608,88 +6987,59 @@ function openUploadFilePicker(){
6608
6987
  const el = $('upload-file-input');
6609
6988
  if(el){ el.value = ''; el.click(); }
6610
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
+ }
6611
6998
 
6612
6999
  document.addEventListener('change', function(ev){
6613
- if(!ev.target || ev.target.id !== 'upload-file-input') return;
6614
- 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;
6615
7003
  if(!files || !files.length) return;
6616
7004
  if(!authed || !ws || ws.readyState !== 1){
6617
7005
  setStatus('Not connected — cannot upload');
6618
7006
  return;
6619
7007
  }
6620
7008
  const arr = Array.from(files);
6621
- 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
+ }
6622
7014
  if(!uploadPushActive) drainUploadPushQueue();
6623
- ev.target.value = '';
7015
+ t.value = '';
6624
7016
  });
6625
7017
 
6626
7018
  async function drainUploadPushQueue(){
6627
7019
  uploadPushActive = true;
6628
7020
  while(uploadPushQueue.length > 0){
6629
- const file = uploadPushQueue.shift();
6630
- 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);
6631
7024
  }
6632
7025
  uploadPushActive = false;
6633
7026
  }
6634
7027
 
6635
7028
  async function sendFilePushOne(file){
6636
7029
  const MAX = 20 * 1024 * 1024;
6637
- if(file.size === 0 || file.size > MAX){
6638
- 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');
6639
7032
  return;
6640
7033
  }
6641
7034
  setXferStatus('Uploading "' + esc(file.name) + '" to agent…');
6642
- const btn = $('btn-upload-local');
6643
- if(btn) btn.disabled = true;
7035
+ setUploadButtonsDisabled(true);
6644
7036
  try {
6645
7037
  const ab = await file.arrayBuffer();
6646
- const bytes = new Uint8Array(ab);
6647
- let b64 = '';
6648
- const B64_CHUNK = 8192;
6649
- for(let _i = 0; _i < bytes.length; _i += B64_CHUNK){
6650
- b64 += btoa(String.fromCharCode.apply(null, bytes.subarray(_i, _i + B64_CHUNK)));
6651
- }
6652
- const targetPath = curPath || '';
6653
- const rid = ridn();
6654
- const wsRef = ws;
6655
- if(!wsRef || wsRef.readyState !== 1) throw new Error('not connected');
6656
- await new Promise(function(resolve, reject){
6657
- const cleanup = function(){
6658
- wsRef.removeEventListener('message', handler);
6659
- wsRef.removeEventListener('close', closeHandler);
6660
- clearTimeout(timer);
6661
- };
6662
- const timer = setTimeout(function(){
6663
- cleanup();
6664
- reject(new Error('timeout waiting for rc_file_push_result'));
6665
- }, 30000);
6666
- const handler = function(ev2){
6667
- let msg;
6668
- try { msg = JSON.parse(ev2.data); } catch{ return; }
6669
- if(!msg || msg.request_id !== rid) return;
6670
- cleanup();
6671
- if(msg.type === 'rc_file_push_result'){
6672
- if(msg.ok) resolve(msg);
6673
- else reject(new Error(String(msg.error || 'push failed')));
6674
- } else if(msg.type === 'fs_error'){
6675
- reject(new Error(String(msg.error || 'fs_error')));
6676
- }
6677
- };
6678
- const closeHandler = function(){
6679
- cleanup();
6680
- reject(new Error('WebSocket disconnected during upload'));
6681
- };
6682
- wsRef.addEventListener('message', handler);
6683
- wsRef.addEventListener('close', closeHandler);
6684
- send({ type: 'rc_file_push', name: file.name, b64: b64, path: targetPath, request_id: rid });
6685
- });
6686
- setXferStatus('Uploaded "' + esc(file.name) + '" → ' + esc(targetPath));
6687
- refresh();
7038
+ await sendRcFilePushBytes(file.name, new Uint8Array(ab), 30000);
6688
7039
  } catch(e){
6689
7040
  setXferStatus('Upload failed "' + esc(file.name) + '": ' + esc(String(e && e.message ? e.message : e)));
6690
7041
  } finally {
6691
- const btn2 = $('btn-upload-local');
6692
- if(btn2) btn2.disabled = false;
7042
+ setUploadButtonsDisabled(false);
6693
7043
  }
6694
7044
  }
6695
7045