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 +14 -0
- package/README.en.md +14 -0
- package/README.md +14 -0
- package/docs/API.md +1 -1
- package/docs/VERIFY.md +11 -0
- package/lib/client.js +57 -14
- package/lib/index.js +72 -12
- package/package.json +1 -1
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 (
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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,
|
|
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,
|
|
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 ->
|
|
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.
|
|
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('
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|