dsh-linked-workspace 0.8.1 → 0.8.2
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 +21 -0
- package/README.en.md +15 -0
- package/README.md +15 -0
- package/docs/API.md +6 -6
- package/docs/VERIFY.md +13 -0
- package/lib/client.js +347 -54
- package/lib/index.js +138 -40
- package/lib/linked.js +46 -24
- package/package.json +2 -2
- package/scripts/doc_tool.py +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2 — 2026-11-11
|
|
4
|
+
|
|
5
|
+
Three-day review round: security, data-integrity and large-directory fixes.
|
|
6
|
+
|
|
7
|
+
- **Document preview XSS (high)**: the preview sanitizer now normalizes control characters, whitespace and zero-width characters before checking a URL scheme, so `jav\tascript:` links (and `vbscript:`, `file:`, `blob:`, `data:text/html`) can no longer run in the GUI. Only `http(s)`, `mailto`, `tel`, relative URLs and bitmap `data:image/*` sources are kept; SVG data URLs stay blocked.
|
|
8
|
+
- **Mirror containment on Windows**: `realpathSync.native` is used for the containment check, so a directory junction can no longer make `.remote` (or a mirrored file) point outside the workspace. Linked `.local`/`.remote` entries that are themselves links are removed as links, never followed.
|
|
9
|
+
- **No silent truncation when copying**: copying a remote file larger than 64 MB, or reading one incompletely, now fails with an explicit error instead of writing a truncated copy. The `linked_workspace` read tool no longer mirrors a truncated payload into `.remote`.
|
|
10
|
+
- **Large local files**: `local.read` reads only the requested head of the file instead of loading the whole thing into memory, so peeking at multi-gigabyte files no longer allocates them (or fails with `ERR_FS_FILE_TOO_LARGE`).
|
|
11
|
+
- **Non-empty directory delete from the tool**: `linked_workspace` `delete` now removes directories recursively, matching the UI, instead of failing on SFTP `rmdir`.
|
|
12
|
+
- **Paged directory listings**: `fs.list` and `local.list` return `limit`/`offset` with `total` and `hasMore` (default 500, max 5000); both trees render a “Load more (N left)” row. A 20,000-entry folder no longer freezes the UI (it used to render all 20,012 rows).
|
|
13
|
+
- **Deep sync trees**: the mirror sync marks a job truncated when it stops at the depth limit, instead of reporting success with missing files.
|
|
14
|
+
- **Accessibility**: visible `:focus-visible` rings for tree rows, inputs, buttons and tabs; `role="treeitem"`/`aria-level`/`aria-selected`/`aria-expanded` on tree rows with ←/→ collapse/expand; the add-workspace dialog focuses its first field, traps Tab, and closes on Escape.
|
|
15
|
+
- **Excel preview honesty**: `read_xlsx` reports `totalRow`/`totalCol`/`truncated`, and the grid notes when it shows only the first 200 rows × 40 columns or the first 20 worksheets.
|
|
16
|
+
- **Build and host fixes**: the client build fails loudly when a `src/parts/*` file is missing; the dead branch in `linked.update` is gone; `HostPool` reuses one in-flight connection per host instead of leaking a duplicate on concurrent first connects.
|
|
17
|
+
|
|
18
|
+
Earlier unreleased work included in this version:
|
|
19
|
+
|
|
20
|
+
- Fixed linked-directory context-menu creation on both the remote and local trees: New folder and New file are direct menu actions, so the scrollable menu no longer clips the creation submenu.
|
|
21
|
+
- CSV, HTML, SVG and other preview formats now create ordinary empty files; only Markdown, PDF, DOCX, XLSX and PPTX use document templates.
|
|
22
|
+
- Creation keeps the name dialog open on failure, displays the error and prevents repeated submissions while pending. New files and folders reject existing names with HTTP 409 instead of replacing or silently reusing them, on both remote and local trees.
|
|
23
|
+
|
|
3
24
|
## 0.8.1 — 2026-11-09
|
|
4
25
|
|
|
5
26
|
- **Image zoom**: the image preview gains zoom-out/zoom-in buttons, a percentage readout, “Fit to window” and “1:1” controls, plus `Ctrl/Cmd + wheel` zoom.
|
package/README.en.md
CHANGED
|
@@ -119,6 +119,21 @@ 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.2
|
|
123
|
+
|
|
124
|
+
Security, data-integrity and large-directory fixes from the three-day review round:
|
|
125
|
+
|
|
126
|
+
- The document-preview sanitizer normalizes control characters, whitespace and zero-width characters before checking a URL scheme, so `jav\tascript:`, `vbscript:`, `file:` and `data:text/html` links no longer run in the GUI; only `http(s)`, `mailto`, `tel`, relative URLs and bitmap `data:image` sources are kept.
|
|
127
|
+
- Windows junctions can no longer escape the `.remote` containment check (`realpathSync.native`); linked `.local`/`.remote` entries are removed as links, never followed.
|
|
128
|
+
- Copying a remote file over 64 MB, or reading one incompletely, now fails loudly instead of writing a truncated copy; the read tool no longer mirrors truncated payloads.
|
|
129
|
+
- `local.read` reads only the requested head instead of loading the whole file into memory.
|
|
130
|
+
- The `linked_workspace` `delete` action removes non-empty directories recursively.
|
|
131
|
+
- Directory listings are paged (500 by default, 5000 max, with `total`/`hasMore`) and both trees render a “Load more” row, so a 20,000-entry folder no longer freezes the UI.
|
|
132
|
+
- Mirror sync marks a job truncated at the depth limit instead of reporting success with files missing.
|
|
133
|
+
- Accessibility: `:focus-visible` rings, `treeitem`/`aria-level`/`aria-expanded` semantics, `←`/`→` collapse and expand, and an add-workspace dialog that focuses its first field, traps Tab and closes on Escape.
|
|
134
|
+
- Excel previews report `totalRow`/`totalCol`/`truncated` and note when the grid shows only the first 200 rows × 40 columns or the first 20 worksheets.
|
|
135
|
+
- The client build fails when a `src/parts/*` file is missing; the dead `linked.update` branch is gone; `HostPool` reuses one in-flight connection per host.
|
|
136
|
+
|
|
122
137
|
### 0.7.0
|
|
123
138
|
|
|
124
139
|
Single-click tree navigation with preview tabs and double-click pinning, remote/local terminal buttons with `cd`, VSCode-like shortcuts, unsaved-close prompts, and an auto-save switch under Settings → Connections.
|
package/README.md
CHANGED
|
@@ -114,6 +114,21 @@ dsh plugin --profile "$DSH_PROFILE" add "file:$PWD/dsh-linked-workspace"
|
|
|
114
114
|
|
|
115
115
|
已打开的旧右侧栏「远程目录」标签页会在刷新后自动恢复(插件保留了两个隐藏的旧 tab 类型别名),标签标题也会自动改成「联动工作区目录」,新开的标签使用新的 `dsh-linked-workspace` 类型。
|
|
116
116
|
|
|
117
|
+
### 0.8.2 变化
|
|
118
|
+
|
|
119
|
+
安全、数据完整性与大目录体验修复(三日评审轮):
|
|
120
|
+
|
|
121
|
+
- 文档预览净化器先归一控制字符/空白/零宽字符再判 scheme,`jav\tascript:`、`vbscript:`、`file:`、`data:text/html` 不再可执行;只保留 http(s)/mailto/tel、相对链接与位图 `data:image`。
|
|
122
|
+
- Windows junction 不再能绕过 `.remote` 包含性校验(改用 `realpathSync.native`);链接目录只删链接本身。
|
|
123
|
+
- 远端复制 >64 MB 或读取不完整时直接报错,不再静默写入截断副本;工具读文件只在未截断时同步到 `.remote`。
|
|
124
|
+
- `local.read` 只读取所需头部,不再把超大文件整体读入内存。
|
|
125
|
+
- 工具 `delete` 可递归删除非空远端目录。
|
|
126
|
+
- 目录列表分页(默认 500、最大 5000,返回 `total`/`hasMore`),树底「加载更多」;2 万项目录不再卡死。
|
|
127
|
+
- 同步深度超限会标记为截断,不再把漏文件当成功。
|
|
128
|
+
- 可访问性:`:focus-visible` 焦点环、树行 `treeitem`/`aria-level`/`aria-expanded`、`←`/`→` 折叠展开、添加工作区弹窗聚焦首字段 + Tab 循环 + Esc 关闭。
|
|
129
|
+
- Excel 预览返回 `totalRow`/`totalCol`/`truncated`,并提示「仅显示前 200 行 × 40 列」「仅显示前 20 个工作表」。
|
|
130
|
+
- 客户端构建缺 `src/parts/*` 文件时直接失败;移除 `linked.update` 死分支;`HostPool` 并发首连复用同一连接。
|
|
131
|
+
|
|
117
132
|
### 0.5.11 变化
|
|
118
133
|
|
|
119
134
|
远程目录的只读文件现在能看到代码内容;设置里「打开工作区」会跳转并关闭设置,远程目录「打开」会打开对应远程标签。
|
package/docs/API.md
CHANGED
|
@@ -58,12 +58,12 @@ 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 }` | `{ path, entries: RemoteEntry[] }` |
|
|
61
|
+
| `fs.list` | `{ workspaceId?/linkedId?, path, limit?, offset? }` | `{ path, entries: RemoteEntry[], total, offset, limit, hasMore }` (default 500, max 5000) |
|
|
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
|
-
| `fs.write` | `{ workspaceId?/linkedId?, path, content, expectedMtime? }` | `{ path, size, mtime }` |
|
|
64
|
+
| `fs.write` | `{ workspaceId?/linkedId?, path, content, expectedMtime?, createOnly? }` | `{ path, size, mtime }` (`createOnly: true`: existing target → 409) |
|
|
65
65
|
| `fs.stat` | `{ workspaceId?/linkedId?, path }` | `{ path, relative, type, size, mtime, mode }` |
|
|
66
|
-
| `fs.mkdir` | `{ workspaceId?/linkedId?, path }` | `{}` |
|
|
66
|
+
| `fs.mkdir` | `{ workspaceId?/linkedId?, path, createOnly? }` | `{}` (`createOnly: true`: existing target → 409) |
|
|
67
67
|
| `fs.rename` | `{ workspaceId?/linkedId?, from, to }` | `{}` |
|
|
68
68
|
| `fs.move` | `{ workspaceId?/linkedId?, path, targetDir? }` | `{ path }` (same remote tree; collision → 409) |
|
|
69
69
|
| `fs.delete` | `{ workspaceId?/linkedId?, path }` | `{}` |
|
|
@@ -71,7 +71,7 @@ relative to the scope root, or absolute inside it.
|
|
|
71
71
|
| `settings.get` | `{}` | `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode, autoSave }` |
|
|
72
72
|
| `settings.update` | `{ editorTheme?, editorThemeFamily?, editorThemeMode?, fileOpenMode?, autoSave? }` | `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode, autoSave }` |
|
|
73
73
|
| `local.browse` | `{ path? }` | `{ path, parent, home, entries: [{ name, path }] }` |
|
|
74
|
-
| `local.mkdir` | `{ path, name }` | `{ path }` |
|
|
74
|
+
| `local.mkdir` | `{ path, name }` | `{ path }` (existing target → 409) |
|
|
75
75
|
| `linked.list` | `{}` | `{ links: LinkInfo[] }` (each link carries `running`) |
|
|
76
76
|
| `linked.create` | `{ hostId, remotePath, localPath, name?, mirror? }` | `{ link, workspaceId: string|null }` |
|
|
77
77
|
| `linked.update` | `{ id, name?, mirror? }` | `{ link }` |
|
|
@@ -79,7 +79,7 @@ relative to the scope root, or absolute inside it.
|
|
|
79
79
|
| `linked.sync` | `{ id, maxFiles?, maxBytes? }` | `{ job: SyncJob }` (starts a background job) |
|
|
80
80
|
| `linked.syncStatus` | `{ id }` | `{ job: SyncJob | null }` |
|
|
81
81
|
| `linked.syncCancel` | `{ id }` | `{ cancelled: boolean }` |
|
|
82
|
-
| `local.list` | `{ path }` | `{ path, entries: [{ name, path, type, size, mtime }] }` |
|
|
82
|
+
| `local.list` | `{ path, limit?, offset? }` | `{ path, entries: [{ name, path, type, size, mtime }], total, offset, limit, hasMore }` |
|
|
83
83
|
| `local.openExternal` | `{ path }` | `{ opened, path }` |
|
|
84
84
|
| `terminal.open` | `{ workspaceId?/linkedId?, side: 'remote'\|'local' }` | `{ opened, side, path, host? }` |
|
|
85
85
|
| `fs.openExternal` | `{ workspaceId?/linkedId?, path }` | `{ opened, localPath, size }` |
|
|
@@ -102,7 +102,7 @@ relative to the scope root, or absolute inside it.
|
|
|
102
102
|
| `local.move` | `{ path, targetDir?, root? }` | `{ path, type }` (same local tree; collision → 409) |
|
|
103
103
|
| `local.delete` | `{ path, root? }` | `{}` (recursive) |
|
|
104
104
|
| `local.copy` | `{ path, targetDir? }` | `{ path, type }` |
|
|
105
|
-
| `local.newFile` | `{ path }` | `{ path }` |
|
|
105
|
+
| `local.newFile` | `{ path }` | `{ path }` (existing target → 409) |
|
|
106
106
|
| `local.chmod` | `{ path, mode }` | `{ path, mode }` |
|
|
107
107
|
| `local.edit` | `{ path, app? }` | `{ path }` (opens Notepad) |
|
|
108
108
|
|
package/docs/VERIFY.md
CHANGED
|
@@ -89,6 +89,8 @@ Host code (lib/) reloads live with `plugin_manager set_plugin include:linked-wor
|
|
|
89
89
|
19. 编辑器:内部内容面板不显示路径/保存头部(Ctrl+S 保存);修改未保存时文件标签名前出现灰色圆点;行号、语法高亮(字符串/注释/关键字有颜色)、主题配色、
|
|
90
90
|
远端并发修改 → 冲突提示(重新加载/强制保存)、二进制/大文件只读;底部状态栏完整可见(不再被视口裁切)。
|
|
91
91
|
|
|
92
|
+
20. **右键新建回归**:远端目录和本地目录的文件夹均右击后,菜单中直接出现「新建文件夹」「新建文件」(不依赖向右展开的子菜单);分别在根目录和嵌套目录创建文件夹、`.txt`、`.csv`、`.html`、`.svg`、`.png`,应创建空普通文件。`.md`、`.pdf`、`.docx`、`.xlsx`、`.pptx` 仍创建对应模板并打开;重复名称应留在命名对话框并显示错误,不得覆盖已有文件。
|
|
93
|
+
|
|
92
94
|
## 联动会话(模型工具)
|
|
93
95
|
|
|
94
96
|
1. 在联动工作区里新建会话,问模型「这个联动工作区的远端根目录是什么」→ 它应调用 `linked_workspace` 的 `list`/`info`。
|
|
@@ -132,3 +134,14 @@ Host code (lib/) reloads live with `plugin_manager set_plugin include:linked-wor
|
|
|
132
134
|
4. 本地与远端各打开一个 `.html`/`.htm`:内容面板以 iframe 渲染页面,状态栏显示 HTML、大小和「网页脚本已禁用」。
|
|
133
135
|
5. HTML 内联样式与静态标签正常显示;页面内脚本不执行,链接不会跳出 DSH 主界面。
|
|
134
136
|
6. 读取失败的 HTML(权限/过大/二进制)显示错误提示和重试按钮。
|
|
137
|
+
|
|
138
|
+
## 安全、分页与可访问性(0.8.2)
|
|
139
|
+
|
|
140
|
+
1. **预览链接净化**:在本地或远端放一个 Markdown 文件,内容含 `<a href="jav	ascript:document.title='X'">click</a>` 与 `[example](https://example.com)`;预览后点第一个链接:页面不应跳转、标题不变、DSH 界面不被替换;第二个链接仍指向 `https://example.com`。`vbscript:`、`file:`、`data:text/html`、`data:image/svg+xml` 链接同样应被移除。
|
|
141
|
+
2. **大目录分页**:造一个 20 000 项的目录(`1..20000 | % { New-Item -ItemType File "f$_.txt" }`),在目录树展开它:应只出现 500 行和「加载更多(剩余 19500 项)」,页面不卡顿、可滚动、可点其他文件;点「加载更多」追加 500 行并更新剩余计数,滚到底部后可继续加载。
|
|
142
|
+
3. **键盘操作**:Tab 到目录树行应看到明显焦点环;聚焦树行时 `←` 收起、`→` 展开目录;读屏应能听到 treeitem 层级与展开状态。
|
|
143
|
+
4. **添加工作区弹窗**:打开后焦点直接在第一个输入框;连按 Tab 焦点在弹窗内循环,不会跑到背景页面;`Esc` 关闭弹窗。
|
|
144
|
+
5. **大文件窥视**:打开一个 >2 GB 的本地文件(只读预览):应显示「文件过大,仅显示前 512 KB」而不是报错或长时间卡住;内存不随文件大小增长。
|
|
145
|
+
6. **复制上限**:在远端复制一个 >64 MB 的文本/二进制文件:应提示「超过 64 MB,无法复制」,目标目录不得出现被截断的副本。
|
|
146
|
+
7. **非空目录删除(工具)**:让 agent 用 `linked_workspace` 的 `delete` 删除一个含文件的远端目录:应递归删除成功,不再报 SFTP `Failure`。
|
|
147
|
+
8. **Excel 截断提示**:打开一个 >200 行或 >40 列的 xlsx:表格上方应出现「仅显示前 200 行 × 40 列」;工作表超过 20 个时出现「仅显示前 20 个工作表」。
|
package/lib/client.js
CHANGED
|
@@ -1835,8 +1835,112 @@ function fileIconSpec(name, isDir) {
|
|
|
1835
1835
|
return ICON_spec('unknown')
|
|
1836
1836
|
}
|
|
1837
1837
|
|
|
1838
|
+
// ---- file-creation.js ----
|
|
1839
|
+
// Shared by the directory menu, its submit handler and API regression tests.
|
|
1840
|
+
// Preview formats (CSV/HTML/images) are regular files; only document templates
|
|
1841
|
+
// are sent to docs.create.
|
|
1842
|
+
function newEntryMenuItems(menu, t, onNew) {
|
|
1843
|
+
return [true, false].map(function (isDir) {
|
|
1844
|
+
var title = t(isDir ? 'newFolderTitle' : 'newFileTitle');
|
|
1845
|
+
return {
|
|
1846
|
+
label: t(isDir ? 'ctxNewFolder' : 'ctxNewFile'),
|
|
1847
|
+
onSelect: function () {
|
|
1848
|
+
onNew({ kind: 'new', side: menu.side, dir: menu.path, isDir: isDir,
|
|
1849
|
+
title: title, label: t('nameLabel'), value: title });
|
|
1850
|
+
},
|
|
1851
|
+
};
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function newEntryRequest(side, dirPath, name, isDir, scope) {
|
|
1856
|
+
var child = String(name || '').trim();
|
|
1857
|
+
if (!child || child === '.' || child === '..' || /[/\\\0]/.test(child)) {
|
|
1858
|
+
var error = new Error('文件或文件夹名称无效 / Invalid file or folder name');
|
|
1859
|
+
error.code = 'BAD_REQUEST';
|
|
1860
|
+
throw error;
|
|
1861
|
+
}
|
|
1862
|
+
var parent = String(dirPath || '');
|
|
1863
|
+
var separator = side === 'remote' || parent.indexOf('\\') < 0 ? '/' : '\\';
|
|
1864
|
+
var target = parent + (/[\\/]$/.test(parent) ? '' : separator) + child;
|
|
1865
|
+
var match = child.toLowerCase().match(/\.[^./\\]+$/);
|
|
1866
|
+
var extension = match ? match[0] : '';
|
|
1867
|
+
var kinds = { '.md': 'markdown', '.markdown': 'markdown', '.pdf': 'pdf',
|
|
1868
|
+
'.docx': 'docx', '.xlsx': 'xlsx', '.pptx': 'pptx' };
|
|
1869
|
+
var kind = isDir ? '' : (kinds[extension] || '');
|
|
1870
|
+
var method;
|
|
1871
|
+
var params;
|
|
1872
|
+
if (isDir) {
|
|
1873
|
+
method = side === 'remote' ? 'fs.mkdir' : 'local.mkdir';
|
|
1874
|
+
params = side === 'remote' ? { path: target, createOnly: true } : { path: parent, name: child };
|
|
1875
|
+
} else if (kind) {
|
|
1876
|
+
method = 'docs.create';
|
|
1877
|
+
params = { path: target, kind: kind };
|
|
1878
|
+
} else {
|
|
1879
|
+
method = side === 'remote' ? 'fs.write' : 'local.newFile';
|
|
1880
|
+
params = side === 'remote' ? { path: target, content: '', createOnly: true } : { path: target };
|
|
1881
|
+
}
|
|
1882
|
+
if (side === 'remote') params = Object.assign({}, scope, params);
|
|
1883
|
+
return { method: method, params: params, path: target, kind: kind };
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// ---- url-safety.js ----
|
|
1887
|
+
// URL safety helpers shared by the document sanitizer and its unit tests.
|
|
1888
|
+
//
|
|
1889
|
+
// Browsers strip ASCII control characters, tabs and newlines while parsing a
|
|
1890
|
+
// URL scheme, so `jav	ascript:` reaches the address bar as `javascript:`.
|
|
1891
|
+
// The checks below normalise the value the same way before looking at it.
|
|
1892
|
+
var SAFE_SCHEMES = { http: 1, https: 1, mailto: 1, tel: 1 };
|
|
1893
|
+
var IMAGE_DATA = /^data:image\/(?:png|jpeg|jpg|gif|webp|bmp|avif|x-icon)[;,]/
|
|
1894
|
+
var URL_ATTRIBUTES = {
|
|
1895
|
+
href: 1,
|
|
1896
|
+
src: 1,
|
|
1897
|
+
'xlink:href': 1,
|
|
1898
|
+
action: 1,
|
|
1899
|
+
formaction: 1,
|
|
1900
|
+
data: 1,
|
|
1901
|
+
poster: 1,
|
|
1902
|
+
background: 1,
|
|
1903
|
+
cite: 1,
|
|
1904
|
+
longdesc: 1,
|
|
1905
|
+
usemap: 1,
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
function normalizeUrlValue(value) {
|
|
1909
|
+
return String(value == null ? '' : value)
|
|
1910
|
+
// C0/C1 controls, space, zero-width and line/paragraph separators are what
|
|
1911
|
+
// URL parsers silently drop inside a scheme.
|
|
1912
|
+
.replace(/[\u0000-\u0020\u007f-\u009f\u200b-\u200f\u2028\u2029\ufeff]/g, '')
|
|
1913
|
+
.trim();
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function urlSchemeOf(value) {
|
|
1917
|
+
var text = normalizeUrlValue(value);
|
|
1918
|
+
var match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(text);
|
|
1919
|
+
return match ? match[1].toLowerCase() : '';
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// Returns '' when the attribute is safe to keep, or a short reason when the
|
|
1923
|
+
// sanitizer has to drop it. `tagName` is only needed for data: URLs, which are
|
|
1924
|
+
// allowed for images and rejected everywhere else.
|
|
1925
|
+
function urlAttributeViolation(name, value, tagName) {
|
|
1926
|
+
var attribute = String(name || '').toLowerCase();
|
|
1927
|
+
if (/^on/.test(attribute)) return 'event handler';
|
|
1928
|
+
if (!URL_ATTRIBUTES[attribute]) return '';
|
|
1929
|
+
var text = normalizeUrlValue(value);
|
|
1930
|
+
if (text === '') return '';
|
|
1931
|
+
// Relative paths, fragments and protocol-relative URLs have no scheme.
|
|
1932
|
+
if (text.charAt(0) === '#' || text.slice(0, 2) === '//') return '';
|
|
1933
|
+
var scheme = urlSchemeOf(text);
|
|
1934
|
+
if (scheme === '') return '';
|
|
1935
|
+
if (SAFE_SCHEMES[scheme]) return '';
|
|
1936
|
+
if (scheme === 'data' && String(tagName || '').toLowerCase() === 'img' && IMAGE_DATA.test(text)) return '';
|
|
1937
|
+
return scheme + ': URL';
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1838
1940
|
var TREE_TAB_TYPE = 'dsh-linked-workspace';
|
|
1839
1941
|
var REMOTE_TAB_TYPE = 'dsh-linked-workspace:remote';
|
|
1942
|
+
// One tree page; a folder with 20k entries must not render 20k rows.
|
|
1943
|
+
var TREE_PAGE_SIZE = 500;
|
|
1840
1944
|
var linkedLocalKeys = new Set();
|
|
1841
1945
|
var linkedWorkspaceKeys = new Set();
|
|
1842
1946
|
var hasRemoteDirectories = false;
|
|
@@ -2050,6 +2154,10 @@ function fileIconSpec(name, isDir) {
|
|
|
2050
2154
|
latency: '延迟',
|
|
2051
2155
|
countSuffix: ' 个',
|
|
2052
2156
|
noEntries: '空目录',
|
|
2157
|
+
loadMore: '加载更多(剩余 {{count}} 项)',
|
|
2158
|
+
loadMoreShort: '加载更多',
|
|
2159
|
+
sheetTruncated: '仅显示前 {{rows}} 行 × {{cols}} 列',
|
|
2160
|
+
sheetMore: '工作表较多,仅显示前 20 个',
|
|
2053
2161
|
noCredential: '缺少凭据',
|
|
2054
2162
|
sourceManagerShort: 'SSH',
|
|
2055
2163
|
remoteGroup: '远程目录',
|
|
@@ -2379,6 +2487,10 @@ function fileIconSpec(name, isDir) {
|
|
|
2379
2487
|
latency: 'Latency',
|
|
2380
2488
|
countSuffix: '',
|
|
2381
2489
|
noEntries: 'Empty directory',
|
|
2490
|
+
loadMore: 'Load more ({{count}} left)',
|
|
2491
|
+
loadMoreShort: 'Load more',
|
|
2492
|
+
sheetTruncated: 'Showing the first {{rows}} rows × {{cols}} columns',
|
|
2493
|
+
sheetMore: 'Showing the first 20 worksheets only',
|
|
2382
2494
|
noCredential: 'No credentials',
|
|
2383
2495
|
sourceManagerShort: 'SSH',
|
|
2384
2496
|
remoteGroup: 'Remote directories',
|
|
@@ -2689,6 +2801,7 @@ function fileIconSpec(name, isDir) {
|
|
|
2689
2801
|
FS_IS_DIR: '目标是目录',
|
|
2690
2802
|
FS_NOT_DIR: '目标不是目录',
|
|
2691
2803
|
FS_TOO_LARGE: '文件过大',
|
|
2804
|
+
FS_EXISTS: '目标已存在,请使用其他名称',
|
|
2692
2805
|
CONFLICT: '远端文件已被修改',
|
|
2693
2806
|
BAD_REQUEST: '请求参数无效',
|
|
2694
2807
|
},
|
|
@@ -2703,6 +2816,7 @@ function fileIconSpec(name, isDir) {
|
|
|
2703
2816
|
FS_IS_DIR: 'That path is a directory',
|
|
2704
2817
|
FS_NOT_DIR: 'That path is not a directory',
|
|
2705
2818
|
FS_TOO_LARGE: 'The file is too large',
|
|
2819
|
+
FS_EXISTS: 'The target already exists; choose another name',
|
|
2706
2820
|
CONFLICT: 'The remote file changed',
|
|
2707
2821
|
BAD_REQUEST: 'Invalid request',
|
|
2708
2822
|
},
|
|
@@ -2783,7 +2897,7 @@ function fileIconSpec(name, isDir) {
|
|
|
2783
2897
|
parsed.querySelectorAll('script,style,iframe,object,embed,link,meta,base,form').forEach(function (node) { node.remove(); });
|
|
2784
2898
|
parsed.querySelectorAll('*').forEach(function (element) {
|
|
2785
2899
|
Array.from(element.attributes).forEach(function (attribute) {
|
|
2786
|
-
if (
|
|
2900
|
+
if (urlAttributeViolation(attribute.name, attribute.value, element.tagName)) {
|
|
2787
2901
|
element.removeAttribute(attribute.name);
|
|
2788
2902
|
}
|
|
2789
2903
|
});
|
|
@@ -3049,6 +3163,9 @@ function fileIconSpec(name, isDir) {
|
|
|
3049
3163
|
'.rw-tree-body{padding:2px 4px 6px;border-top:1px solid var(--dsw-alias-border-l1)}',
|
|
3050
3164
|
'.rw-row{display:flex;align-items:center;gap:5px;width:100%;min-width:0;height:23px;padding:0 6px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;box-sizing:border-box}',
|
|
3051
3165
|
'.rw-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
|
|
3166
|
+
'.rw-row:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4176e6));outline-offset:-1px}',
|
|
3167
|
+
'.rw-input:focus-visible,.rw-textarea:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4176e6));outline-offset:-1px}',
|
|
3168
|
+
'.rw-btn:focus-visible,.rw-doc-tab:focus-visible,.rw-icon-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4176e6));outline-offset:1px}',
|
|
3052
3169
|
'.rw-row.is-active{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}',
|
|
3053
3170
|
'.rw-row-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto;min-width:0}',
|
|
3054
3171
|
'.rw-row-size{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
|
|
@@ -3176,6 +3293,10 @@ function fileIconSpec(name, isDir) {
|
|
|
3176
3293
|
'.rw-doc-table td{border:1px solid var(--dsw-alias-border-l2);padding:0}',
|
|
3177
3294
|
'.rw-doc-table input{width:100%;border:0;background:transparent;padding:4px 5px;font:inherit;color:var(--dsw-alias-label-primary);outline:none;box-sizing:border-box}',
|
|
3178
3295
|
'.rw-doc-tabs{display:flex;gap:3px;padding:5px;border-bottom:1px solid var(--dsw-alias-border-l1);overflow:auto;flex:none}',
|
|
3296
|
+
'.rw-doc-note{padding:3px 10px;font-size:11px;color:var(--dsw-alias-label-tertiary);border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
3297
|
+
'.rw-load-more{width:100%;border:0;background:transparent;cursor:pointer;text-align:left;font:inherit;font-size:11.5px;color:var(--dsw-alias-brand-primary)}',
|
|
3298
|
+
'.rw-load-more:hover{text-decoration:underline}',
|
|
3299
|
+
'.rw-load-more:disabled{opacity:.6;cursor:default}',
|
|
3179
3300
|
'.rw-doc-tab{padding:2px 8px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;cursor:pointer;white-space:nowrap}',
|
|
3180
3301
|
'.rw-doc-tab.is-on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}',
|
|
3181
3302
|
'.rw-doc-grid{width:100%;border-collapse:collapse;font-size:11.5px}',
|
|
@@ -4507,6 +4628,19 @@ function fileIconSpec(name, isDir) {
|
|
|
4507
4628
|
});
|
|
4508
4629
|
}
|
|
4509
4630
|
|
|
4631
|
+
function listRequest(path, offset) {
|
|
4632
|
+
var params = { path: path, limit: TREE_PAGE_SIZE, offset: offset };
|
|
4633
|
+
return props.kind === 'local'
|
|
4634
|
+
? apiCall('local.list', params)
|
|
4635
|
+
: apiCall('fs.list', Object.assign(params, scopeParams(props.scope)));
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
function applyEntries(list) {
|
|
4639
|
+
return props.showHidden ? list : list.filter(function (entry) {
|
|
4640
|
+
return String(entry.name || '').charAt(0) !== '.';
|
|
4641
|
+
});
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4510
4644
|
function load(path, silent) {
|
|
4511
4645
|
if (pending.current.has(path)) return;
|
|
4512
4646
|
pending.current.add(path);
|
|
@@ -4516,18 +4650,18 @@ function fileIconSpec(name, isDir) {
|
|
|
4516
4650
|
entries.set(path, { loading: !silent, error: null, entries: existing.entries || [], loaded: existing.loaded });
|
|
4517
4651
|
return { entries: entries };
|
|
4518
4652
|
});
|
|
4519
|
-
|
|
4520
|
-
? apiCall('local.list', { path: path })
|
|
4521
|
-
: apiCall('fs.list', Object.assign({ path: path }, scopeParams(props.scope)));
|
|
4522
|
-
request.then(function (result) {
|
|
4653
|
+
listRequest(path, 0).then(function (result) {
|
|
4523
4654
|
pending.current.delete(path);
|
|
4524
|
-
var entries = result.entries || [];
|
|
4525
|
-
if (!props.showHidden) {
|
|
4526
|
-
entries = entries.filter(function (entry) { return String(entry.name || '').charAt(0) !== '.'; });
|
|
4527
|
-
}
|
|
4528
4655
|
setCache(function (previous) {
|
|
4529
4656
|
var next = new Map(previous.entries);
|
|
4530
|
-
next.set(path, {
|
|
4657
|
+
next.set(path, {
|
|
4658
|
+
loading: false,
|
|
4659
|
+
error: null,
|
|
4660
|
+
loaded: true,
|
|
4661
|
+
entries: applyEntries(result.entries || []),
|
|
4662
|
+
total: Number(result.total) || (result.entries || []).length,
|
|
4663
|
+
hasMore: Boolean(result.hasMore),
|
|
4664
|
+
});
|
|
4531
4665
|
return { entries: next };
|
|
4532
4666
|
});
|
|
4533
4667
|
setExpanded(function (previous) {
|
|
@@ -4545,7 +4679,39 @@ function fileIconSpec(name, isDir) {
|
|
|
4545
4679
|
});
|
|
4546
4680
|
}
|
|
4547
4681
|
|
|
4548
|
-
|
|
4682
|
+
function loadMore(path) {
|
|
4683
|
+
var record = cache.entries.get(path);
|
|
4684
|
+
if (!record || record.loadingMore || !record.hasMore) return;
|
|
4685
|
+
setCache(function (previous) {
|
|
4686
|
+
var next = new Map(previous.entries);
|
|
4687
|
+
var current = next.get(path);
|
|
4688
|
+
if (current) next.set(path, Object.assign({}, current, { loadingMore: true }));
|
|
4689
|
+
return { entries: next };
|
|
4690
|
+
});
|
|
4691
|
+
listRequest(path, (record.entries || []).length).then(function (result) {
|
|
4692
|
+
setCache(function (previous) {
|
|
4693
|
+
var next = new Map(previous.entries);
|
|
4694
|
+
var current = next.get(path) || { entries: [] };
|
|
4695
|
+
next.set(path, Object.assign({}, current, {
|
|
4696
|
+
loadingMore: false,
|
|
4697
|
+
loaded: true,
|
|
4698
|
+
entries: current.entries.concat(applyEntries(result.entries || [])),
|
|
4699
|
+
total: Number(result.total) || current.total,
|
|
4700
|
+
hasMore: Boolean(result.hasMore),
|
|
4701
|
+
}));
|
|
4702
|
+
return { entries: next };
|
|
4703
|
+
});
|
|
4704
|
+
}).catch(function (error) {
|
|
4705
|
+
setCache(function (previous) {
|
|
4706
|
+
var next = new Map(previous.entries);
|
|
4707
|
+
var current = next.get(path);
|
|
4708
|
+
if (current) next.set(path, Object.assign({}, current, { loadingMore: false, moreError: error }));
|
|
4709
|
+
return { entries: next };
|
|
4710
|
+
});
|
|
4711
|
+
});
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4714
|
+
return h('div', { className: 'rw-tree', role: 'tree', 'aria-label': t('allFiles') }, h(TreeDirectory, {
|
|
4549
4715
|
path: rootPath,
|
|
4550
4716
|
name: props.rootName || t('allFiles'),
|
|
4551
4717
|
depth: 0,
|
|
@@ -4556,12 +4722,13 @@ function fileIconSpec(name, isDir) {
|
|
|
4556
4722
|
toggle: toggle,
|
|
4557
4723
|
state: { entries: cache.entries },
|
|
4558
4724
|
load: load,
|
|
4725
|
+
loadMore: loadMore,
|
|
4559
4726
|
onSelect: props.onSelect,
|
|
4560
4727
|
onPin: props.onPin,
|
|
4561
4728
|
onSelectPath: props.onSelectPath,
|
|
4562
4729
|
onContextMenu: props.onContextMenu,
|
|
4563
4730
|
selectedPath: props.selectedPath || '',
|
|
4564
|
-
});
|
|
4731
|
+
}));
|
|
4565
4732
|
}
|
|
4566
4733
|
|
|
4567
4734
|
function TreeSection(props) {
|
|
@@ -4883,20 +5050,46 @@ function fileIconSpec(name, isDir) {
|
|
|
4883
5050
|
var state = useState(props.value || '');
|
|
4884
5051
|
var value = state[0];
|
|
4885
5052
|
var setValue = state[1];
|
|
4886
|
-
|
|
4887
|
-
|
|
5053
|
+
var statusState = useState({ busy: false, error: '' });
|
|
5054
|
+
var status = statusState[0];
|
|
5055
|
+
var setStatus = statusState[1];
|
|
5056
|
+
var pendingRef = useRef(false);
|
|
5057
|
+
var mountedRef = useRef(true);
|
|
5058
|
+
useEffect(function () {
|
|
5059
|
+
mountedRef.current = true;
|
|
5060
|
+
return function () { mountedRef.current = false; };
|
|
5061
|
+
}, []);
|
|
5062
|
+
function close() { if (!pendingRef.current) props.onClose(); }
|
|
5063
|
+
function submit() {
|
|
5064
|
+
if (value.trim() === '' || pendingRef.current) return;
|
|
5065
|
+
pendingRef.current = true;
|
|
5066
|
+
setStatus({ busy: true, error: '' });
|
|
5067
|
+
Promise.resolve().then(function () { return props.onSubmit(value.trim()); }).then(function () {
|
|
5068
|
+
pendingRef.current = false;
|
|
5069
|
+
if (mountedRef.current) setStatus({ busy: false, error: '' });
|
|
5070
|
+
}).catch(function (error) {
|
|
5071
|
+
pendingRef.current = false;
|
|
5072
|
+
if (mountedRef.current) setStatus({ busy: false, error: messageOf(error, props.t, props.locale) });
|
|
5073
|
+
});
|
|
5074
|
+
}
|
|
5075
|
+
return h(DialogShell, { title: props.title, onClose: close },
|
|
4888
5076
|
h('label', { className: 'rw-dialog-label' }, props.label),
|
|
4889
5077
|
h('input', {
|
|
4890
5078
|
className: 'rw-dialog-input',
|
|
4891
5079
|
value: value,
|
|
5080
|
+
disabled: status.busy,
|
|
4892
5081
|
autoFocus: true,
|
|
4893
5082
|
spellCheck: false,
|
|
4894
5083
|
onChange: function (event) { setValue(event.currentTarget.value); },
|
|
4895
|
-
onKeyDown: function (event) {
|
|
5084
|
+
onKeyDown: function (event) {
|
|
5085
|
+
if (event.key === 'Enter' && (!event.nativeEvent || !event.nativeEvent.isComposing)) { event.preventDefault(); submit(); }
|
|
5086
|
+
else if (event.key === 'Escape') close();
|
|
5087
|
+
},
|
|
4896
5088
|
}),
|
|
5089
|
+
status.error ? h(Banner, { kind: 'error' }, status.error) : null,
|
|
4897
5090
|
h('div', { className: 'rw-dialog-actions' },
|
|
4898
|
-
h('button', { type: 'button', className: 'rw-btn rw-btn-sm',
|
|
4899
|
-
h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: submit }, props.t('confirm'))));
|
|
5091
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: status.busy, onClick: close }, props.t('cancel')),
|
|
5092
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', disabled: status.busy || value.trim() === '', onClick: submit }, props.t(status.busy ? 'loading' : 'confirm'))));
|
|
4900
5093
|
}
|
|
4901
5094
|
|
|
4902
5095
|
function ConfirmDialog(props) {
|
|
@@ -5225,6 +5418,12 @@ function fileIconSpec(name, isDir) {
|
|
|
5225
5418
|
h('div', { className: 'rw-doc-tabs' }, sheets.map(function (sheet, index) {
|
|
5226
5419
|
return h('button', { key: sheet.name, type: 'button', className: 'rw-doc-tab' + (index === state.sheet ? ' is-on' : ''), onClick: function () { patch({ sheet: index }); } }, sheet.name);
|
|
5227
5420
|
})),
|
|
5421
|
+
active.truncated
|
|
5422
|
+
? h('div', { className: 'rw-doc-note' }, t('sheetTruncated', { rows: active.maxRow || 0, cols: active.maxCol || 0 }))
|
|
5423
|
+
: null,
|
|
5424
|
+
(state.data.sheetCount || sheets.length) > sheets.length
|
|
5425
|
+
? h('div', { className: 'rw-doc-note' }, t('sheetMore'))
|
|
5426
|
+
: null,
|
|
5228
5427
|
h('div', { style: { flex: '1 1 auto', overflow: 'auto', padding: 8 } },
|
|
5229
5428
|
h('table', { className: 'rw-doc-grid' },
|
|
5230
5429
|
h('thead', null, header),
|
|
@@ -5814,27 +6013,13 @@ function fileIconSpec(name, isDir) {
|
|
|
5814
6013
|
}
|
|
5815
6014
|
|
|
5816
6015
|
function submitNew(side, dirPath, name, isDir) {
|
|
5817
|
-
var
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
if (isDir) {
|
|
5821
|
-
promise = side === 'remote'
|
|
5822
|
-
? apiCall('fs.mkdir', Object.assign({ path: target }, scopeParams(scopeRef)))
|
|
5823
|
-
: apiCall('local.mkdir', { path: dirPath, name: name });
|
|
5824
|
-
} else if (docKind) {
|
|
5825
|
-
promise = side === 'remote'
|
|
5826
|
-
? apiCall('docs.create', Object.assign({ path: target, kind: docKind }, scopeParams(scopeRef)))
|
|
5827
|
-
: apiCall('docs.create', { path: target, kind: docKind });
|
|
5828
|
-
} else {
|
|
5829
|
-
promise = side === 'remote'
|
|
5830
|
-
? apiCall('fs.write', Object.assign({ path: target, content: '' }, scopeParams(scopeRef)))
|
|
5831
|
-
: apiCall('local.newFile', { path: target });
|
|
5832
|
-
}
|
|
5833
|
-
promise.then(function () {
|
|
6016
|
+
var request = newEntryRequest(side, dirPath, name, isDir, scopeParams(scopeRef));
|
|
6017
|
+
return apiCall(request.method, request.params).then(function () {
|
|
6018
|
+
setDialog(null);
|
|
5834
6019
|
showNotice('success', t('newDone'));
|
|
5835
6020
|
refreshTree();
|
|
5836
|
-
if (!isDir &&
|
|
5837
|
-
})
|
|
6021
|
+
if (!isDir && request.kind) openInternalFile(side, request.path);
|
|
6022
|
+
});
|
|
5838
6023
|
}
|
|
5839
6024
|
|
|
5840
6025
|
function submitChmod(side, targetPath, mode) {
|
|
@@ -5849,9 +6034,9 @@ function fileIconSpec(name, isDir) {
|
|
|
5849
6034
|
}
|
|
5850
6035
|
|
|
5851
6036
|
function submitDialog(current, value) {
|
|
6037
|
+
if (current.kind === 'new') return submitNew(current.side, current.dir, value, current.isDir);
|
|
5852
6038
|
setDialog(null);
|
|
5853
6039
|
if (current.kind === 'rename') submitRenamed(current.side, current.path, value);
|
|
5854
|
-
else if (current.kind === 'new') submitNew(current.side, current.dir, value, current.isDir);
|
|
5855
6040
|
else if (current.kind === 'chmod') submitChmod(current.side, current.path, value);
|
|
5856
6041
|
}
|
|
5857
6042
|
|
|
@@ -5911,14 +6096,8 @@ function fileIconSpec(name, isDir) {
|
|
|
5911
6096
|
setDialog({ kind: 'chmod', side: menu.side, path: menu.path, title: t('chmodTitle'), label: t('chmodLabel'), value: '644' });
|
|
5912
6097
|
} });
|
|
5913
6098
|
if (menu.isDir) {
|
|
5914
|
-
items.push({
|
|
5915
|
-
|
|
5916
|
-
setDialog({ kind: 'new', side: menu.side, dir: menu.path, isDir: true, title: t('newFolderTitle'), label: t('nameLabel'), value: t('newFolderTitle') });
|
|
5917
|
-
} },
|
|
5918
|
-
{ label: t('ctxNewFile'), onSelect: function () {
|
|
5919
|
-
setDialog({ kind: 'new', side: menu.side, dir: menu.path, isDir: false, title: t('newFileTitle'), label: t('nameLabel'), value: t('newFileTitle') });
|
|
5920
|
-
} },
|
|
5921
|
-
] });
|
|
6099
|
+
items.push({ separator: true });
|
|
6100
|
+
items = items.concat(newEntryMenuItems(menu, t, setDialog));
|
|
5922
6101
|
}
|
|
5923
6102
|
items.push({ separator: true });
|
|
5924
6103
|
items.push({ label: t('ctxProperties'), onSelect: function () { showProperties(menu.side, menu.path); } });
|
|
@@ -5979,11 +6158,12 @@ function fileIconSpec(name, isDir) {
|
|
|
5979
6158
|
}
|
|
5980
6159
|
return h(PromptDialog, {
|
|
5981
6160
|
t: t,
|
|
6161
|
+
locale: locale,
|
|
5982
6162
|
title: dialog.title,
|
|
5983
6163
|
label: dialog.label,
|
|
5984
6164
|
value: dialog.value,
|
|
5985
6165
|
onClose: function () { setDialog(null); },
|
|
5986
|
-
onSubmit: function (value) { submitDialog(dialog, value); },
|
|
6166
|
+
onSubmit: function (value) { return submitDialog(dialog, value); },
|
|
5987
6167
|
});
|
|
5988
6168
|
}
|
|
5989
6169
|
|
|
@@ -6348,6 +6528,7 @@ function fileIconSpec(name, isDir) {
|
|
|
6348
6528
|
toggle: toggle,
|
|
6349
6529
|
state: state,
|
|
6350
6530
|
load: props.load,
|
|
6531
|
+
loadMore: props.loadMore,
|
|
6351
6532
|
onSelect: onSelect,
|
|
6352
6533
|
onPin: onPin,
|
|
6353
6534
|
onSelectPath: onSelectPath,
|
|
@@ -6359,6 +6540,9 @@ function fileIconSpec(name, isDir) {
|
|
|
6359
6540
|
children.push(h('button', {
|
|
6360
6541
|
key: child.path,
|
|
6361
6542
|
type: 'button',
|
|
6543
|
+
role: 'treeitem',
|
|
6544
|
+
'aria-level': depth + 1,
|
|
6545
|
+
'aria-selected': active,
|
|
6362
6546
|
className: 'rw-row' + (active ? ' is-active' : ''),
|
|
6363
6547
|
style: { paddingLeft: 6 + (depth + 1) * 12 },
|
|
6364
6548
|
title: child.path,
|
|
@@ -6380,12 +6564,28 @@ function fileIconSpec(name, isDir) {
|
|
|
6380
6564
|
h('span', { className: 'rw-row-size' }, formatBytes(child.size))));
|
|
6381
6565
|
}
|
|
6382
6566
|
});
|
|
6567
|
+
if (entry.hasMore) {
|
|
6568
|
+
children.push(h('button', {
|
|
6569
|
+
key: 'more',
|
|
6570
|
+
type: 'button',
|
|
6571
|
+
className: 'rw-row-msg rw-load-more',
|
|
6572
|
+
style: { paddingLeft: 6 + (depth + 1) * 12 },
|
|
6573
|
+
disabled: Boolean(entry.loadingMore),
|
|
6574
|
+
onClick: function () { if (props.loadMore) props.loadMore(path); },
|
|
6575
|
+
}, entry.loadingMore ? Spinner(12) : null,
|
|
6576
|
+
entry.moreError
|
|
6577
|
+
? messageOf(entry.moreError, t, locale)
|
|
6578
|
+
: t('loadMore', { count: Math.max((entry.total || 0) - entry.entries.length, 0) })));
|
|
6579
|
+
}
|
|
6383
6580
|
}
|
|
6384
6581
|
}
|
|
6385
6582
|
|
|
6386
6583
|
return h(React.Fragment, null,
|
|
6387
6584
|
h('button', {
|
|
6388
6585
|
type: 'button',
|
|
6586
|
+
role: 'treeitem',
|
|
6587
|
+
'aria-level': depth + 1,
|
|
6588
|
+
'aria-expanded': isOpen,
|
|
6389
6589
|
className: 'rw-row' + (selectedPath === path ? ' is-active' : ''),
|
|
6390
6590
|
style: { paddingLeft: 6 + depth * 12 },
|
|
6391
6591
|
title: path,
|
|
@@ -6393,13 +6593,16 @@ function fileIconSpec(name, isDir) {
|
|
|
6393
6593
|
if (onSelectPath) onSelectPath({ path: path, name: name, isDir: true });
|
|
6394
6594
|
if (event.detail !== 2) toggle(path);
|
|
6395
6595
|
},
|
|
6596
|
+
onKeyDown: function (event) {
|
|
6597
|
+
if (event.key === 'ArrowRight' && !isOpen) { event.preventDefault(); toggle(path); }
|
|
6598
|
+
else if (event.key === 'ArrowLeft' && isOpen) { event.preventDefault(); toggle(path); }
|
|
6599
|
+
},
|
|
6396
6600
|
onContextMenu: function (event) {
|
|
6397
6601
|
if (!onContextMenu) return;
|
|
6398
6602
|
event.preventDefault();
|
|
6399
6603
|
event.stopPropagation();
|
|
6400
6604
|
onContextMenu({ path: path, name: name, isDir: true, event: event });
|
|
6401
6605
|
},
|
|
6402
|
-
'aria-expanded': isOpen,
|
|
6403
6606
|
},
|
|
6404
6607
|
h('span', { className: 'rw-chevron' + (isOpen ? ' is-open' : '') }, IconChevron(12)),
|
|
6405
6608
|
h(FileGlyph, { name: name, dir: true, open: isOpen, size: 13 }),
|
|
@@ -6430,6 +6633,11 @@ function fileIconSpec(name, isDir) {
|
|
|
6430
6633
|
});
|
|
6431
6634
|
}
|
|
6432
6635
|
|
|
6636
|
+
function listRequest(path, offset) {
|
|
6637
|
+
return Object.assign({ path: path, limit: TREE_PAGE_SIZE, offset: offset },
|
|
6638
|
+
source.kind === 'linked' ? { linkedId: source.id } : { workspaceId: source.id });
|
|
6639
|
+
}
|
|
6640
|
+
|
|
6433
6641
|
function load(path) {
|
|
6434
6642
|
setCache(function (previous) {
|
|
6435
6643
|
var record = previous.entries.get(path);
|
|
@@ -6439,13 +6647,17 @@ function fileIconSpec(name, isDir) {
|
|
|
6439
6647
|
entries.set(path, { loading: true, error: null, entries: existing.entries || [], loaded: existing.loaded });
|
|
6440
6648
|
return { entries: entries };
|
|
6441
6649
|
});
|
|
6442
|
-
|
|
6443
|
-
? { linkedId: source.id, path: path }
|
|
6444
|
-
: { workspaceId: source.id, path: path };
|
|
6445
|
-
apiCall('fs.list', request).then(function (result) {
|
|
6650
|
+
apiCall('fs.list', listRequest(path, 0)).then(function (result) {
|
|
6446
6651
|
setCache(function (previous) {
|
|
6447
6652
|
var entries = new Map(previous.entries);
|
|
6448
|
-
entries.set(path, {
|
|
6653
|
+
entries.set(path, {
|
|
6654
|
+
loading: false,
|
|
6655
|
+
error: null,
|
|
6656
|
+
loaded: true,
|
|
6657
|
+
entries: result.entries || [],
|
|
6658
|
+
total: Number(result.total) || (result.entries || []).length,
|
|
6659
|
+
hasMore: Boolean(result.hasMore),
|
|
6660
|
+
});
|
|
6449
6661
|
return { entries: entries };
|
|
6450
6662
|
});
|
|
6451
6663
|
setExpanded(function (previous) {
|
|
@@ -6462,6 +6674,38 @@ function fileIconSpec(name, isDir) {
|
|
|
6462
6674
|
});
|
|
6463
6675
|
}
|
|
6464
6676
|
|
|
6677
|
+
function loadMore(path) {
|
|
6678
|
+
var record = cache.entries.get(path);
|
|
6679
|
+
if (!record || record.loadingMore || !record.hasMore) return;
|
|
6680
|
+
setCache(function (previous) {
|
|
6681
|
+
var entries = new Map(previous.entries);
|
|
6682
|
+
var current = entries.get(path);
|
|
6683
|
+
if (current) entries.set(path, Object.assign({}, current, { loadingMore: true }));
|
|
6684
|
+
return { entries: entries };
|
|
6685
|
+
});
|
|
6686
|
+
apiCall('fs.list', listRequest(path, (record.entries || []).length)).then(function (result) {
|
|
6687
|
+
setCache(function (previous) {
|
|
6688
|
+
var entries = new Map(previous.entries);
|
|
6689
|
+
var current = entries.get(path) || { entries: [] };
|
|
6690
|
+
entries.set(path, Object.assign({}, current, {
|
|
6691
|
+
loadingMore: false,
|
|
6692
|
+
loaded: true,
|
|
6693
|
+
entries: current.entries.concat(result.entries || []),
|
|
6694
|
+
total: Number(result.total) || current.total,
|
|
6695
|
+
hasMore: Boolean(result.hasMore),
|
|
6696
|
+
}));
|
|
6697
|
+
return { entries: entries };
|
|
6698
|
+
});
|
|
6699
|
+
}).catch(function (error) {
|
|
6700
|
+
setCache(function (previous) {
|
|
6701
|
+
var entries = new Map(previous.entries);
|
|
6702
|
+
var current = entries.get(path);
|
|
6703
|
+
if (current) entries.set(path, Object.assign({}, current, { loadingMore: false, moreError: error }));
|
|
6704
|
+
return { entries: entries };
|
|
6705
|
+
});
|
|
6706
|
+
});
|
|
6707
|
+
}
|
|
6708
|
+
|
|
6465
6709
|
useEffect(function () {
|
|
6466
6710
|
if (!open) return;
|
|
6467
6711
|
var root = source.remotePath;
|
|
@@ -6490,7 +6734,7 @@ function fileIconSpec(name, isDir) {
|
|
|
6490
6734
|
h('span', { className: 'rw-tree-ws-sub' }, source.username + '@' + source.host + (source.kind === 'linked' ? ' · ' + t('linkedChip') : ''))),
|
|
6491
6735
|
h(StatusPill, { status: source.status, t: t })),
|
|
6492
6736
|
open
|
|
6493
|
-
? h('div', { className: 'rw-tree-body' },
|
|
6737
|
+
? h('div', { className: 'rw-tree-body', role: 'tree', 'aria-label': source.name },
|
|
6494
6738
|
h(TreeDirectory, {
|
|
6495
6739
|
path: source.remotePath,
|
|
6496
6740
|
name: '/',
|
|
@@ -6502,6 +6746,7 @@ function fileIconSpec(name, isDir) {
|
|
|
6502
6746
|
toggle: toggle,
|
|
6503
6747
|
state: { entries: cache.entries },
|
|
6504
6748
|
load: load,
|
|
6749
|
+
loadMore: loadMore,
|
|
6505
6750
|
onSelect: props.onSelect,
|
|
6506
6751
|
selectedPath: props.selectedPath,
|
|
6507
6752
|
}))
|
|
@@ -6778,10 +7023,58 @@ function fileIconSpec(name, isDir) {
|
|
|
6778
7023
|
});
|
|
6779
7024
|
}
|
|
6780
7025
|
|
|
7026
|
+
var modalRef = useRef(null);
|
|
7027
|
+
var focusablesOf = function () {
|
|
7028
|
+
var node = modalRef.current;
|
|
7029
|
+
if (!node) return [];
|
|
7030
|
+
return Array.prototype.filter.call(
|
|
7031
|
+
node.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'),
|
|
7032
|
+
function (element) { return !element.disabled && element.offsetParent !== null; });
|
|
7033
|
+
};
|
|
7034
|
+
|
|
7035
|
+
useEffect(function () {
|
|
7036
|
+
if (!props.open) return;
|
|
7037
|
+
var node = modalRef.current;
|
|
7038
|
+
if (!node) return;
|
|
7039
|
+
// Focus the first real field, not the tab strip, so typing works at once.
|
|
7040
|
+
var target = node.querySelector('.rw-modal-body input, .rw-modal-body textarea') || focusablesOf()[0];
|
|
7041
|
+
if (target && typeof target.focus === 'function') target.focus();
|
|
7042
|
+
}, [props.open, flow]);
|
|
7043
|
+
|
|
7044
|
+
function onModalKeyDown(event) {
|
|
7045
|
+
if (event.key === 'Escape') {
|
|
7046
|
+
event.preventDefault();
|
|
7047
|
+
event.stopPropagation();
|
|
7048
|
+
if (props.onCancel) props.onCancel();
|
|
7049
|
+
return;
|
|
7050
|
+
}
|
|
7051
|
+
if (event.key !== 'Tab') return;
|
|
7052
|
+
// Keep Tab inside the dialog: without this the focus escapes to the
|
|
7053
|
+
// page behind the backdrop.
|
|
7054
|
+
var focusables = focusablesOf();
|
|
7055
|
+
if (focusables.length === 0) return;
|
|
7056
|
+
var first = focusables[0];
|
|
7057
|
+
var last = focusables[focusables.length - 1];
|
|
7058
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
7059
|
+
event.preventDefault();
|
|
7060
|
+
last.focus();
|
|
7061
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
7062
|
+
event.preventDefault();
|
|
7063
|
+
first.focus();
|
|
7064
|
+
}
|
|
7065
|
+
}
|
|
7066
|
+
|
|
6781
7067
|
if (!props.open) return null;
|
|
6782
7068
|
|
|
6783
7069
|
return h('div', { className: 'rw-modal-backdrop', role: 'presentation' },
|
|
6784
|
-
h('div', {
|
|
7070
|
+
h('div', {
|
|
7071
|
+
ref: modalRef,
|
|
7072
|
+
className: 'rw-modal',
|
|
7073
|
+
role: 'dialog',
|
|
7074
|
+
'aria-modal': 'true',
|
|
7075
|
+
'aria-label': t('flowTitle'),
|
|
7076
|
+
onKeyDown: onModalKeyDown,
|
|
7077
|
+
},
|
|
6785
7078
|
h('div', { className: 'rw-modal-head' },
|
|
6786
7079
|
h('span', { className: 'rw-icon-chip' }, IconLink(16)),
|
|
6787
7080
|
h('span', { className: 'rw-modal-title' }, t('flowTitle')),
|
package/lib/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* addresses and workspace paths.
|
|
17
17
|
*/
|
|
18
18
|
import { randomUUID } from 'node:crypto'
|
|
19
|
-
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
20
|
import { spawn } from 'node:child_process'
|
|
21
21
|
import { mkdir, readdir, stat as fsStat } from 'node:fs/promises'
|
|
22
22
|
import { homedir, tmpdir } from 'node:os'
|
|
@@ -61,6 +61,7 @@ const LINKED_SYNC_BYTES = 50 * 1024 * 1024
|
|
|
61
61
|
const LINKED_SYNC_FILE = 20 * 1024 * 1024
|
|
62
62
|
const LINKED_SYNC_EXCLUDES = new Set(['.git', 'node_modules', '__pycache__', '.conda', '.venv', '.mypy_cache', '.pytest_cache', '.ipynb_checkpoints', '.ruff_cache'])
|
|
63
63
|
const LINKED_SYNC_TIMEOUT_MS = 30_000
|
|
64
|
+
const LINKED_SYNC_MAX_DEPTH = 24
|
|
64
65
|
const LINKED_OPEN_FILE_CAP = 64 * 1024 * 1024
|
|
65
66
|
const MAX_BODY_BYTES = 16 * 1024 * 1024
|
|
66
67
|
const EDITABLE_LIMIT = 2 * 1024 * 1024
|
|
@@ -82,6 +83,17 @@ const IMAGE_MIME = Object.freeze({
|
|
|
82
83
|
const MAX_NAME_LENGTH = 120
|
|
83
84
|
const CONNECT_TIMEOUT_MS = 20_000
|
|
84
85
|
const OP_TIMEOUT_MS = 60_000
|
|
86
|
+
// Directory listings are paged so a folder with tens of thousands of entries
|
|
87
|
+
// cannot stall the Host or flood the renderer.
|
|
88
|
+
const LIST_PAGE_SIZE = 500
|
|
89
|
+
const LIST_PAGE_MAX = 5000
|
|
90
|
+
|
|
91
|
+
/** Clamp a requested page window to the listing limits. */
|
|
92
|
+
const pageWindow = (params = {}, total = 0) => {
|
|
93
|
+
const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
|
|
94
|
+
const offset = Math.min(Math.max(Number(params.offset) || 0, 0), Math.max(total, 0))
|
|
95
|
+
return { limit, offset }
|
|
96
|
+
}
|
|
85
97
|
|
|
86
98
|
const LOG_PREFIX = '[dsh-linked-workspace]'
|
|
87
99
|
|
|
@@ -505,10 +517,31 @@ function mapLocalFsError(error, fallback = 'INTERNAL') {
|
|
|
505
517
|
if (code === 'ENOENT') return new ApiError('FS_NOT_FOUND', '本地路径不存在')
|
|
506
518
|
if (code === 'EACCES' || code === 'EPERM') return new ApiError('FS_PERMISSION', '没有权限访问本地路径')
|
|
507
519
|
if (code === 'ENOTDIR') return new ApiError('FS_NOT_DIR', '本地路径不是目录')
|
|
508
|
-
if (code === 'EEXIST') return new ApiError('
|
|
520
|
+
if (code === 'EEXIST') return new ApiError('FS_EXISTS', '同名文件或文件夹已存在')
|
|
509
521
|
return new ApiError(fallback, error && error.message ? error.message : String(error))
|
|
510
522
|
}
|
|
511
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Read at most `maxBytes` from the start of a local file.
|
|
526
|
+
* Unlike readFileSync this never maps the whole file, so a peek at a huge
|
|
527
|
+
* (or larger-than-2-GiB) file stays cheap.
|
|
528
|
+
*/
|
|
529
|
+
function readHeadSync(absolute, maxBytes) {
|
|
530
|
+
const fd = openSync(absolute, 'r')
|
|
531
|
+
try {
|
|
532
|
+
const buffer = Buffer.allocUnsafe(Math.max(1, Math.min(maxBytes, 16 * 1024 * 1024)))
|
|
533
|
+
let filled = 0
|
|
534
|
+
while (filled < buffer.length) {
|
|
535
|
+
const read = readSync(fd, buffer, filled, buffer.length - filled, filled)
|
|
536
|
+
if (read <= 0) break
|
|
537
|
+
filled += read
|
|
538
|
+
}
|
|
539
|
+
return buffer.subarray(0, filled)
|
|
540
|
+
} finally {
|
|
541
|
+
closeSync(fd)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
512
545
|
function localDrives() {
|
|
513
546
|
if (process.platform !== 'win32') return []
|
|
514
547
|
const drives = []
|
|
@@ -670,6 +703,7 @@ class HostPool {
|
|
|
670
703
|
this.knownHostsFile = knownHostsFile
|
|
671
704
|
this.records = new Map() // hostId -> HostRecord
|
|
672
705
|
this.connections = new Map() // hostId -> { authority, remote: RemoteHost }
|
|
706
|
+
this.connecting = new Map() // hostId -> in-flight connect promise
|
|
673
707
|
this.statuses = new Map() // hostId -> { state, message?, latencyMs?, checkedAt }
|
|
674
708
|
}
|
|
675
709
|
|
|
@@ -703,8 +737,8 @@ class HostPool {
|
|
|
703
737
|
}
|
|
704
738
|
|
|
705
739
|
async remote(hostRecord) {
|
|
706
|
-
const existing = this.connections.get(hostRecord.id)
|
|
707
740
|
const authority = `${hostRecord.host}:${hostRecord.port}`
|
|
741
|
+
const existing = this.connections.get(hostRecord.id)
|
|
708
742
|
if (existing && existing.authority === authority) {
|
|
709
743
|
try {
|
|
710
744
|
await existing.remote.ensureConnected()
|
|
@@ -715,15 +749,33 @@ class HostPool {
|
|
|
715
749
|
throw error
|
|
716
750
|
}
|
|
717
751
|
}
|
|
752
|
+
// Two concurrent first requests for one host must share one connection;
|
|
753
|
+
// otherwise the second RemoteHost overwrites the map entry and the first
|
|
754
|
+
// SSH socket leaks.
|
|
755
|
+
const pending = this.connecting.get(hostRecord.id)
|
|
756
|
+
if (pending) return pending
|
|
718
757
|
const remote = new RemoteHost({
|
|
719
758
|
host: hostRecord,
|
|
720
759
|
knownHostsFile: this.knownHostsFile,
|
|
721
760
|
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
722
761
|
logger: log,
|
|
723
762
|
})
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
763
|
+
const entry = { authority, remote }
|
|
764
|
+
this.connections.set(hostRecord.id, entry)
|
|
765
|
+
const attempt = (async () => {
|
|
766
|
+
try {
|
|
767
|
+
await remote.connect()
|
|
768
|
+
return remote
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (this.connections.get(hostRecord.id) === entry) this.connections.delete(hostRecord.id)
|
|
771
|
+
remote.dispose().catch(() => {})
|
|
772
|
+
throw error
|
|
773
|
+
} finally {
|
|
774
|
+
this.connecting.delete(hostRecord.id)
|
|
775
|
+
}
|
|
776
|
+
})()
|
|
777
|
+
this.connecting.set(hostRecord.id, attempt)
|
|
778
|
+
return attempt
|
|
727
779
|
}
|
|
728
780
|
|
|
729
781
|
async disposeAll() {
|
|
@@ -1107,8 +1159,11 @@ export function apply(ctx) {
|
|
|
1107
1159
|
const seen = new Set()
|
|
1108
1160
|
const shouldStop = () => job.cancel || job.files >= job.maxFiles || job.bytes >= job.maxBytes
|
|
1109
1161
|
const visit = async (directory, depth) => {
|
|
1110
|
-
|
|
1111
|
-
|
|
1162
|
+
// Hitting the depth limit makes the scan incomplete, exactly like the
|
|
1163
|
+
// file/byte caps: flag it so the removal pass is skipped and the UI
|
|
1164
|
+
// can say the archive is partial.
|
|
1165
|
+
if (depth > LINKED_SYNC_MAX_DEPTH || shouldStop()) {
|
|
1166
|
+
if (!job.cancel) job.truncated = true
|
|
1112
1167
|
return
|
|
1113
1168
|
}
|
|
1114
1169
|
let listing
|
|
@@ -1325,7 +1380,15 @@ export function apply(ctx) {
|
|
|
1325
1380
|
const remoteCopyRecursive = async (remote, source, target) => {
|
|
1326
1381
|
const stat = await remote.stat(source)
|
|
1327
1382
|
if (stat.type !== 'dir') {
|
|
1383
|
+
// readFile truncates silently, so an oversized source has to be refused
|
|
1384
|
+
// here: copying only its first 64 MiB would corrupt the destination.
|
|
1385
|
+
if (stat.size > LINKED_OPEN_FILE_CAP) {
|
|
1386
|
+
throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 超过 64 MB,无法复制`)
|
|
1387
|
+
}
|
|
1328
1388
|
const read = await remote.readFile(source, { maxBytes: LINKED_OPEN_FILE_CAP })
|
|
1389
|
+
if (read.truncated || read.data.length !== stat.size) {
|
|
1390
|
+
throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 读取不完整,已取消复制`)
|
|
1391
|
+
}
|
|
1329
1392
|
await remote.writeFileAtomic(target, read.data)
|
|
1330
1393
|
return 1
|
|
1331
1394
|
}
|
|
@@ -1805,16 +1868,22 @@ export function apply(ctx) {
|
|
|
1805
1868
|
? scope.root
|
|
1806
1869
|
: scopeTarget(scope, params.path)
|
|
1807
1870
|
const listing = await scope.remote.listDir(target)
|
|
1871
|
+
const all = listing.entries.map((entry) => ({
|
|
1872
|
+
name: entry.name,
|
|
1873
|
+
path: entry.path,
|
|
1874
|
+
type: entry.type,
|
|
1875
|
+
size: entry.size,
|
|
1876
|
+
mtime: entry.mtime,
|
|
1877
|
+
relative: scopeRelative(scope, entry.path),
|
|
1878
|
+
}))
|
|
1879
|
+
const page = pageWindow(params, all.length)
|
|
1808
1880
|
return {
|
|
1809
1881
|
path: target,
|
|
1810
|
-
entries:
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
mtime: entry.mtime,
|
|
1816
|
-
relative: scopeRelative(scope, entry.path),
|
|
1817
|
-
})),
|
|
1882
|
+
entries: all.slice(page.offset, page.offset + page.limit),
|
|
1883
|
+
total: all.length,
|
|
1884
|
+
offset: page.offset,
|
|
1885
|
+
limit: page.limit,
|
|
1886
|
+
hasMore: page.offset + page.limit < all.length,
|
|
1818
1887
|
}
|
|
1819
1888
|
},
|
|
1820
1889
|
|
|
@@ -1876,6 +1945,7 @@ export function apply(ctx) {
|
|
|
1876
1945
|
if (apiError.code !== 'FS_NOT_FOUND') throw apiError
|
|
1877
1946
|
current = undefined
|
|
1878
1947
|
}
|
|
1948
|
+
if (params.createOnly === true && current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
|
|
1879
1949
|
if (current && current.type === 'dir') throw new ApiError('FS_IS_DIR', '目标是目录,无法写入')
|
|
1880
1950
|
if (current && typeof params.expectedMtime === 'number' && params.expectedMtime > 0) {
|
|
1881
1951
|
const currentSeconds = Math.floor(current.mtime / 1000)
|
|
@@ -2059,6 +2129,17 @@ export function apply(ctx) {
|
|
|
2059
2129
|
const scope = await requireScope(params)
|
|
2060
2130
|
const target = scopeTarget(scope, params.path)
|
|
2061
2131
|
if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能创建工作区根目录')
|
|
2132
|
+
if (params.createOnly === true) {
|
|
2133
|
+
let current
|
|
2134
|
+
try {
|
|
2135
|
+
current = await scope.remote.stat(target)
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
const apiError = asApiError(error)
|
|
2138
|
+
if (apiError.code !== 'FS_NOT_FOUND') throw apiError
|
|
2139
|
+
current = undefined
|
|
2140
|
+
}
|
|
2141
|
+
if (current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
|
|
2142
|
+
}
|
|
2062
2143
|
await scope.remote.mkdirp(target)
|
|
2063
2144
|
return {}
|
|
2064
2145
|
},
|
|
@@ -2187,32 +2268,43 @@ export function apply(ctx) {
|
|
|
2187
2268
|
throw mapLocalFsError(error)
|
|
2188
2269
|
}
|
|
2189
2270
|
if (!info.isDirectory()) throw new ApiError('FS_NOT_DIR', '本地路径不是目录')
|
|
2190
|
-
|
|
2191
|
-
try {
|
|
2192
|
-
rawEntries = await readdir(target, { withFileTypes: true })
|
|
2193
|
-
} catch (error) {
|
|
2271
|
+
const rawEntries = await readdir(target, { withFileTypes: true }).catch((error) => {
|
|
2194
2272
|
throw mapLocalFsError(error)
|
|
2195
|
-
}
|
|
2273
|
+
})
|
|
2196
2274
|
const entries = []
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2275
|
+
// Stat in small parallel batches: one await per entry is slow for big
|
|
2276
|
+
// folders, but an unbounded Promise.all can exhaust file descriptors.
|
|
2277
|
+
for (let start = 0; start < rawEntries.length; start += 128) {
|
|
2278
|
+
const batch = rawEntries.slice(start, start + 128)
|
|
2279
|
+
const stated = await Promise.all(batch.map(async (entry) => {
|
|
2280
|
+
const full = join(target, entry.name)
|
|
2281
|
+
let stat
|
|
2282
|
+
try {
|
|
2283
|
+
stat = await fsStat(full)
|
|
2284
|
+
} catch {
|
|
2285
|
+
return null
|
|
2286
|
+
}
|
|
2287
|
+
let type = 'file'
|
|
2288
|
+
if (stat.isDirectory()) type = 'dir'
|
|
2289
|
+
else if (stat.isSymbolicLink()) type = 'symlink'
|
|
2290
|
+
return { name: entry.name, path: full, type, size: stat.size, mtime: stat.mtimeMs }
|
|
2291
|
+
}))
|
|
2292
|
+
for (const item of stated) if (item) entries.push(item)
|
|
2209
2293
|
}
|
|
2210
2294
|
entries.sort((left, right) => {
|
|
2211
2295
|
if (left.type === 'dir' && right.type !== 'dir') return -1
|
|
2212
2296
|
if (right.type === 'dir' && left.type !== 'dir') return 1
|
|
2213
2297
|
return left.name.localeCompare(right.name)
|
|
2214
2298
|
})
|
|
2215
|
-
|
|
2299
|
+
const page = pageWindow(params, entries.length)
|
|
2300
|
+
return {
|
|
2301
|
+
path: target,
|
|
2302
|
+
entries: entries.slice(page.offset, page.offset + page.limit),
|
|
2303
|
+
total: entries.length,
|
|
2304
|
+
offset: page.offset,
|
|
2305
|
+
limit: page.limit,
|
|
2306
|
+
hasMore: page.offset + page.limit < entries.length,
|
|
2307
|
+
}
|
|
2216
2308
|
},
|
|
2217
2309
|
|
|
2218
2310
|
async 'local.stat'(params = {}) {
|
|
@@ -2246,13 +2338,15 @@ export function apply(ctx) {
|
|
|
2246
2338
|
if (info.isDirectory()) throw new ApiError('FS_IS_DIR', '目标是目录,无法作为文件读取')
|
|
2247
2339
|
const tooLarge = info.size > EDITABLE_LIMIT
|
|
2248
2340
|
const cap = tooLarge ? PEEK_LIMIT : READ_CAP
|
|
2341
|
+
// Read at most `cap` bytes. Reading the whole file first would pull
|
|
2342
|
+
// multi-gigabyte files into Host memory (and on Windows readFileSync
|
|
2343
|
+
// throws ERR_FS_FILE_TOO_LARGE above 2 GiB even for a peek).
|
|
2249
2344
|
let data
|
|
2250
2345
|
try {
|
|
2251
|
-
data =
|
|
2346
|
+
data = readHeadSync(absolute, cap)
|
|
2252
2347
|
} catch (error) {
|
|
2253
2348
|
throw mapLocalFsError(error)
|
|
2254
2349
|
}
|
|
2255
|
-
if (data.length > cap) data = data.subarray(0, cap)
|
|
2256
2350
|
const binary = looksBinary(data)
|
|
2257
2351
|
return {
|
|
2258
2352
|
path: absolute,
|
|
@@ -2640,7 +2734,6 @@ export function apply(ctx) {
|
|
|
2640
2734
|
if (nextName.length > MAX_NAME_LENGTH) throw new ApiError('BAD_REQUEST', '工作区名称过长')
|
|
2641
2735
|
const nextMirror = typeof params.mirror === 'boolean' ? params.mirror : record.mirror
|
|
2642
2736
|
const renamed = nextName !== '' && nextName !== record.name
|
|
2643
|
-
if (renamed && nextName === '') throw new ApiError('BAD_REQUEST', '名称不能为空')
|
|
2644
2737
|
record.name = nextName !== '' ? nextName : record.name
|
|
2645
2738
|
record.mirror = nextMirror
|
|
2646
2739
|
if (nextMirror === false) {
|
|
@@ -2919,8 +3012,11 @@ export function apply(ctx) {
|
|
|
2919
3012
|
return toolText(`${target} (${stat.size} B, mtime ${stat.mtime}) is a binary file; use list_dir/stat or download it locally.`)
|
|
2920
3013
|
}
|
|
2921
3014
|
const content = decodeUtf8(read.data)
|
|
2922
|
-
const
|
|
2923
|
-
|
|
3015
|
+
const truncated = read.data.length < stat.size
|
|
3016
|
+
const suffix = truncated ? `\n...(truncated at ${read.data.length} of ${stat.size} bytes)` : ''
|
|
3017
|
+
// Only a complete read may be archived: mirroring a truncated prefix
|
|
3018
|
+
// would leave a silently corrupt copy under .remote.
|
|
3019
|
+
if (!truncated) await mirrorWrite(link, target, read.data)
|
|
2924
3020
|
return toolText(`${target} (${stat.size} B, mtime ${stat.mtime})${suffix}\n\n${content}`)
|
|
2925
3021
|
}
|
|
2926
3022
|
|
|
@@ -2993,7 +3089,9 @@ export function apply(ctx) {
|
|
|
2993
3089
|
if (action === 'delete') {
|
|
2994
3090
|
if (target === root) throw new ApiError('BAD_REQUEST', '不能删除联动工作区根目录')
|
|
2995
3091
|
const stat = await remote.stat(target)
|
|
2996
|
-
|
|
3092
|
+
// rmdir only removes empty directories (same as the UI's fs.delete,
|
|
3093
|
+
// which uses this recursive helper).
|
|
3094
|
+
if (stat.type === 'dir') await remoteDeleteRecursive(remote, target)
|
|
2997
3095
|
else await remote.unlink(target)
|
|
2998
3096
|
await linkedFs.removeMirrorPath(link.localPath, linkedRelative(link, target)).catch(() => {})
|
|
2999
3097
|
return toolText(`Deleted ${target}`)
|
package/lib/linked.js
CHANGED
|
@@ -509,6 +509,35 @@ function isContained(realRoot, realTarget) {
|
|
|
509
509
|
return offset !== '' && !offset.startsWith('..') && !isAbsolute(offset)
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Resolve a path the way the OS does. The JS `realpathSync` does not follow
|
|
514
|
+
* Windows junctions, so a junction pointing outside the workspace would pass a
|
|
515
|
+
* plain string containment test; the native call resolves it correctly.
|
|
516
|
+
*/
|
|
517
|
+
function realPathOf(target) {
|
|
518
|
+
return typeof realpathSync.native === 'function' ? realpathSync.native(target) : realpathSync(target)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* True for symlinks and for Windows junctions, which `lstatSync` reports as
|
|
523
|
+
* ordinary directories (mode 40666, `isSymbolicLink() === false`).
|
|
524
|
+
*/
|
|
525
|
+
function isLinkLike(target, stats) {
|
|
526
|
+
if (stats && stats.isSymbolicLink()) return true
|
|
527
|
+
if (process.platform !== 'win32') return false
|
|
528
|
+
try {
|
|
529
|
+
return realPathOf(target) !== resolve(target)
|
|
530
|
+
} catch {
|
|
531
|
+
return false
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function unsafeLinkError(target, detail) {
|
|
536
|
+
const error = new Error(`refusing to remove ${target}: ${detail}`)
|
|
537
|
+
error.code = 'ELINKED_UNSAFE'
|
|
538
|
+
return error
|
|
539
|
+
}
|
|
540
|
+
|
|
512
541
|
/**
|
|
513
542
|
* Remove empty parent directories of `target`, stopping at `stopAt` (exclusive).
|
|
514
543
|
* Best effort: the first non-empty or unreadable directory ends the walk.
|
|
@@ -550,8 +579,8 @@ export async function removeMirrorPath(localPath, relativePath) {
|
|
|
550
579
|
const mirrorRoot = join(root, MIRROR_DIR)
|
|
551
580
|
const target = join(mirrorRoot, ...relativeValue.split('/'))
|
|
552
581
|
|
|
553
|
-
// A symlinked `.remote` would redirect every operation
|
|
554
|
-
// workspace; refuse instead of deleting through it.
|
|
582
|
+
// A symlinked or junctioned `.remote` would redirect every operation
|
|
583
|
+
// outside the workspace; refuse instead of deleting through it.
|
|
555
584
|
let mirrorStats
|
|
556
585
|
try {
|
|
557
586
|
mirrorStats = lstatSync(mirrorRoot)
|
|
@@ -559,12 +588,13 @@ export async function removeMirrorPath(localPath, relativePath) {
|
|
|
559
588
|
if (error && error.code === 'ENOENT') return false
|
|
560
589
|
throw error
|
|
561
590
|
}
|
|
562
|
-
if (mirrorStats
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
591
|
+
if (isLinkLike(mirrorRoot, mirrorStats)) {
|
|
592
|
+
throw unsafeLinkError(target, `${mirrorRoot} is a symbolic link`)
|
|
593
|
+
}
|
|
594
|
+
const realRoot = realPathOf(root)
|
|
595
|
+
const realMirror = realPathOf(mirrorRoot)
|
|
596
|
+
if (!isContained(realRoot, realMirror)) {
|
|
597
|
+
throw unsafeLinkError(target, `${mirrorRoot} resolves outside ${root}`)
|
|
568
598
|
}
|
|
569
599
|
|
|
570
600
|
let stats
|
|
@@ -576,18 +606,13 @@ export async function removeMirrorPath(localPath, relativePath) {
|
|
|
576
606
|
throw error
|
|
577
607
|
}
|
|
578
608
|
|
|
579
|
-
if (stats
|
|
609
|
+
if (isLinkLike(target, stats)) {
|
|
580
610
|
// Delete the link itself, never what it points at.
|
|
581
611
|
removeSymlink(target)
|
|
582
612
|
} else {
|
|
583
|
-
const
|
|
584
|
-
const realTarget = realpathSync(target)
|
|
613
|
+
const realTarget = realPathOf(target)
|
|
585
614
|
if (!isContained(realMirror, realTarget)) {
|
|
586
|
-
|
|
587
|
-
`refusing to remove ${target}: it resolves outside ${mirrorRoot}`,
|
|
588
|
-
)
|
|
589
|
-
error.code = 'ELINKED_UNSAFE'
|
|
590
|
-
throw error
|
|
615
|
+
throw unsafeLinkError(target, `it resolves outside ${mirrorRoot}`)
|
|
591
616
|
}
|
|
592
617
|
rmSync(target, { recursive: true, force: true, maxRetries: 3 })
|
|
593
618
|
}
|
|
@@ -626,18 +651,15 @@ function removeOwnedDir(target, root) {
|
|
|
626
651
|
if (error && error.code === 'ENOENT') return false
|
|
627
652
|
throw error
|
|
628
653
|
}
|
|
629
|
-
if (stats
|
|
654
|
+
if (isLinkLike(target, stats)) {
|
|
655
|
+
// A junction/symlink owns only the link: remove it, keep the data outside.
|
|
630
656
|
removeSymlink(target)
|
|
631
657
|
return true
|
|
632
658
|
}
|
|
633
|
-
const realRoot =
|
|
634
|
-
const realTarget =
|
|
659
|
+
const realRoot = realPathOf(root)
|
|
660
|
+
const realTarget = realPathOf(target)
|
|
635
661
|
if (!isContained(realRoot, realTarget)) {
|
|
636
|
-
|
|
637
|
-
`refusing to remove ${target}: it resolves outside ${root}`,
|
|
638
|
-
)
|
|
639
|
-
error.code = 'ELINKED_UNSAFE'
|
|
640
|
-
throw error
|
|
662
|
+
throw unsafeLinkError(target, `it resolves outside ${root}`)
|
|
641
663
|
}
|
|
642
664
|
rmSync(target, { recursive: true, force: true, maxRetries: 3 })
|
|
643
665
|
return true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-linked-workspace",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "node scripts/build-client.mjs",
|
|
75
|
-
"test": "node --test test/linked.test.mjs test/parts.test.mjs test/git.test.mjs && node test/client-smoke.mjs",
|
|
75
|
+
"test": "node --test test/linked.test.mjs test/parts.test.mjs test/git.test.mjs test/file-creation.test.mjs && node test/client-smoke.mjs",
|
|
76
76
|
"prepack": "node scripts/build-client.mjs"
|
|
77
77
|
},
|
|
78
78
|
"repository": {
|
package/scripts/doc_tool.py
CHANGED
|
@@ -106,18 +106,25 @@ def read_xlsx(path):
|
|
|
106
106
|
sheets = []
|
|
107
107
|
for sheet in workbook.worksheets[:20]:
|
|
108
108
|
rows = []
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
total_row = sheet.max_row or 0
|
|
110
|
+
total_col = sheet.max_column or 0
|
|
111
|
+
max_row = min(total_row, MAX_SHEET_ROWS)
|
|
112
|
+
max_col = min(total_col, MAX_SHEET_COLS)
|
|
111
113
|
for row in sheet.iter_rows(min_row=1, max_row=max_row, min_col=1, max_col=max_col):
|
|
112
114
|
rows.append([json_value(cell.value) for cell in row])
|
|
113
115
|
sheets.append({
|
|
114
116
|
"name": sheet.title,
|
|
115
117
|
"rows": rows,
|
|
118
|
+
# maxRow/maxCol stay the rendered window so the client can size the
|
|
119
|
+
# grid; totalRow/totalCol/truncated tell it the sheet is larger.
|
|
116
120
|
"maxRow": max_row,
|
|
117
121
|
"maxCol": max_col,
|
|
122
|
+
"totalRow": total_row,
|
|
123
|
+
"totalCol": total_col,
|
|
124
|
+
"truncated": total_row > max_row or total_col > max_col,
|
|
118
125
|
"frozen": bool(sheet.freeze_panes),
|
|
119
126
|
})
|
|
120
|
-
emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets)})
|
|
127
|
+
emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets), "sheetLimit": 20})
|
|
121
128
|
|
|
122
129
|
|
|
123
130
|
def write_xlsx(source, data, out):
|