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 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&#9;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&#9;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 (/^on/i.test(attribute.name) || /^javascript:/i.test(attribute.value) || /^data:text\/html/i.test(attribute.value)) {
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
- var request = props.kind === 'local'
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, { loading: false, error: null, loaded: true, entries: entries });
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
- return h(TreeDirectory, {
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
- function submit() { if (value.trim() !== '') props.onSubmit(value.trim()); }
4887
- return h(DialogShell, { title: props.title, onClose: props.onClose },
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) { if (event.key === 'Enter') submit(); },
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', onClick: props.onClose }, props.t('cancel')),
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 target = joinChildPath(dirPath, name, side);
5818
- var docKind = documentKindOf(name);
5819
- var promise;
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 && docKind) openInternalFile(side, target);
5837
- }).catch(noticeError);
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({ label: t('ctxNew'), sub: [
5915
- { label: t('ctxNewFolder'), onSelect: function () {
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
- var request = source.kind === 'linked'
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, { loading: false, error: null, loaded: true, entries: result.entries || [] });
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', { className: 'rw-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('flowTitle') },
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('BAD_REQUEST', '同名文件夹已存在')
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
- this.connections.set(hostRecord.id, { authority, remote })
725
- await remote.connect()
726
- return remote
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
- if (depth > 24 || shouldStop()) {
1111
- if (shouldStop() && !job.cancel) job.truncated = true
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: listing.entries.map((entry) => ({
1811
- name: entry.name,
1812
- path: entry.path,
1813
- type: entry.type,
1814
- size: entry.size,
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
- let rawEntries
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
- for (const entry of rawEntries) {
2198
- const full = join(target, entry.name)
2199
- let stat
2200
- try {
2201
- stat = await fsStat(full)
2202
- } catch {
2203
- continue
2204
- }
2205
- let type = 'file'
2206
- if (stat.isDirectory()) type = 'dir'
2207
- else if (stat.isSymbolicLink()) type = 'symlink'
2208
- entries.push({ name: entry.name, path: full, type, size: stat.size, mtime: stat.mtimeMs })
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
- return { path: target, entries }
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 = readFileSync(absolute)
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 suffix = read.data.length < stat.size ? `\n...(truncated at ${read.data.length} of ${stat.size} bytes)` : ''
2923
- await mirrorWrite(link, target, read.data)
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
- if (stat.type === 'dir') await remote.rmdir(target)
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 outside the
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.isSymbolicLink()) {
563
- const error = new Error(
564
- `refusing to remove ${target}: ${mirrorRoot} is a symbolic link`,
565
- )
566
- error.code = 'ELINKED_UNSAFE'
567
- throw error
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.isSymbolicLink()) {
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 realMirror = realpathSync(mirrorRoot)
584
- const realTarget = realpathSync(target)
613
+ const realTarget = realPathOf(target)
585
614
  if (!isContained(realMirror, realTarget)) {
586
- const error = new Error(
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.isSymbolicLink()) {
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 = realpathSync(root)
634
- const realTarget = realpathSync(target)
659
+ const realRoot = realPathOf(root)
660
+ const realTarget = realPathOf(target)
635
661
  if (!isContained(realRoot, realTarget)) {
636
- const error = new Error(
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.1",
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": {
@@ -106,18 +106,25 @@ def read_xlsx(path):
106
106
  sheets = []
107
107
  for sheet in workbook.worksheets[:20]:
108
108
  rows = []
109
- max_row = min(sheet.max_row or 0, MAX_SHEET_ROWS)
110
- max_col = min(sheet.max_column or 0, MAX_SHEET_COLS)
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):