dsh-linked-workspace 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.3 — 2026-11-11
4
+
5
+ Second review round: follow-ups to the 0.8.2 paging and preview work.
6
+
7
+ - **Preview overlay (medium)**: the sanitizer now strips inline `style` attributes as well. A document containing `<div style="position:fixed;inset:0;z-index:99999;background:#fff">` used to cover the whole DSH window and could impersonate a login page (click-jacking / phishing without any script).
8
+ - **Paging survives refresh (medium)**: the local tree refreshes every 4 seconds; it now re-requests only the number of entries already loaded instead of resetting to the first page, so “Load more” results no longer disappear after up to 4 s.
9
+ - **Paging offset with hidden files (medium)**: “Load more” uses the raw server offset, so with hidden files toggled off entries are neither duplicated (200 duplicates before) nor skipped; the remaining count follows the same offset.
10
+ - **Directory listing cache (medium)**: `fs.list` caches a listing for 5 seconds, so paging a huge directory reads it once instead of re-running a full `listDir` (20,000 entries: ~10.4 s per call). Writes, mkdir, rename, move, delete, copy and transfers invalidate the cache, and `limit` is clamped server-side to 1..5000.
11
+ - **Partial copy cleanup (low)**: if a directory copy fails midway (for example on a >64 MB file), the partial target is removed and the error is `FS_INCOMPLETE`; `FS_TOO_LARGE` now only means an actual size cap.
12
+ - **Connection reuse (low)**: `HostPool` keys in-flight connects by host *and* authority, so an address change cannot attach an old connection to a new request; a connection replaced while connecting is disposed instead of leaked, and `setHosts` clears stale pending entries.
13
+ - **Right-sidebar requests (low)**: the linked-workspace tree source de-duplicates in-flight loads instead of sending the same request twice.
14
+ - **Large pickers and PDF export (low)**: the local/remote directory pickers render at most 500 entries plus a “type the path directly” note; markdown-to-PDF export stops after 999 name suffixes with an explicit error instead of looping forever.
15
+ - **Tests**: fixed two self-inconsistent assertions in `test/host-api.mjs` (a folder left behind made a later create return 409, and an already-existing folder was expected to answer 400); the suite now passes all 28 steps.
16
+
3
17
  ## 0.8.2 — 2026-11-11
4
18
 
5
19
  Three-day review round: security, data-integrity and large-directory fixes.
package/README.en.md CHANGED
@@ -119,6 +119,20 @@ Old “Remote directories” tabs are restored after refresh through hidden lega
119
119
 
120
120
  See [CHANGELOG.md](CHANGELOG.md) for the full version history.
121
121
 
122
+ ### 0.8.3
123
+
124
+ Follow-up fixes to the 0.8.2 paging and preview work (second review round):
125
+
126
+ - Document previews now also drop inline `style` attributes, so a `position:fixed` overlay can no longer cover the whole app and impersonate a login page (click-jacking/phishing surface).
127
+ - Tree refreshes (the local tree every 4 s) re-request only the entries already loaded, so “Load more” results are no longer reset to the first page.
128
+ - With hidden files toggled, “Load more” uses the true server offset: entries are neither duplicated nor skipped, and the “N left” label is computed from that offset.
129
+ - `fs.list` gained a 5-second listing cache: paging reads the directory once, and any write, mkdir, rename, move, delete, copy or transfer invalidates it.
130
+ - A directory copy that fails midway removes the partial copy and reports `FS_INCOMPLETE`; `FS_TOO_LARGE` is now reserved for real size limits.
131
+ - Concurrent first connects are keyed by host *and* address, so an address change cannot hand the old connection to the new request, and replaced connections are released.
132
+ - The right-sidebar tree source de-duplicates in-flight loads instead of issuing the same request twice.
133
+ - Directory pickers render at most 500 entries and suggest typing the path; PDF export stops after 999 name suffixes with an explicit error instead of looping forever.
134
+ - Tests: two self-inconsistent assertions in `test/host-api.mjs` were fixed (a leftover folder made a later create return 409, and an existing name was expected to return 400); all 28 steps now pass.
135
+
122
136
  ### 0.8.2
123
137
 
124
138
  Security, data-integrity and large-directory fixes from the three-day review round:
package/README.md CHANGED
@@ -114,6 +114,20 @@ dsh plugin --profile "$DSH_PROFILE" add "file:$PWD/dsh-linked-workspace"
114
114
 
115
115
  已打开的旧右侧栏「远程目录」标签页会在刷新后自动恢复(插件保留了两个隐藏的旧 tab 类型别名),标签标题也会自动改成「联动工作区目录」,新开的标签使用新的 `dsh-linked-workspace` 类型。
116
116
 
117
+ ### 0.8.3 变化
118
+
119
+ 针对 0.8.2 分页与预览的后续修复(第二轮评审):
120
+
121
+ - 文档预览同时删除内联 `style`,`position:fixed` 全屏覆盖层不再能把整页伪装成登录页(点击劫持/钓鱼面)。
122
+ - 目录树刷新(本地树每 4 秒)只重取已加载的条目数,不再把「加载更多」的结果打回第一页。
123
+ - 含隐藏文件时「加载更多」用服务端真实偏移,条目不再重复或遗漏;「剩余 N 项」按真实偏移计算。
124
+ - `fs.list` 增加 5 秒列表缓存:翻页只读一次目录;写、建、改名、移动、删除、复制、传输后立即失效。
125
+ - 目录复制中途失败会清理已复制的半成品并返回 `FS_INCOMPLETE`(原 `FS_TOO_LARGE` 只用于真实超限)。
126
+ - 并发首次连接按「主机 + 地址」复用,主地址变化时不再把旧连接交给新地址请求,被替换的连接会释放。
127
+ - 右侧栏树的数据源增加请求去重,重复加载不再重复发请求。
128
+ - 目录选择器最多渲染 500 项并提示可直接输入路径;导出 PDF 的同名后缀最多尝试 999 次,超限报错而不是无限循环。
129
+ - 测试:修复 `test/host-api.mjs` 自身两处断言缺陷(重复创建文件夹残留、把已存在当 400),全套 28 步通过。
130
+
117
131
  ### 0.8.2 变化
118
132
 
119
133
  安全、数据完整性与大目录体验修复(三日评审轮):
package/docs/API.md CHANGED
@@ -58,7 +58,7 @@ relative to the scope root, or absolute inside it.
58
58
  | `workspaces.remove` | `{ id }` | `{}` |
59
59
  | `workspaces.rename` | `{ id, name }` | `{ workspace }` |
60
60
  | `workspaces.test` | `{ id }` | `{ ok, latencyMs?, resolvedPath?, error? }` |
61
- | `fs.list` | `{ workspaceId?/linkedId?, path, limit?, offset? }` | `{ path, entries: RemoteEntry[], total, offset, limit, hasMore }` (default 500, max 5000) |
61
+ | `fs.list` | `{ workspaceId?/linkedId?, path, limit?, offset? }` | `{ path, entries: RemoteEntry[], total, offset, limit, hasMore }` (default 500, max 5000). Listings are cached for 5 s for `offset > 0`; writes, mkdir, rename, move, delete, copy and transfers invalidate the cache. |
62
62
  | `fs.read` | `{ workspaceId?/linkedId?, path, maxBytes? }` | `FileRead` |
63
63
  | `media.read` | `{ workspaceId?/linkedId?, path }` | `{ path, size, mtime, mime, base64 }` (PNG/JPG/GIF/WebP/BMP/ICO/AVIF/SVG, max 24 MB) |
64
64
  | `fs.write` | `{ workspaceId?/linkedId?, path, content, expectedMtime?, createOnly? }` | `{ path, size, mtime }` (`createOnly: true`: existing target → 409) |
package/docs/VERIFY.md CHANGED
@@ -145,3 +145,14 @@ Host code (lib/) reloads live with `plugin_manager set_plugin include:linked-wor
145
145
  6. **复制上限**:在远端复制一个 >64 MB 的文本/二进制文件:应提示「超过 64 MB,无法复制」,目标目录不得出现被截断的副本。
146
146
  7. **非空目录删除(工具)**:让 agent 用 `linked_workspace` 的 `delete` 删除一个含文件的远端目录:应递归删除成功,不再报 SFTP `Failure`。
147
147
  8. **Excel 截断提示**:打开一个 >200 行或 >40 列的 xlsx:表格上方应出现「仅显示前 200 行 × 40 列」;工作表超过 20 个时出现「仅显示前 20 个工作表」。
148
+
149
+ ## 分页续修与预览覆盖(0.8.3)
150
+
151
+ 1. **预览覆盖层**:放一个 Markdown 文件,内容为 `<div style="position:fixed;inset:0;z-index:99999;background:#fff;padding:24px">FAKE-LOGIN-PAGE</div>` 加一行普通文字;预览后不应出现全屏白底覆盖层,`div` 上不应残留 `style` 属性,页面其余部分照常可点。
152
+ 2. **加载更多不被刷新清空**:在本地树展开一个 >500 项的目录,点「加载更多」后**静置 10 秒**(跨过两次 4 秒刷新):行数与剩余计数应保持不变,不回退到 500 行。
153
+ 3. **隐藏文件下的分页**:目录中同时放 300 个 `.hidden*.txt` 与 600 个 `visible*.txt`;先关闭「显示隐藏的文件」再展开该目录,点「加载更多」:可见条目不应重复、不应遗漏,最终数量为 600,剩余计数归零。
154
+ 4. **目录选择器上限**:进入一个含 600+ 子目录的父目录:列表面板最多显示 500 项,并提示「目录项过多,仅显示前 500 项;可直接在输入框中输入路径」;在输入框直接键入子路径仍可选择。
155
+ 5. **翻页性能**:对 20 000 项的远端目录连续点两次「加载更多」:第二次起应明显快于首次(5 秒内命中列表缓存),且界面不卡顿。
156
+ 6. **复制失败清理(开发核验)**:复制一个内含 >64 MB 文件的目录:应报错且目标目录不留半成品(可用 `git status` 之外的目录列表确认)。
157
+ 7. **导出 PDF 上限(开发核验)**:同目录放 999 个同名 `<name>.pdf` 后再次导出同名的 Markdown:应提示「同名 PDF 过多,请先清理目录中的旧导出」,不进入无限循环。
158
+ 8. **host-api 套件**:在有 SSH 主机与 DSH 运行时 Node 的机器上跑 `node test/host-api.mjs`(本机系统 Node 24.13 删除中日韩字符目录会崩溃,用 DSH 自带 Electron Node 执行):应为 28 步全部通过。
package/lib/client.js CHANGED
@@ -1937,10 +1937,20 @@ function urlAttributeViolation(name, value, tagName) {
1937
1937
  return scheme + ': URL';
1938
1938
  }
1939
1939
 
1940
+ // Complete attribute policy for the document preview. Inline styles are dropped
1941
+ // as well: `position:fixed; inset:0; z-index:99999` can cover the whole Harness
1942
+ // window, which turns a preview into a clickjacking / phishing surface without
1943
+ // a single line of script.
1944
+ function attributeViolation(name, value, tagName) {
1945
+ if (String(name || '').toLowerCase() === 'style') return 'inline style';
1946
+ return urlAttributeViolation(name, value, tagName);
1947
+ }
1948
+
1940
1949
  var TREE_TAB_TYPE = 'dsh-linked-workspace';
1941
1950
  var REMOTE_TAB_TYPE = 'dsh-linked-workspace:remote';
1942
1951
  // One tree page; a folder with 20k entries must not render 20k rows.
1943
1952
  var TREE_PAGE_SIZE = 500;
1953
+ var TREE_PAGE_MAX = 5000;
1944
1954
  var linkedLocalKeys = new Set();
1945
1955
  var linkedWorkspaceKeys = new Set();
1946
1956
  var hasRemoteDirectories = false;
@@ -2156,6 +2166,7 @@ function urlAttributeViolation(name, value, tagName) {
2156
2166
  noEntries: '空目录',
2157
2167
  loadMore: '加载更多(剩余 {{count}} 项)',
2158
2168
  loadMoreShort: '加载更多',
2169
+ pickerTruncated: '目录项过多,仅显示前 {{count}} 项;可直接在输入框中输入路径',
2159
2170
  sheetTruncated: '仅显示前 {{rows}} 行 × {{cols}} 列',
2160
2171
  sheetMore: '工作表较多,仅显示前 20 个',
2161
2172
  noCredential: '缺少凭据',
@@ -2489,6 +2500,7 @@ function urlAttributeViolation(name, value, tagName) {
2489
2500
  noEntries: 'Empty directory',
2490
2501
  loadMore: 'Load more ({{count}} left)',
2491
2502
  loadMoreShort: 'Load more',
2503
+ pickerTruncated: 'Too many entries; only the first {{count}} are shown. Type a path directly.',
2492
2504
  sheetTruncated: 'Showing the first {{rows}} rows × {{cols}} columns',
2493
2505
  sheetMore: 'Showing the first 20 worksheets only',
2494
2506
  noCredential: 'No credentials',
@@ -2897,7 +2909,7 @@ function urlAttributeViolation(name, value, tagName) {
2897
2909
  parsed.querySelectorAll('script,style,iframe,object,embed,link,meta,base,form').forEach(function (node) { node.remove(); });
2898
2910
  parsed.querySelectorAll('*').forEach(function (element) {
2899
2911
  Array.from(element.attributes).forEach(function (attribute) {
2900
- if (urlAttributeViolation(attribute.name, attribute.value, element.tagName)) {
2912
+ if (attributeViolation(attribute.name, attribute.value, element.tagName)) {
2901
2913
  element.removeAttribute(attribute.name);
2902
2914
  }
2903
2915
  });
@@ -3764,6 +3776,10 @@ function urlAttributeViolation(name, value, tagName) {
3764
3776
 
3765
3777
  var listing = local.listing;
3766
3778
  var entries = listing ? listing.entries : [];
3779
+ // A folder with thousands of sub-folders must not build thousands of DOM
3780
+ // rows; the input box below still accepts any path.
3781
+ var PICKER_LIMIT = 500;
3782
+ var shownEntries = entries.slice(0, PICKER_LIMIT);
3767
3783
  var crumbs = useMemo(function () {
3768
3784
  if (!listing || !listing.path) return [];
3769
3785
  var path = listing.path;
@@ -3816,12 +3832,15 @@ function urlAttributeViolation(name, value, tagName) {
3816
3832
  ? h('div', { className: 'rw-row-msg' }, IconAlert(12), local.error)
3817
3833
  : entries.length === 0
3818
3834
  ? h('div', { className: 'rw-row-msg' }, t('emptyFolder'))
3819
- : entries.map(function (entry) {
3835
+ : shownEntries.map(function (entry) {
3820
3836
  return h('button', {
3821
3837
  key: entry.path, type: 'button', className: 'rw-browser-row', role: 'listitem',
3822
3838
  onClick: function () { navigate(entry.path); },
3823
3839
  }, h('span', { className: 'rw-dir-icon' }, IconFolder(13, false)), h('span', null, entry.name));
3824
- })),
3840
+ }),
3841
+ entries.length > PICKER_LIMIT
3842
+ ? h('div', { className: 'rw-row-msg' }, t('pickerTruncated', { count: PICKER_LIMIT }))
3843
+ : null),
3825
3844
  h('div', { className: 'rw-browser-actions', style: { padding: '8px', gap: 8 } },
3826
3845
  h('div', { className: 'rw-inline', style: { minWidth: 0, flex: '1 1 auto' } },
3827
3846
  h('input', {
@@ -3893,6 +3912,9 @@ function urlAttributeViolation(name, value, tagName) {
3893
3912
 
3894
3913
  var listing = remote.listing;
3895
3914
  var dirs = listing ? (listing.entries || []).filter(function (entry) { return entry.type === 'dir'; }) : [];
3915
+ var PICKER_LIMIT = 500;
3916
+ var shownDirs = dirs.slice(0, PICKER_LIMIT);
3917
+ var pickerTruncated = dirs.length > PICKER_LIMIT || Boolean(listing && listing.hasMore);
3896
3918
  var crumbs = useMemo(function () {
3897
3919
  if (!listing || !listing.path) return [];
3898
3920
  var pieces = listing.path.split('/').filter(function (piece) { return piece !== ''; });
@@ -3924,12 +3946,15 @@ function urlAttributeViolation(name, value, tagName) {
3924
3946
  ? h('div', { className: 'rw-row-msg' }, IconAlert(12), remote.error)
3925
3947
  : dirs.length === 0
3926
3948
  ? h('div', { className: 'rw-row-msg' }, t('noEntries'))
3927
- : dirs.map(function (entry) {
3949
+ : shownDirs.map(function (entry) {
3928
3950
  return h('button', {
3929
3951
  key: entry.path, type: 'button', className: 'rw-browser-row', role: 'listitem',
3930
3952
  onClick: function () { navigate(entry.path); },
3931
3953
  }, h('span', { className: 'rw-dir-icon' }, IconFolder(13, false)), h('span', null, entry.name));
3932
- })),
3954
+ }),
3955
+ pickerTruncated
3956
+ ? h('div', { className: 'rw-row-msg' }, t('pickerTruncated', { count: PICKER_LIMIT }))
3957
+ : null),
3933
3958
  h('div', { className: 'rw-browser-actions', style: { padding: '8px' } },
3934
3959
  h('span', { className: 'rw-hint rw-input-mono', title: remote.input || (listing && listing.path) || '' },
3935
3960
  (listing && listing.path) || (remote.input !== '' ? remote.input : t('home'))),
@@ -4628,8 +4653,8 @@ function urlAttributeViolation(name, value, tagName) {
4628
4653
  });
4629
4654
  }
4630
4655
 
4631
- function listRequest(path, offset) {
4632
- var params = { path: path, limit: TREE_PAGE_SIZE, offset: offset };
4656
+ function listRequest(path, offset, limit) {
4657
+ var params = { path: path, limit: limit || TREE_PAGE_SIZE, offset: offset };
4633
4658
  return props.kind === 'local'
4634
4659
  ? apiCall('local.list', params)
4635
4660
  : apiCall('fs.list', Object.assign(params, scopeParams(props.scope)));
@@ -4644,13 +4669,17 @@ function urlAttributeViolation(name, value, tagName) {
4644
4669
  function load(path, silent) {
4645
4670
  if (pending.current.has(path)) return;
4646
4671
  pending.current.add(path);
4672
+ var previousRecord = cache.entries.get(path) || {};
4673
+ // A silent refresh keeps as many rows as the user already loaded:
4674
+ // re-reading only the first page would drop everything past it.
4675
+ var keep = silent ? Math.min(Math.max(Number(previousRecord.raw) || 0, TREE_PAGE_SIZE), TREE_PAGE_MAX) : TREE_PAGE_SIZE;
4647
4676
  setCache(function (previous) {
4648
4677
  var entries = new Map(previous.entries);
4649
4678
  var existing = entries.get(path) || {};
4650
- entries.set(path, { loading: !silent, error: null, entries: existing.entries || [], loaded: existing.loaded });
4679
+ entries.set(path, { loading: !silent, error: null, entries: existing.entries || [], loaded: existing.loaded, raw: existing.raw, total: existing.total, hasMore: existing.hasMore });
4651
4680
  return { entries: entries };
4652
4681
  });
4653
- listRequest(path, 0).then(function (result) {
4682
+ listRequest(path, 0, keep).then(function (result) {
4654
4683
  pending.current.delete(path);
4655
4684
  setCache(function (previous) {
4656
4685
  var next = new Map(previous.entries);
@@ -4659,6 +4688,7 @@ function urlAttributeViolation(name, value, tagName) {
4659
4688
  error: null,
4660
4689
  loaded: true,
4661
4690
  entries: applyEntries(result.entries || []),
4691
+ raw: (result.entries || []).length,
4662
4692
  total: Number(result.total) || (result.entries || []).length,
4663
4693
  hasMore: Boolean(result.hasMore),
4664
4694
  });
@@ -4682,13 +4712,18 @@ function urlAttributeViolation(name, value, tagName) {
4682
4712
  function loadMore(path) {
4683
4713
  var record = cache.entries.get(path);
4684
4714
  if (!record || record.loadingMore || !record.hasMore) return;
4715
+ // Offset is the number of RAW entries already fetched, not the number of
4716
+ // visible rows: hidden entries are filtered out of the list but still
4717
+ // occupy positions in the server-side listing.
4718
+ var offset = Number(record.raw);
4719
+ if (!Number.isFinite(offset)) offset = (record.entries || []).length;
4685
4720
  setCache(function (previous) {
4686
4721
  var next = new Map(previous.entries);
4687
4722
  var current = next.get(path);
4688
4723
  if (current) next.set(path, Object.assign({}, current, { loadingMore: true }));
4689
4724
  return { entries: next };
4690
4725
  });
4691
- listRequest(path, (record.entries || []).length).then(function (result) {
4726
+ listRequest(path, offset).then(function (result) {
4692
4727
  setCache(function (previous) {
4693
4728
  var next = new Map(previous.entries);
4694
4729
  var current = next.get(path) || { entries: [] };
@@ -4696,6 +4731,7 @@ function urlAttributeViolation(name, value, tagName) {
4696
4731
  loadingMore: false,
4697
4732
  loaded: true,
4698
4733
  entries: current.entries.concat(applyEntries(result.entries || [])),
4734
+ raw: offset + (result.entries || []).length,
4699
4735
  total: Number(result.total) || current.total,
4700
4736
  hasMore: Boolean(result.hasMore),
4701
4737
  }));
@@ -6575,7 +6611,7 @@ function urlAttributeViolation(name, value, tagName) {
6575
6611
  }, entry.loadingMore ? Spinner(12) : null,
6576
6612
  entry.moreError
6577
6613
  ? messageOf(entry.moreError, t, locale)
6578
- : t('loadMore', { count: Math.max((entry.total || 0) - entry.entries.length, 0) })));
6614
+ : t('loadMore', { count: Math.max((entry.total || 0) - (Number.isFinite(entry.raw) ? entry.raw : entry.entries.length), 0) })));
6579
6615
  }
6580
6616
  }
6581
6617
  }
@@ -6639,15 +6675,18 @@ function urlAttributeViolation(name, value, tagName) {
6639
6675
  }
6640
6676
 
6641
6677
  function load(path) {
6678
+ // Ref-based guard: a state setter callback is not a lock, so checking
6679
+ // record.loading inside setCache would still fire duplicate requests.
6680
+ if (pending.current.has(path)) return;
6681
+ pending.current.add(path);
6642
6682
  setCache(function (previous) {
6643
- var record = previous.entries.get(path);
6644
- if (record && record.loading) return previous;
6645
6683
  var entries = new Map(previous.entries);
6646
6684
  var existing = entries.get(path) || {};
6647
6685
  entries.set(path, { loading: true, error: null, entries: existing.entries || [], loaded: existing.loaded });
6648
6686
  return { entries: entries };
6649
6687
  });
6650
6688
  apiCall('fs.list', listRequest(path, 0)).then(function (result) {
6689
+ pending.current.delete(path);
6651
6690
  setCache(function (previous) {
6652
6691
  var entries = new Map(previous.entries);
6653
6692
  entries.set(path, {
@@ -6655,6 +6694,7 @@ function urlAttributeViolation(name, value, tagName) {
6655
6694
  error: null,
6656
6695
  loaded: true,
6657
6696
  entries: result.entries || [],
6697
+ raw: (result.entries || []).length,
6658
6698
  total: Number(result.total) || (result.entries || []).length,
6659
6699
  hasMore: Boolean(result.hasMore),
6660
6700
  });
@@ -6666,6 +6706,7 @@ function urlAttributeViolation(name, value, tagName) {
6666
6706
  return next;
6667
6707
  });
6668
6708
  }).catch(function (error) {
6709
+ pending.current.delete(path);
6669
6710
  setCache(function (previous) {
6670
6711
  var entries = new Map(previous.entries);
6671
6712
  entries.set(path, { loading: false, error: error, loaded: false, entries: [] });
@@ -6677,13 +6718,14 @@ function urlAttributeViolation(name, value, tagName) {
6677
6718
  function loadMore(path) {
6678
6719
  var record = cache.entries.get(path);
6679
6720
  if (!record || record.loadingMore || !record.hasMore) return;
6721
+ var offset = Number.isFinite(Number(record.raw)) ? Number(record.raw) : (record.entries || []).length;
6680
6722
  setCache(function (previous) {
6681
6723
  var entries = new Map(previous.entries);
6682
6724
  var current = entries.get(path);
6683
6725
  if (current) entries.set(path, Object.assign({}, current, { loadingMore: true }));
6684
6726
  return { entries: entries };
6685
6727
  });
6686
- apiCall('fs.list', listRequest(path, (record.entries || []).length)).then(function (result) {
6728
+ apiCall('fs.list', listRequest(path, offset)).then(function (result) {
6687
6729
  setCache(function (previous) {
6688
6730
  var entries = new Map(previous.entries);
6689
6731
  var current = entries.get(path) || { entries: [] };
@@ -6691,6 +6733,7 @@ function urlAttributeViolation(name, value, tagName) {
6691
6733
  loadingMore: false,
6692
6734
  loaded: true,
6693
6735
  entries: current.entries.concat(result.entries || []),
6736
+ raw: offset + (result.entries || []).length,
6694
6737
  total: Number(result.total) || current.total,
6695
6738
  hasMore: Boolean(result.hasMore),
6696
6739
  }));
package/lib/index.js CHANGED
@@ -87,6 +87,21 @@ const OP_TIMEOUT_MS = 60_000
87
87
  // cannot stall the Host or flood the renderer.
88
88
  const LIST_PAGE_SIZE = 500
89
89
  const LIST_PAGE_MAX = 5000
90
+ // SFTP readdir has no paging, so a directory is always read in one call. A
91
+ // freshly read listing is kept briefly so "load more" does not repeat the same
92
+ // expensive scan; a first page (offset 0) is always re-read.
93
+ const LISTING_CACHE_TTL_MS = 5000
94
+ const LISTING_CACHE_LIMIT = 128
95
+ const listingCache = new Map()
96
+ const listingCacheKey = (scope, target) => `${scope.kind === 'linked' ? 'L' : 'W'}:${scope.id}:${target}`
97
+ const rememberListing = (key, listing) => {
98
+ listingCache.delete(key)
99
+ listingCache.set(key, { at: Date.now(), listing })
100
+ while (listingCache.size > LISTING_CACHE_LIMIT) {
101
+ listingCache.delete(listingCache.keys().next().value)
102
+ }
103
+ }
104
+ const forgetListings = () => listingCache.clear()
90
105
 
91
106
  /** Clamp a requested page window to the listing limits. */
92
107
  const pageWindow = (params = {}, total = 0) => {
@@ -111,6 +126,7 @@ const ERROR_STATUS = {
111
126
  FS_IS_DIR: 400,
112
127
  FS_NOT_DIR: 400,
113
128
  FS_TOO_LARGE: 413,
129
+ FS_INCOMPLETE: 500,
114
130
  FS_EXISTS: 409,
115
131
  CONFLICT: 409,
116
132
  INTERNAL: 500,
@@ -703,7 +719,7 @@ class HostPool {
703
719
  this.knownHostsFile = knownHostsFile
704
720
  this.records = new Map() // hostId -> HostRecord
705
721
  this.connections = new Map() // hostId -> { authority, remote: RemoteHost }
706
- this.connecting = new Map() // hostId -> in-flight connect promise
722
+ this.connecting = new Map() // hostId -> { authority, promise }
707
723
  this.statuses = new Map() // hostId -> { state, message?, latencyMs?, checkedAt }
708
724
  }
709
725
 
@@ -718,6 +734,12 @@ class HostPool {
718
734
  this.connections.delete(hostId)
719
735
  }
720
736
  }
737
+ // In-flight connects for removed or re-addressed hosts must not be reused:
738
+ // the attempt itself notices the lost map entry and disposes its socket.
739
+ for (const [hostId, pending] of [...this.connecting]) {
740
+ const host = this.records.get(hostId)
741
+ if (!host || `${host.host}:${host.port}` !== pending.authority) this.connecting.delete(hostId)
742
+ }
721
743
  }
722
744
 
723
745
  host(hostId) {
@@ -751,9 +773,11 @@ class HostPool {
751
773
  }
752
774
  // Two concurrent first requests for one host must share one connection;
753
775
  // otherwise the second RemoteHost overwrites the map entry and the first
754
- // SSH socket leaks.
776
+ // SSH socket leaks. The pending attempt is bound to the address it dials,
777
+ // so a host edit during connect never hands the old socket to the new
778
+ // address.
755
779
  const pending = this.connecting.get(hostRecord.id)
756
- if (pending) return pending
780
+ if (pending && pending.authority === authority) return pending.promise
757
781
  const remote = new RemoteHost({
758
782
  host: hostRecord,
759
783
  knownHostsFile: this.knownHostsFile,
@@ -765,16 +789,22 @@ class HostPool {
765
789
  const attempt = (async () => {
766
790
  try {
767
791
  await remote.connect()
792
+ // The host may have been removed while the socket was connecting: keep
793
+ // the map state authoritative so the unused connection is closed.
794
+ if (this.connections.get(hostRecord.id) !== entry) {
795
+ remote.dispose().catch(() => {})
796
+ }
768
797
  return remote
769
798
  } catch (error) {
770
799
  if (this.connections.get(hostRecord.id) === entry) this.connections.delete(hostRecord.id)
771
800
  remote.dispose().catch(() => {})
772
801
  throw error
773
802
  } finally {
774
- this.connecting.delete(hostRecord.id)
803
+ const current = this.connecting.get(hostRecord.id)
804
+ if (current && current.promise === attempt) this.connecting.delete(hostRecord.id)
775
805
  }
776
806
  })()
777
- this.connecting.set(hostRecord.id, attempt)
807
+ this.connecting.set(hostRecord.id, { authority, promise: attempt })
778
808
  return attempt
779
809
  }
780
810
 
@@ -1387,7 +1417,7 @@ export function apply(ctx) {
1387
1417
  }
1388
1418
  const read = await remote.readFile(source, { maxBytes: LINKED_OPEN_FILE_CAP })
1389
1419
  if (read.truncated || read.data.length !== stat.size) {
1390
- throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 读取不完整,已取消复制`)
1420
+ throw new ApiError('FS_INCOMPLETE', `${posix.basename(source)} 读取不完整,已取消复制`)
1391
1421
  }
1392
1422
  await remote.writeFileAtomic(target, read.data)
1393
1423
  return 1
@@ -1595,6 +1625,7 @@ export function apply(ctx) {
1595
1625
  // ── document bridge (Markdown/PDF/DOCX/XLSX/PPTX) ──────────────────────
1596
1626
 
1597
1627
  const DOC_MAX_BYTES = 80 * 1024 * 1024
1628
+ const DOC_PDF_SUFFIX_MAX = 999
1598
1629
  const DOC_TOOL = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'doc_tool.py')
1599
1630
  const DOC_KIND_BY_EXT = new Map([
1600
1631
  ['.md', 'markdown'], ['.markdown', 'markdown'],
@@ -1867,7 +1898,18 @@ export function apply(ctx) {
1867
1898
  const target = params.path === undefined || params.path === ''
1868
1899
  ? scope.root
1869
1900
  : scopeTarget(scope, params.path)
1870
- const listing = await scope.remote.listDir(target)
1901
+ const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
1902
+ const requestedOffset = Math.max(Number(params.offset) || 0, 0)
1903
+ const key = listingCacheKey(scope, target)
1904
+ let listing
1905
+ if (requestedOffset > 0) {
1906
+ const hit = listingCache.get(key)
1907
+ if (hit && Date.now() - hit.at < LISTING_CACHE_TTL_MS) listing = hit.listing
1908
+ }
1909
+ if (!listing) {
1910
+ listing = await scope.remote.listDir(target)
1911
+ rememberListing(key, listing)
1912
+ }
1871
1913
  const all = listing.entries.map((entry) => ({
1872
1914
  name: entry.name,
1873
1915
  path: entry.path,
@@ -1876,7 +1918,7 @@ export function apply(ctx) {
1876
1918
  mtime: entry.mtime,
1877
1919
  relative: scopeRelative(scope, entry.path),
1878
1920
  }))
1879
- const page = pageWindow(params, all.length)
1921
+ const page = pageWindow({ limit, offset: requestedOffset }, all.length)
1880
1922
  return {
1881
1923
  path: target,
1882
1924
  entries: all.slice(page.offset, page.offset + page.limit),
@@ -1959,6 +2001,7 @@ export function apply(ctx) {
1959
2001
  mode: current ? current.mode : undefined,
1960
2002
  })
1961
2003
  await scopeMirror(scope, target, data)
2004
+ forgetListings()
1962
2005
  return { path: target, size: info.size, mtime: info.mtime }
1963
2006
  },
1964
2007
 
@@ -2083,19 +2126,21 @@ export function apply(ctx) {
2083
2126
  const directory = posix.dirname(outputPath)
2084
2127
  let candidate = outputPath
2085
2128
  let index = 1
2086
- while (await target.scope.remote.stat(candidate).catch(() => null)) {
2129
+ while (index <= DOC_PDF_SUFFIX_MAX && await target.scope.remote.stat(candidate).catch(() => null)) {
2087
2130
  candidate = posix.join(directory, posix.basename(base) + '-' + index + '.pdf')
2088
2131
  index += 1
2089
2132
  }
2133
+ if (index > DOC_PDF_SUFFIX_MAX) throw new ApiError('FS_EXISTS', '同名 PDF 过多,请先清理目录中的旧导出')
2090
2134
  outputPath = candidate
2091
2135
  } else {
2092
2136
  const directory = dirname(outputPath)
2093
2137
  let candidate = outputPath
2094
2138
  let index = 1
2095
- while (existsSync(candidate)) {
2139
+ while (index <= DOC_PDF_SUFFIX_MAX && existsSync(candidate)) {
2096
2140
  candidate = join(directory, basename(base) + '-' + index + '.pdf')
2097
2141
  index += 1
2098
2142
  }
2143
+ if (index > DOC_PDF_SUFFIX_MAX) throw new ApiError('FS_EXISTS', '同名 PDF 过多,请先清理目录中的旧导出')
2099
2144
  outputPath = candidate
2100
2145
  }
2101
2146
  const temp = await makeDocTemp()
@@ -2141,6 +2186,7 @@ export function apply(ctx) {
2141
2186
  if (current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
2142
2187
  }
2143
2188
  await scope.remote.mkdirp(target)
2189
+ forgetListings()
2144
2190
  return {}
2145
2191
  },
2146
2192
 
@@ -2151,6 +2197,7 @@ export function apply(ctx) {
2151
2197
  if (from === scope.root) throw new ApiError('BAD_REQUEST', '不能移动工作区根目录')
2152
2198
  await scope.remote.rename(from, to)
2153
2199
  await scopeMirrorRemove(scope, from)
2200
+ forgetListings()
2154
2201
  return {}
2155
2202
  },
2156
2203
 
@@ -2172,6 +2219,7 @@ export function apply(ctx) {
2172
2219
  if (await scope.remote.stat(target).catch(() => null)) throw new ApiError('FS_EXISTS', '目标目录中已存在同名文件或文件夹')
2173
2220
  await scope.remote.rename(source, target)
2174
2221
  await scopeMirrorRemove(scope, source)
2222
+ forgetListings()
2175
2223
  return { path: target }
2176
2224
  },
2177
2225
 
@@ -2181,6 +2229,7 @@ export function apply(ctx) {
2181
2229
  if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能删除工作区根目录')
2182
2230
  await remoteDeleteRecursive(scope.remote, target)
2183
2231
  await scopeMirrorRemove(scope, target)
2232
+ forgetListings()
2184
2233
  return {}
2185
2234
  },
2186
2235
 
@@ -2188,7 +2237,9 @@ export function apply(ctx) {
2188
2237
  const scope = await requireScope(params)
2189
2238
  if (scope.kind !== 'linked') throw new ApiError('BAD_REQUEST', '只有联动工作区支持文件传输')
2190
2239
  if (params.side === 'local') {
2191
- return await transferLocalToRemote(scope.record, scope.remote, params.path)
2240
+ const result = await transferLocalToRemote(scope.record, scope.remote, params.path)
2241
+ forgetListings()
2242
+ return result
2192
2243
  }
2193
2244
  const target = scopeTarget(scope, params.path)
2194
2245
  return await transferToMirror(scope.record, scope.remote, target)
@@ -2219,7 +2270,16 @@ export function apply(ctx) {
2219
2270
  throw new ApiError('BAD_REQUEST', '不能把目录复制到自身内部')
2220
2271
  }
2221
2272
  const target = await remoteUniqueTarget(scope.remote, parent, posix.basename(source))
2222
- const files = await remoteCopyRecursive(scope.remote, source, target)
2273
+ let files
2274
+ try {
2275
+ files = await remoteCopyRecursive(scope.remote, source, target)
2276
+ } catch (error) {
2277
+ // A half-copied tree is worse than a clean failure: remove what this
2278
+ // call created (the target name was unique, so nothing else lives there).
2279
+ await remoteDeleteRecursive(scope.remote, target).catch(() => {})
2280
+ throw error
2281
+ }
2282
+ forgetListings()
2223
2283
  return { path: target, files }
2224
2284
  },
2225
2285
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-linked-workspace",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Linked local ⇄ remote workspaces and SSH-backed remote directories for DeepSeek Harness: theme-aware split editor, file-type icons, and an agent tool that edits remote files directly.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",