dsh-linked-workspace 0.7.0 → 0.8.1
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 +12 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/docs/API.md +4 -0
- package/docs/VERIFY.md +19 -0
- package/lib/client.js +570 -41
- package/lib/index.js +125 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.1 — 2026-11-09
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **HTML preview**: `.html` and `.htm` files now render in a sandboxed iframe in the content panel. Scripts are disabled; the status bar notes this.
|
|
7
|
+
|
|
8
|
+
## 0.8.0 — 2026-11-09
|
|
9
|
+
|
|
10
|
+
- **Image previews**: PNG/JPG/GIF/WebP/BMP/ICO/AVIF/SVG now render in the content panel for both remote and local files (`media.read` / `local.media`, 24 MB cap).
|
|
11
|
+
- **CSV/TSV table view**: CSV and TSV files open in a table by default with a Table/Source toggle. The source view keeps editing, syntax highlighting, and save; tables are capped at 1000 rows and 80 columns.
|
|
12
|
+
- **Move files and folders**: the directory context menu gains "Move to…" for remote and local entries. A directory picker chooses the destination inside the same tree; the plugin rejects collisions, moving a folder into itself, moving the workspace root, and moving a file with unsaved edits.
|
|
13
|
+
- New host APIs `media.read`, `fs.move`, `local.media`, `local.move`; `FS_EXISTS` now maps to HTTP 409.
|
|
14
|
+
|
|
3
15
|
## 0.7.0 — 2026-11-09
|
|
4
16
|
|
|
5
17
|
- **VSCode-like tree interaction**: single-click a folder to expand/collapse and single-click a file to open it. A single-click preview tab is replaced by the next file; double-click pins the tab so later single clicks do not overwrite it. Double-clicking a folder keeps it expanded.
|
package/README.en.md
CHANGED
|
@@ -61,6 +61,7 @@ The content panel now edits **Markdown / PDF / DOCX / XLSX / PPTX** files on bot
|
|
|
61
61
|
- **Refresh**: reload roots, git status and search.
|
|
62
62
|
- File-type icons for JS/TS/Python/JSON/Markdown/Shell/YAML/images/archives/config files, etc.
|
|
63
63
|
- Editor: line numbers, syntax highlighting (layered rendering), theme colors, `Ctrl/Cmd+S` save, conflict prompt (reload/force save), read-only protection for binary and >2 MB files, UTF-8/LF/CRLF/line-count status bar. The embedded content panel hides the path/save header; unsaved changes show a gray dot in the file tab. Read-only tabs render the file content but cannot be edited or saved.
|
|
64
|
+
- **Image, CSV and HTML preview**: PNG/JPG/GIF/WebP/BMP/ICO/AVIF/SVG render directly in the content panel (up to 24 MB) with toolbar zoom, `Ctrl/Cmd + wheel` zoom, fit-to-window and 1:1 actual-size controls. CSV/TSV opens as a table by default with a Table/Source toggle; the source view remains editable and saveable. Tables are capped at 1000 rows and 80 columns. HTML/HTM files render in a sandboxed iframe with scripts disabled.
|
|
64
65
|
- **Shortcuts**: `Ctrl/Cmd+S` saves the current file; `Ctrl/Cmd+W` closes the current tab (with a save/discard/cancel prompt when dirty); `Ctrl+Tab` / `Shift+Ctrl+Tab` cycles tabs; `Ctrl/Cmd+B` toggles the directory panel; `Ctrl/Cmd+Shift+F` opens file search.
|
|
65
66
|
- **Auto save**: enable it under Settings → Connections → Editor behavior. It saves about one second after you stop typing. When off, use `Ctrl+S`. Closing a dirty file always asks first instead of discarding silently.
|
|
66
67
|
- Multiple files can be open at once (up to 12 slots). A single click opens a preview tab and replaces the current unpinned preview; double-click pins the tab; clicking the same file focuses its existing tab. Local files use the same internal editor and can be edited and saved.
|
|
@@ -72,7 +73,7 @@ The content panel now edits **Markdown / PDF / DOCX / XLSX / PPTX** files on bot
|
|
|
72
73
|
|
|
73
74
|
### Context menu (files, folders, roots)
|
|
74
75
|
|
|
75
|
-
Transfer (download to `.remote` / upload to remote), download to the default folder, queue, open, edit with Notepad, copy, paste, copy path, rename, delete, change permissions, new folder/file, properties. Remote operations use SFTP; local operations use the local filesystem. The transfer queue runs one job at a time.
|
|
76
|
+
Transfer (download to `.remote` / upload to remote), download to the default folder, queue, open, edit with Notepad, copy, paste, copy path, **Move to…** (only within the same local or remote tree), rename, delete, change permissions, new folder/file, properties. Remote operations use SFTP; local operations use the local filesystem. The transfer queue runs one job at a time.
|
|
76
77
|
|
|
77
78
|
### Linked-workspace layout
|
|
78
79
|
|
package/README.md
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
- **刷新**:重新拉取根目录、git 状态与搜索。
|
|
63
63
|
- 不同文件类型使用不同彩色图标(JS/TS/Python/JSON/Markdown/Shell/YAML/图片/压缩包/配置文件等)。
|
|
64
64
|
- 编辑器:行号、语法高亮(叠层渲染)、主题配色、`Ctrl/Cmd+S` 保存、冲突提示(重新加载/强制保存)、二进制与 >2 MB 文件只读保护、UTF-8/LF/CRLF/行数状态栏;内嵌内容面板不显示文件路径/保存工具条,未保存修改在文件标签前显示灰点。
|
|
65
|
+
- **图片、CSV 与网页预览**:PNG/JPG/GIF/WebP/BMP/ICO/AVIF/SVG 直接在内容面板按图片显示(最大 24 MB),支持按钮缩放、`Ctrl/Cmd + 滚轮` 缩放、适应窗口和 1:1 原始大小;CSV/TSV 默认按表格显示,可在「表格 / 源码」间切换,源码视图仍可编辑保存。超过 1000 行或 80 列时只展示截断后的表格。HTML/HTM 文件以沙箱 iframe 渲染网页预览(脚本禁用)。
|
|
65
66
|
- **快捷键**:`Ctrl/Cmd+S` 保存当前文件;`Ctrl/Cmd+W` 关闭当前标签(未保存时提示保存/放弃/取消);`Ctrl+Tab` / `Shift+Ctrl+Tab` 切换标签;`Ctrl/Cmd+B` 显示/隐藏目录面板;`Ctrl/Cmd+Shift+F` 打开文件搜索。
|
|
66
67
|
- **自动保存**:设置 → 连接 →「编辑器行为」中开启;停止输入约 1 秒后自动保存。关闭时使用 `Ctrl+S` 手动保存;未保存文件关闭前会弹窗,不会静默丢弃。
|
|
67
68
|
- **多个文件同时打开**:文件内容面板内最多同时打开 12 个标签;单击文件打开预览标签并替换未固定的预览,双击固定标签,重复点击同一文件聚焦原标签;本地文件与远端文件共用内部编辑器,本地文件可编辑保存。
|
|
@@ -73,7 +74,7 @@
|
|
|
73
74
|
|
|
74
75
|
### 右键菜单(文件/文件夹/根节点)
|
|
75
76
|
|
|
76
|
-
传输(下载到 `.remote
|
|
77
|
+
传输(下载到 `.remote`)、下载到默认文件夹、队列、打开、用记事本编辑、复制、粘贴、复制路径、**移动到…**(只允许移动到同一棵本地树或同一棵远程树内的目录)、重命名、删除、更改权限、新建(文件夹/文件)、属性。远端操作走 SFTP;本地目录的同类操作走本机文件系统。传输队列一次执行一个任务并显示排队/传输中/完成/失败状态。
|
|
77
78
|
|
|
78
79
|
### 联动工作区结构
|
|
79
80
|
|
package/docs/API.md
CHANGED
|
@@ -60,10 +60,12 @@ relative to the scope root, or absolute inside it.
|
|
|
60
60
|
| `workspaces.test` | `{ id }` | `{ ok, latencyMs?, resolvedPath?, error? }` |
|
|
61
61
|
| `fs.list` | `{ workspaceId?/linkedId?, path }` | `{ path, entries: RemoteEntry[] }` |
|
|
62
62
|
| `fs.read` | `{ workspaceId?/linkedId?, path, maxBytes? }` | `FileRead` |
|
|
63
|
+
| `media.read` | `{ workspaceId?/linkedId?, path }` | `{ path, size, mtime, mime, base64 }` (PNG/JPG/GIF/WebP/BMP/ICO/AVIF/SVG, max 24 MB) |
|
|
63
64
|
| `fs.write` | `{ workspaceId?/linkedId?, path, content, expectedMtime? }` | `{ path, size, mtime }` |
|
|
64
65
|
| `fs.stat` | `{ workspaceId?/linkedId?, path }` | `{ path, relative, type, size, mtime, mode }` |
|
|
65
66
|
| `fs.mkdir` | `{ workspaceId?/linkedId?, path }` | `{}` |
|
|
66
67
|
| `fs.rename` | `{ workspaceId?/linkedId?, from, to }` | `{}` |
|
|
68
|
+
| `fs.move` | `{ workspaceId?/linkedId?, path, targetDir? }` | `{ path }` (same remote tree; collision → 409) |
|
|
67
69
|
| `fs.delete` | `{ workspaceId?/linkedId?, path }` | `{}` |
|
|
68
70
|
| `fs.home` | `{ workspaceId?/linkedId? }` | `{ home }` |
|
|
69
71
|
| `settings.get` | `{}` | `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode, autoSave }` |
|
|
@@ -93,9 +95,11 @@ relative to the scope root, or absolute inside it.
|
|
|
93
95
|
| `fs.copy` | `{ workspaceId?/linkedId?, path, targetDir? }` | `{ path, files }` |
|
|
94
96
|
| `fs.chmod` | `{ workspaceId?/linkedId?, path, mode }` | stat |
|
|
95
97
|
| `local.read` | `{ path }` | `{ path, content, size, mtime, truncated, binary, tooLarge, editable }` |
|
|
98
|
+
| `local.media` | `{ path }` | `{ path, size, mtime, mime, base64 }` (max 24 MB) |
|
|
96
99
|
| `local.write` | `{ path, content, expectedMtime? }` | `{ path, size, mtime }` |
|
|
97
100
|
| `local.stat` | `{ path }` | `{ path, type, size, mtime, mode }` |
|
|
98
101
|
| `local.rename` | `{ from, to }` | `{ path }` |
|
|
102
|
+
| `local.move` | `{ path, targetDir?, root? }` | `{ path, type }` (same local tree; collision → 409) |
|
|
99
103
|
| `local.delete` | `{ path, root? }` | `{}` (recursive) |
|
|
100
104
|
| `local.copy` | `{ path, targetDir? }` | `{ path, type }` |
|
|
101
105
|
| `local.newFile` | `{ path }` | `{ path }` |
|
package/docs/VERIFY.md
CHANGED
|
@@ -113,3 +113,22 @@ Host code (lib/) reloads live with `plugin_manager set_plugin include:linked-wor
|
|
|
113
113
|
7. 以上操作在本地目录与远端(联动工作区)文件上都应可用;Office/PDF 需要 Python 3 + python-docx/openpyxl/python-pptx/PyMuPDF。
|
|
114
114
|
|
|
115
115
|
- 新建一个空白联动工作区会话:右侧栏开始页应出现「联动工作区目录」,打开后目录面板正常显示。
|
|
116
|
+
|
|
117
|
+
## 图片 / CSV 预览与移动(0.8.0)
|
|
118
|
+
|
|
119
|
+
1. 本地目录与远端目录各放一个 PNG、一个 SVG、一个 JPG:单击文件应在内容面板显示图片,状态栏显示 MIME 与大小;损坏图片显示错误提示而不是空白。
|
|
120
|
+
2. 打开 CSV/TSV:默认显示表格,第一行作为表头;超过 1000 行或 80 列时显示截断提示;点「源码」可切换编辑器并保存,表格模式下保存按钮禁用。
|
|
121
|
+
3. 含引号、逗号、字段内换行的 CSV 应正确分列;TSV 按制表符分列。
|
|
122
|
+
4. 右键文件/文件夹 →「移动到…」:在本地目录内可移动到当前目录的任意子目录,在远端目录内可移动到当前远程工作区根目录或子目录。
|
|
123
|
+
5. 移动后目录树与内容面板一致;已打开的旧标签关闭,已修改未保存的文件阻止移动并提示先保存。
|
|
124
|
+
6. 目标目录已有同名文件时提示冲突(409),不覆盖;文件夹不能移动到自身或其子目录。
|
|
125
|
+
7. 移动选择器不能浏览到当前工作区/本地目录之外,根目录本身不可作为被移动项。
|
|
126
|
+
|
|
127
|
+
## 图片缩放与网页预览(0.8.1)
|
|
128
|
+
|
|
129
|
+
1. 打开 PNG/SVG:图片默认适应窗口;点「适应窗口」重新适配,「1:1」恢复原始像素。
|
|
130
|
+
2. 点 `−` / `+` 时缩放比例按 20% 步进,状态栏百分比同步;`Ctrl/Cmd + 滚轮` 可连续缩放,最小 5%、最大 800%。
|
|
131
|
+
3. 放大后图片区域出现滚动条,可查看四周;缩小后图片保持居中,不出现拉伸变形。
|
|
132
|
+
4. 本地与远端各打开一个 `.html`/`.htm`:内容面板以 iframe 渲染页面,状态栏显示 HTML、大小和「网页脚本已禁用」。
|
|
133
|
+
5. HTML 内联样式与静态标签正常显示;页面内脚本不执行,链接不会跳出 DSH 主界面。
|
|
134
|
+
6. 读取失败的 HTML(权限/过大/二进制)显示错误提示和重试按钮。
|
package/lib/client.js
CHANGED
|
@@ -1848,6 +1848,13 @@ function fileIconSpec(name, isDir) {
|
|
|
1848
1848
|
return String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
1849
1849
|
}
|
|
1850
1850
|
|
|
1851
|
+
function pathInsideOf(root, target) {
|
|
1852
|
+
var base = linkedScopeKey(root);
|
|
1853
|
+
var value = linkedScopeKey(target);
|
|
1854
|
+
if (!base) return true;
|
|
1855
|
+
return value === base || value.indexOf(base + '/') === 0;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1851
1858
|
function isLinkedWorkspaceId(workspaceId) {
|
|
1852
1859
|
return Boolean(workspaceId) && linkedWorkspaceKeys.has(String(workspaceId));
|
|
1853
1860
|
}
|
|
@@ -1998,6 +2005,25 @@ function fileIconSpec(name, isDir) {
|
|
|
1998
2005
|
tooLarge: '文件过大({{size}}),暂不支持在侧边栏内编辑。',
|
|
1999
2006
|
truncated: '内容已截断',
|
|
2000
2007
|
fileLoadError: '无法打开文件',
|
|
2008
|
+
imageLoadError: '无法预览图片',
|
|
2009
|
+
imageZoomIn: '放大',
|
|
2010
|
+
imageZoomOut: '缩小',
|
|
2011
|
+
imageFit: '适应窗口',
|
|
2012
|
+
imageActualSize: '原始大小',
|
|
2013
|
+
htmlLoadError: '无法预览网页',
|
|
2014
|
+
htmlScriptsDisabled: '网页脚本已禁用',
|
|
2015
|
+
previewOnly: '图片预览',
|
|
2016
|
+
csvTable: '表格',
|
|
2017
|
+
csvSource: '源码',
|
|
2018
|
+
csvEmpty: '没有可显示的数据',
|
|
2019
|
+
csvTruncated: '仅显示前 1000 行',
|
|
2020
|
+
moveTitle: '移动到…',
|
|
2021
|
+
moveMessage: '选择「{{name}}」要移动到的目录',
|
|
2022
|
+
moveDone: '已移动',
|
|
2023
|
+
moveDirty: '该文件有未保存修改,请先保存或关闭后再移动',
|
|
2024
|
+
moveFailed: '移动失败',
|
|
2025
|
+
moveHint: '进入目标文件夹后点击「使用此目录」',
|
|
2026
|
+
ctxMove: '移动到…',
|
|
2001
2027
|
noWorkspace: '远程工作区不存在,请重新添加。',
|
|
2002
2028
|
noTarget: '没有可用的远程目录。先在设置 → 连接中添加,或点击左侧「添加工作区」。',
|
|
2003
2029
|
treeEmpty: '空目录',
|
|
@@ -2308,6 +2334,25 @@ function fileIconSpec(name, isDir) {
|
|
|
2308
2334
|
tooLarge: 'File too large ({{size}}); editing it in the sidebar is not supported yet.',
|
|
2309
2335
|
truncated: 'Content truncated',
|
|
2310
2336
|
fileLoadError: 'Cannot open the file',
|
|
2337
|
+
imageLoadError: 'Cannot preview this image',
|
|
2338
|
+
imageZoomIn: 'Zoom in',
|
|
2339
|
+
imageZoomOut: 'Zoom out',
|
|
2340
|
+
imageFit: 'Fit to window',
|
|
2341
|
+
imageActualSize: 'Actual size',
|
|
2342
|
+
htmlLoadError: 'Cannot preview this page',
|
|
2343
|
+
htmlScriptsDisabled: 'Page scripts are disabled',
|
|
2344
|
+
previewOnly: 'Image preview',
|
|
2345
|
+
csvTable: 'Table',
|
|
2346
|
+
csvSource: 'Source',
|
|
2347
|
+
csvEmpty: 'No rows to show',
|
|
2348
|
+
csvTruncated: 'Showing the first 1000 rows',
|
|
2349
|
+
moveTitle: 'Move to…',
|
|
2350
|
+
moveMessage: 'Choose the destination folder for "{{name}}"',
|
|
2351
|
+
moveDone: 'Moved',
|
|
2352
|
+
moveDirty: 'This file has unsaved changes; save or close it before moving',
|
|
2353
|
+
moveFailed: 'Move failed',
|
|
2354
|
+
moveHint: 'Open the destination folder, then click “Use this directory”',
|
|
2355
|
+
ctxMove: 'Move to…',
|
|
2311
2356
|
noWorkspace: 'This remote workspace no longer exists. Add it again.',
|
|
2312
2357
|
noTarget: 'No remote directory is available. Add one in Settings → Connections, or click “Add workspace” on the left.',
|
|
2313
2358
|
treeEmpty: 'Empty directory',
|
|
@@ -2679,9 +2724,59 @@ function fileIconSpec(name, isDir) {
|
|
|
2679
2724
|
if (value === '.docx') return 'docx';
|
|
2680
2725
|
if (value === '.xlsx') return 'xlsx';
|
|
2681
2726
|
if (value === '.pptx') return 'pptx';
|
|
2727
|
+
if (value === '.csv' || value === '.tsv') return 'csv';
|
|
2728
|
+
if (value === '.html' || value === '.htm') return 'html';
|
|
2729
|
+
if (value === '.png' || value === '.jpg' || value === '.jpeg' || value === '.gif'
|
|
2730
|
+
|| value === '.webp' || value === '.bmp' || value === '.ico' || value === '.avif'
|
|
2731
|
+
|| value === '.svg') return 'image';
|
|
2682
2732
|
return '';
|
|
2683
2733
|
}
|
|
2684
2734
|
|
|
2735
|
+
function isCsvPath(path) {
|
|
2736
|
+
var ext = String(path || '').toLowerCase().match(/\.[^./\\]+$/);
|
|
2737
|
+
return Boolean(ext && (ext[0] === '.csv' || ext[0] === '.tsv'));
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function parseDelimited(text, delimiter) {
|
|
2741
|
+
var rows = [];
|
|
2742
|
+
var row = [];
|
|
2743
|
+
var field = '';
|
|
2744
|
+
var quoted = false;
|
|
2745
|
+
var index = 0;
|
|
2746
|
+
var source = String(text || '');
|
|
2747
|
+
function pushField() { row.push(field); field = ''; }
|
|
2748
|
+
function pushRow() {
|
|
2749
|
+
pushField();
|
|
2750
|
+
rows.push(row);
|
|
2751
|
+
row = [];
|
|
2752
|
+
}
|
|
2753
|
+
while (index < source.length) {
|
|
2754
|
+
var ch = source[index];
|
|
2755
|
+
if (quoted) {
|
|
2756
|
+
if (ch === '"') {
|
|
2757
|
+
if (source[index + 1] === '"') { field += '"'; index += 2; continue; }
|
|
2758
|
+
quoted = false; index += 1; continue;
|
|
2759
|
+
}
|
|
2760
|
+
field += ch; index += 1; continue;
|
|
2761
|
+
}
|
|
2762
|
+
if (ch === '"') { quoted = true; index += 1; continue; }
|
|
2763
|
+
if (ch === delimiter) { pushField(); index += 1; continue; }
|
|
2764
|
+
if (ch === '\r') { index += 1; continue; }
|
|
2765
|
+
if (ch === '\n') { pushRow(); index += 1; continue; }
|
|
2766
|
+
field += ch; index += 1;
|
|
2767
|
+
}
|
|
2768
|
+
if (field !== '' || row.length > 0) pushRow();
|
|
2769
|
+
return rows;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function csvDelimiterOf(path, text) {
|
|
2773
|
+
if (/\.tsv$/i.test(String(path || ''))) return '\t';
|
|
2774
|
+
var firstLine = String(text || '').split(/\r?\n/, 1)[0] || '';
|
|
2775
|
+
var commas = (firstLine.match(/,/g) || []).length;
|
|
2776
|
+
var semicolons = (firstLine.match(/;/g) || []).length;
|
|
2777
|
+
return semicolons > commas ? ';' : ',';
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2685
2780
|
function sanitizeDocumentHtml(html) {
|
|
2686
2781
|
try {
|
|
2687
2782
|
var parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
|
|
@@ -3128,6 +3223,23 @@ function fileIconSpec(name, isDir) {
|
|
|
3128
3223
|
'.rw-props{display:grid;grid-template-columns:70px minmax(0,1fr);gap:5px 9px;font-size:12px;line-height:18px}',
|
|
3129
3224
|
'.rw-props-key{color:var(--dsw-alias-label-tertiary)}',
|
|
3130
3225
|
'.rw-props-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
3226
|
+
'.rw-image-preview{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
|
|
3227
|
+
'.rw-image-head{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);min-width:0;flex:none}',
|
|
3228
|
+
'.rw-image-head .rw-actions{display:flex;align-items:center;gap:6px;flex:none}',
|
|
3229
|
+
'.rw-image-stage{display:block;flex:1 1 auto;min-height:0;overflow:auto;padding:18px;text-align:center;background:repeating-conic-gradient(var(--dsw-alias-bg-layer-1) 0% 25%,var(--dsw-alias-bg-base) 0% 50%) 50%/18px 18px}',
|
|
3230
|
+
'.rw-image-canvas{display:inline-block;min-width:100%;min-height:100%;box-sizing:border-box}',
|
|
3231
|
+
'.rw-image{display:inline-block;max-width:none;max-height:none;vertical-align:top;border-radius:4px;box-shadow:0 2px 16px rgba(0,0,0,.18)}',
|
|
3232
|
+
'.rw-image-tools{display:flex;align-items:center;gap:4px;flex:none}',
|
|
3233
|
+
'.rw-image-zoom{min-width:52px;text-align:center;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);font-size:12px}',
|
|
3234
|
+
'.rw-html-preview{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
|
|
3235
|
+
'.rw-html-stage{flex:1 1 auto;min-height:0;background:#fff;position:relative}',
|
|
3236
|
+
'.rw-html-frame{display:block;width:100%;height:100%;border:0;background:#fff}',
|
|
3237
|
+
'.rw-csv-wrap{flex:1 1 auto;min-height:0;overflow:auto;background:var(--dsw-alias-bg-base)}',
|
|
3238
|
+
'.rw-csv-table{border-collapse:collapse;font:12px/18px ui-monospace,"Cascadia Code",Consolas,monospace;min-width:100%}',
|
|
3239
|
+
'.rw-csv-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-weight:600;text-align:left;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1);white-space:nowrap}',
|
|
3240
|
+
'.rw-csv-table td{padding:3px 8px;border:1px solid var(--dsw-alias-border-l1);vertical-align:top;max-width:360px;overflow-wrap:anywhere}',
|
|
3241
|
+
'.rw-csv-table tr:nth-child(even) td{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 52%,transparent)}',
|
|
3242
|
+
'.rw-csv-note{padding:5px 9px;font-size:10.5px;color:var(--dsw-alias-label-tertiary);border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}',
|
|
3131
3243
|
'.rw-queue-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}',
|
|
3132
3244
|
'.rw-queue-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
3133
3245
|
'.rw-queue-state{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}',
|
|
@@ -3484,6 +3596,7 @@ function fileIconSpec(name, isDir) {
|
|
|
3484
3596
|
var t = props.t;
|
|
3485
3597
|
var locale = props.locale;
|
|
3486
3598
|
var initialPath = props.initialPath || '';
|
|
3599
|
+
var root = props.root || '';
|
|
3487
3600
|
var state = useState({ loading: true, listing: null, error: '', input: initialPath, creating: false, folderName: '' });
|
|
3488
3601
|
var local = state[0];
|
|
3489
3602
|
var setLocal = state[1];
|
|
@@ -3505,6 +3618,7 @@ function fileIconSpec(name, isDir) {
|
|
|
3505
3618
|
|
|
3506
3619
|
function navigate(path) {
|
|
3507
3620
|
if (!path) return;
|
|
3621
|
+
if (root && !pathInsideOf(root, path)) return;
|
|
3508
3622
|
load(path);
|
|
3509
3623
|
}
|
|
3510
3624
|
|
|
@@ -3607,7 +3721,7 @@ function fileIconSpec(name, isDir) {
|
|
|
3607
3721
|
h('div', { className: 'rw-actions' },
|
|
3608
3722
|
h('button', {
|
|
3609
3723
|
type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary',
|
|
3610
|
-
disabled: !listing || local.loading,
|
|
3724
|
+
disabled: !listing || local.loading || (root && !pathInsideOf(root, listing.path)),
|
|
3611
3725
|
onClick: function () { if (listing && props.onPick) props.onPick(listing.path); },
|
|
3612
3726
|
}, IconCheck(12), t('useThisDir')))),
|
|
3613
3727
|
local.creating
|
|
@@ -3705,6 +3819,279 @@ function fileIconSpec(name, isDir) {
|
|
|
3705
3819
|
}, IconCheck(12), t('useThisDir'))));
|
|
3706
3820
|
}
|
|
3707
3821
|
|
|
3822
|
+
function ScopePathPicker(props) {
|
|
3823
|
+
var t = props.t;
|
|
3824
|
+
var locale = props.locale;
|
|
3825
|
+
var scope = props.scope;
|
|
3826
|
+
var root = props.root || '';
|
|
3827
|
+
var state = useState({ loading: true, listing: null, error: '' });
|
|
3828
|
+
var view = state[0];
|
|
3829
|
+
var setView = state[1];
|
|
3830
|
+
|
|
3831
|
+
var load = useCallback(function (path) {
|
|
3832
|
+
setView(function (previous) { return Object.assign({}, previous, { loading: true, error: '' }); });
|
|
3833
|
+
var request = Object.assign({ path: path || root }, scopeParams(scope));
|
|
3834
|
+
apiCall('fs.list', request).then(function (result) {
|
|
3835
|
+
setView({ loading: false, listing: result, error: '' });
|
|
3836
|
+
}).catch(function (error) {
|
|
3837
|
+
setView({ loading: false, listing: null, error: messageOf(error, t, locale) });
|
|
3838
|
+
});
|
|
3839
|
+
}, [scopeKeyOf(scope), root]);
|
|
3840
|
+
|
|
3841
|
+
useEffect(function () { load(props.initialPath || root); }, []);
|
|
3842
|
+
|
|
3843
|
+
var listing = view.listing;
|
|
3844
|
+
var dirs = listing ? (listing.entries || []).filter(function (entry) { return entry.type === 'dir'; }) : [];
|
|
3845
|
+
var parent = listing && listing.path !== root
|
|
3846
|
+
? listing.path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
|
|
3847
|
+
: '';
|
|
3848
|
+
return h('div', { className: 'rw-browser rw-move-browser' },
|
|
3849
|
+
h('div', { className: 'rw-browser-head' },
|
|
3850
|
+
h('button', {
|
|
3851
|
+
type: 'button', className: 'rw-icon-btn', title: t('parentDir'),
|
|
3852
|
+
disabled: view.loading || !parent,
|
|
3853
|
+
onClick: function () { if (parent) load(parent); },
|
|
3854
|
+
}, h('span', { style: { transform: 'rotate(180deg)', display: 'inline-flex' } }, IconChevron(13))),
|
|
3855
|
+
h('div', { className: 'rw-breadcrumb rw-input-mono', title: listing ? listing.path : '' },
|
|
3856
|
+
listing ? listing.path : t('loading')),
|
|
3857
|
+
view.loading ? Spinner(13) : null),
|
|
3858
|
+
h('div', { className: 'rw-browser-list', role: 'list' },
|
|
3859
|
+
view.error
|
|
3860
|
+
? h('div', { className: 'rw-row-msg' }, IconAlert(12), view.error)
|
|
3861
|
+
: dirs.length === 0
|
|
3862
|
+
? h('div', { className: 'rw-row-msg' }, t('noEntries'))
|
|
3863
|
+
: dirs.map(function (entry) {
|
|
3864
|
+
return h('button', {
|
|
3865
|
+
key: entry.path, type: 'button', className: 'rw-browser-row', role: 'listitem',
|
|
3866
|
+
onClick: function () { load(entry.path); },
|
|
3867
|
+
}, h('span', { className: 'rw-dir-icon' }, IconFolder(13, false)), h('span', null, entry.name));
|
|
3868
|
+
})),
|
|
3869
|
+
h('div', { className: 'rw-browser-actions', style: { padding: '8px' } },
|
|
3870
|
+
h('span', { className: 'rw-hint' }, t('moveHint')),
|
|
3871
|
+
h('button', {
|
|
3872
|
+
type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary',
|
|
3873
|
+
disabled: !listing || view.loading,
|
|
3874
|
+
onClick: function () { if (listing) props.onPick(listing.path); },
|
|
3875
|
+
}, IconCheck(12), t('useThisDir'))));
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
function ImagePreview(props) {
|
|
3879
|
+
var ctx = props.ctx;
|
|
3880
|
+
var t = props.t;
|
|
3881
|
+
var locale = props.locale;
|
|
3882
|
+
var scope = props.scope;
|
|
3883
|
+
var local = props.local === true;
|
|
3884
|
+
var filePath = props.path;
|
|
3885
|
+
var state = useState({ loading: true, error: null, data: null });
|
|
3886
|
+
var view = state[0];
|
|
3887
|
+
var setView = state[1];
|
|
3888
|
+
var naturalState = useState(null);
|
|
3889
|
+
var natural = naturalState[0];
|
|
3890
|
+
var setNatural = naturalState[1];
|
|
3891
|
+
var zoomState = useState(1);
|
|
3892
|
+
var zoom = zoomState[0];
|
|
3893
|
+
var setZoom = zoomState[1];
|
|
3894
|
+
var stageRef = useRef(null);
|
|
3895
|
+
var params = scopeParams(scope);
|
|
3896
|
+
var MIN_ZOOM = 0.05;
|
|
3897
|
+
var MAX_ZOOM = 8;
|
|
3898
|
+
|
|
3899
|
+
function clampZoom(value) {
|
|
3900
|
+
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
function zoomBy(factor) {
|
|
3904
|
+
setZoom(function (previous) { return clampZoom(previous * factor); });
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
function fitToStage() {
|
|
3908
|
+
if (!natural || !stageRef.current) return;
|
|
3909
|
+
var width = Math.max(40, stageRef.current.clientWidth - 36);
|
|
3910
|
+
var height = Math.max(40, stageRef.current.clientHeight - 36);
|
|
3911
|
+
setZoom(clampZoom(Math.min(1, width / natural.width, height / natural.height)));
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
var load = useCallback(function () {
|
|
3915
|
+
if (!filePath || (!local && !scope)) {
|
|
3916
|
+
setView({ loading: false, error: new Error(t('noWorkspace')), data: null });
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
setNatural(null);
|
|
3920
|
+
setView({ loading: true, error: null, data: null });
|
|
3921
|
+
var request = local ? { path: filePath } : Object.assign({ path: filePath }, params);
|
|
3922
|
+
apiCall(local ? 'local.media' : 'media.read', request).then(function (result) {
|
|
3923
|
+
setView({ loading: false, error: null, data: result });
|
|
3924
|
+
}).catch(function (error) {
|
|
3925
|
+
setView({ loading: false, error: error, data: null });
|
|
3926
|
+
});
|
|
3927
|
+
}, [scopeKeyOf(scope), filePath, local]);
|
|
3928
|
+
|
|
3929
|
+
useEffect(function () { load(); }, [load]);
|
|
3930
|
+
|
|
3931
|
+
useEffect(function () {
|
|
3932
|
+
setZoom(1);
|
|
3933
|
+
setNatural(null);
|
|
3934
|
+
}, [filePath]);
|
|
3935
|
+
|
|
3936
|
+
useEffect(function () {
|
|
3937
|
+
if (!natural || !stageRef.current) return;
|
|
3938
|
+
var width = Math.max(40, stageRef.current.clientWidth - 36);
|
|
3939
|
+
var height = Math.max(40, stageRef.current.clientHeight - 36);
|
|
3940
|
+
setZoom(clampZoom(Math.min(1, width / natural.width, height / natural.height)));
|
|
3941
|
+
}, [natural]);
|
|
3942
|
+
|
|
3943
|
+
useEffect(function () {
|
|
3944
|
+
var stage = stageRef.current;
|
|
3945
|
+
if (!stage) return;
|
|
3946
|
+
function onWheel(event) {
|
|
3947
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
3948
|
+
event.preventDefault();
|
|
3949
|
+
setZoom(function (previous) {
|
|
3950
|
+
return clampZoom(previous * (event.deltaY < 0 ? 1.2 : 1 / 1.2));
|
|
3951
|
+
});
|
|
3952
|
+
}
|
|
3953
|
+
stage.addEventListener('wheel', onWheel, { passive: false });
|
|
3954
|
+
return function () { stage.removeEventListener('wheel', onWheel); };
|
|
3955
|
+
}, [view.data]);
|
|
3956
|
+
|
|
3957
|
+
var name = basename(filePath) || t('fileTabTitle');
|
|
3958
|
+
var zoomTools = view.data ? h('div', { className: 'rw-image-tools' },
|
|
3959
|
+
h('button', {
|
|
3960
|
+
type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageZoomOut'),
|
|
3961
|
+
onClick: function () { zoomBy(1 / 1.2); },
|
|
3962
|
+
}, '−'),
|
|
3963
|
+
h('span', { className: 'rw-image-zoom' }, Math.round(zoom * 100) + '%'),
|
|
3964
|
+
h('button', {
|
|
3965
|
+
type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageZoomIn'),
|
|
3966
|
+
onClick: function () { zoomBy(1.2); },
|
|
3967
|
+
}, '+'),
|
|
3968
|
+
h('button', {
|
|
3969
|
+
type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageFit'),
|
|
3970
|
+
onClick: fitToStage,
|
|
3971
|
+
}, t('imageFit')),
|
|
3972
|
+
h('button', {
|
|
3973
|
+
type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageActualSize'),
|
|
3974
|
+
onClick: function () { setZoom(1); },
|
|
3975
|
+
}, '1:1')) : null;
|
|
3976
|
+
|
|
3977
|
+
var header = h('div', { className: 'rw-image-head' },
|
|
3978
|
+
h(FileGlyph, { name: name, size: 15 }),
|
|
3979
|
+
h('div', { className: 'rw-editor-title' },
|
|
3980
|
+
h('span', { className: 'rw-editor-name', title: name }, name),
|
|
3981
|
+
h('span', { className: 'rw-editor-path', title: filePath }, filePath)),
|
|
3982
|
+
h('div', { className: 'rw-actions' },
|
|
3983
|
+
zoomTools,
|
|
3984
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: load, disabled: view.loading }, IconRefresh(12), t('reload'))));
|
|
3985
|
+
|
|
3986
|
+
if (view.loading) {
|
|
3987
|
+
return h('div', { className: 'rw-image-preview' }, header,
|
|
3988
|
+
h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')));
|
|
3989
|
+
}
|
|
3990
|
+
if (view.error) {
|
|
3991
|
+
return h('div', { className: 'rw-image-preview' }, header,
|
|
3992
|
+
h('div', { className: 'rw-editor-message' },
|
|
3993
|
+
h(Banner, { kind: 'error' }, t('imageLoadError') + ':' + messageOf(view.error, t, locale)),
|
|
3994
|
+
h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('retry'))));
|
|
3995
|
+
}
|
|
3996
|
+
var data = view.data;
|
|
3997
|
+
var imageStyle = natural
|
|
3998
|
+
? { width: natural.width * zoom + 'px', height: natural.height * zoom + 'px' }
|
|
3999
|
+
: { maxWidth: '100%', maxHeight: '100%' };
|
|
4000
|
+
return h('div', { className: 'rw-image-preview' },
|
|
4001
|
+
header,
|
|
4002
|
+
h('div', { className: 'rw-image-stage', ref: stageRef },
|
|
4003
|
+
h('div', { className: 'rw-image-canvas' },
|
|
4004
|
+
h('img', {
|
|
4005
|
+
className: 'rw-image',
|
|
4006
|
+
src: 'data:' + data.mime + ';base64,' + data.base64,
|
|
4007
|
+
alt: name,
|
|
4008
|
+
style: imageStyle,
|
|
4009
|
+
onLoad: function (event) {
|
|
4010
|
+
var image = event.currentTarget;
|
|
4011
|
+
if (image.naturalWidth && image.naturalHeight) {
|
|
4012
|
+
setNatural({ width: image.naturalWidth, height: image.naturalHeight });
|
|
4013
|
+
}
|
|
4014
|
+
},
|
|
4015
|
+
onError: function () {
|
|
4016
|
+
setView({ loading: false, error: new Error(t('imageLoadError')), data: null });
|
|
4017
|
+
},
|
|
4018
|
+
}))),
|
|
4019
|
+
h('div', { className: 'rw-editor-status' },
|
|
4020
|
+
h('span', { className: 'rw-status-chip' }, data.mime || 'image'),
|
|
4021
|
+
h('span', { className: 'rw-status-chip' }, formatBytes(data.size || 0)),
|
|
4022
|
+
h('span', { className: 'rw-status-chip' }, Math.round(zoom * 100) + '%'),
|
|
4023
|
+
h('span', { className: 'rw-fill', style: { flex: '1 1 auto' } }),
|
|
4024
|
+
h('span', { className: 'rw-status-chip' }, t('previewOnly'))));
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
function HtmlPreview(props) {
|
|
4028
|
+
var ctx = props.ctx;
|
|
4029
|
+
var t = props.t;
|
|
4030
|
+
var locale = props.locale;
|
|
4031
|
+
var scope = props.scope;
|
|
4032
|
+
var local = props.local === true;
|
|
4033
|
+
var filePath = props.path;
|
|
4034
|
+
var state = useState({ loading: true, error: null, data: null });
|
|
4035
|
+
var view = state[0];
|
|
4036
|
+
var setView = state[1];
|
|
4037
|
+
var params = scopeParams(scope);
|
|
4038
|
+
|
|
4039
|
+
var load = useCallback(function () {
|
|
4040
|
+
if (!filePath || (!local && !scope)) {
|
|
4041
|
+
setView({ loading: false, error: new Error(t('noWorkspace')), data: null });
|
|
4042
|
+
return;
|
|
4043
|
+
}
|
|
4044
|
+
setView({ loading: true, error: null, data: null });
|
|
4045
|
+
var request = local ? { path: filePath } : Object.assign({ path: filePath }, params);
|
|
4046
|
+
apiCall(local ? 'local.read' : 'fs.read', request).then(function (result) {
|
|
4047
|
+
if (result.binary || result.tooLarge) {
|
|
4048
|
+
setView({ loading: false, error: new Error(t('htmlLoadError')), data: null });
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
4051
|
+
setView({ loading: false, error: null, data: result });
|
|
4052
|
+
}).catch(function (error) {
|
|
4053
|
+
setView({ loading: false, error: error, data: null });
|
|
4054
|
+
});
|
|
4055
|
+
}, [scopeKeyOf(scope), filePath, local]);
|
|
4056
|
+
|
|
4057
|
+
useEffect(function () { load(); }, [load]);
|
|
4058
|
+
|
|
4059
|
+
var name = basename(filePath) || t('fileTabTitle');
|
|
4060
|
+
var header = h('div', { className: 'rw-image-head' },
|
|
4061
|
+
h(FileGlyph, { name: name, size: 15 }),
|
|
4062
|
+
h('div', { className: 'rw-editor-title' },
|
|
4063
|
+
h('span', { className: 'rw-editor-name', title: name }, name),
|
|
4064
|
+
h('span', { className: 'rw-editor-path', title: filePath }, filePath)),
|
|
4065
|
+
h('div', { className: 'rw-actions' },
|
|
4066
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: load, disabled: view.loading }, IconRefresh(12), t('reload'))));
|
|
4067
|
+
|
|
4068
|
+
if (view.loading) {
|
|
4069
|
+
return h('div', { className: 'rw-html-preview' }, header,
|
|
4070
|
+
h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')));
|
|
4071
|
+
}
|
|
4072
|
+
if (view.error) {
|
|
4073
|
+
return h('div', { className: 'rw-html-preview' }, header,
|
|
4074
|
+
h('div', { className: 'rw-editor-message' },
|
|
4075
|
+
h(Banner, { kind: 'error' }, t('htmlLoadError') + ':' + messageOf(view.error, t, locale)),
|
|
4076
|
+
h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('retry'))));
|
|
4077
|
+
}
|
|
4078
|
+
var data = view.data;
|
|
4079
|
+
return h('div', { className: 'rw-html-preview' },
|
|
4080
|
+
header,
|
|
4081
|
+
h('div', { className: 'rw-html-stage' },
|
|
4082
|
+
h('iframe', {
|
|
4083
|
+
className: 'rw-html-frame',
|
|
4084
|
+
title: name,
|
|
4085
|
+
sandbox: '',
|
|
4086
|
+
srcDoc: data.content || '',
|
|
4087
|
+
})),
|
|
4088
|
+
h('div', { className: 'rw-editor-status' },
|
|
4089
|
+
h('span', { className: 'rw-status-chip' }, 'HTML'),
|
|
4090
|
+
h('span', { className: 'rw-status-chip' }, formatBytes(data.size || 0)),
|
|
4091
|
+
h('span', { className: 'rw-fill', style: { flex: '1 1 auto' } }),
|
|
4092
|
+
h('span', { className: 'rw-status-chip' }, t('htmlScriptsDisabled'))));
|
|
4093
|
+
}
|
|
4094
|
+
|
|
3708
4095
|
// ── editor ─────────────────────────────────────────────────────────────
|
|
3709
4096
|
|
|
3710
4097
|
function colorForToken(colors, type) {
|
|
@@ -3756,6 +4143,10 @@ function fileIconSpec(name, isDir) {
|
|
|
3756
4143
|
var settings = useRuntimeSettings();
|
|
3757
4144
|
var theme = getTheme(resolveEditorTheme(settings));
|
|
3758
4145
|
var language = languageOf(filePath);
|
|
4146
|
+
var csvMode = isCsvPath(filePath);
|
|
4147
|
+
var csvTableState = useState(true);
|
|
4148
|
+
var showCsvTable = csvTableState[0];
|
|
4149
|
+
var setShowCsvTable = csvTableState[1];
|
|
3759
4150
|
|
|
3760
4151
|
var loadState = useState({ loading: true, error: null, file: null });
|
|
3761
4152
|
var load = loadState[0];
|
|
@@ -3880,6 +4271,14 @@ function fileIconSpec(name, isDir) {
|
|
|
3880
4271
|
return count;
|
|
3881
4272
|
}, [content]);
|
|
3882
4273
|
var gutterLines = typeof lineCount === 'number' && lineCount <= 20000;
|
|
4274
|
+
var csvRows = useMemo(function () {
|
|
4275
|
+
if (!csvMode) return [];
|
|
4276
|
+
var text = content.length > 1000000 ? content.slice(0, 1000000) : content;
|
|
4277
|
+
var delimiter = csvDelimiterOf(filePath, text);
|
|
4278
|
+
return parseDelimited(text, delimiter).slice(0, 1000).map(function (row) {
|
|
4279
|
+
return row.slice(0, 80);
|
|
4280
|
+
});
|
|
4281
|
+
}, [content, filePath, csvMode]);
|
|
3883
4282
|
var highlighted = useMemo(function () {
|
|
3884
4283
|
if (!editable) return null;
|
|
3885
4284
|
return highlightNodes(content, language, theme, 180000);
|
|
@@ -3910,13 +4309,20 @@ function fileIconSpec(name, isDir) {
|
|
|
3910
4309
|
props.onClose
|
|
3911
4310
|
? h('button', { type: 'button', className: 'rw-icon-btn', title: t('closeEditor'), onClick: props.onClose }, IconClose(13))
|
|
3912
4311
|
: null,
|
|
4312
|
+
csvMode
|
|
4313
|
+
? h('button', {
|
|
4314
|
+
type: 'button', className: 'rw-btn rw-btn-sm',
|
|
4315
|
+
onClick: function () { setShowCsvTable(function (previous) { return !previous; }); },
|
|
4316
|
+
title: showCsvTable ? t('csvSource') : t('csvTable'),
|
|
4317
|
+
}, showCsvTable ? t('csvSource') : t('csvTable'))
|
|
4318
|
+
: null,
|
|
3913
4319
|
h('button', {
|
|
3914
4320
|
type: 'button', className: 'rw-btn rw-btn-sm', onClick: function () { loadFile(); },
|
|
3915
4321
|
disabled: load.loading || save.saving,
|
|
3916
4322
|
}, IconRefresh(12), t('reload')),
|
|
3917
4323
|
h('button', {
|
|
3918
4324
|
type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: function () { saveFile(false); },
|
|
3919
|
-
disabled: !editable || save.saving,
|
|
4325
|
+
disabled: !editable || save.saving || (csvMode && showCsvTable),
|
|
3920
4326
|
}, save.saving ? Spinner(12) : IconSave(12), save.saving ? t('saving') : t('save'))));
|
|
3921
4327
|
|
|
3922
4328
|
if (load.loading) {
|
|
@@ -3952,46 +4358,66 @@ function fileIconSpec(name, isDir) {
|
|
|
3952
4358
|
: null;
|
|
3953
4359
|
|
|
3954
4360
|
var canView = Boolean(load.file && !load.file.binary && !load.file.tooLarge);
|
|
4361
|
+
var csvColumnCount = 0;
|
|
4362
|
+
csvRows.forEach(function (row) { if (row.length > csvColumnCount) csvColumnCount = row.length; });
|
|
4363
|
+
var csvTable = csvRows.length === 0
|
|
4364
|
+
? h('div', { className: 'rw-editor-message' }, t('csvEmpty'))
|
|
4365
|
+
: h('div', { className: 'rw-csv-wrap' },
|
|
4366
|
+
h('table', { className: 'rw-csv-table' },
|
|
4367
|
+
h('thead', null, h('tr', null,
|
|
4368
|
+
Array.from({ length: csvColumnCount }, function (_, index) {
|
|
4369
|
+
return h('th', { key: 'h' + index, title: csvRows[0][index] || '' }, csvRows[0][index] || '');
|
|
4370
|
+
}))),
|
|
4371
|
+
h('tbody', null, csvRows.slice(1).map(function (row, rowIndex) {
|
|
4372
|
+
return h('tr', { key: 'r' + rowIndex },
|
|
4373
|
+
Array.from({ length: csvColumnCount }, function (_, columnIndex) {
|
|
4374
|
+
var value = row[columnIndex] === undefined ? '' : row[columnIndex];
|
|
4375
|
+
return h('td', { key: 'c' + columnIndex, title: value }, value);
|
|
4376
|
+
}));
|
|
4377
|
+
}))),
|
|
4378
|
+
csvRows.length >= 1000 ? h('div', { className: 'rw-csv-note' }, t('csvTruncated')) : null);
|
|
3955
4379
|
var body = canView
|
|
3956
|
-
?
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
},
|
|
3977
|
-
onScroll: syncScroll,
|
|
3978
|
-
onKeyDown: function (event) {
|
|
3979
|
-
if (!editable) return;
|
|
3980
|
-
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
3981
|
-
event.preventDefault();
|
|
3982
|
-
var target = event.currentTarget;
|
|
3983
|
-
var start = target.selectionStart;
|
|
3984
|
-
var end = target.selectionEnd;
|
|
3985
|
-
var next = content.slice(0, start) + ' ' + content.slice(end);
|
|
3986
|
-
setContent(next);
|
|
4380
|
+
? (csvMode && showCsvTable
|
|
4381
|
+
? csvTable
|
|
4382
|
+
: h('div', { className: 'rw-editor-body' },
|
|
4383
|
+
h('div', { className: 'rw-code' },
|
|
4384
|
+
h('div', { className: 'rw-gutter', ref: gutterRef },
|
|
4385
|
+
gutterLines
|
|
4386
|
+
? h('div', { className: 'rw-gutter-inner' }, Array.from({ length: lineCount }, function (_, index) { return index + 1; }).join('\n'))
|
|
4387
|
+
: null),
|
|
4388
|
+
h('div', { className: 'rw-code-area' },
|
|
4389
|
+
h('pre', { className: 'rw-highlight', ref: highlightRef, 'aria-hidden': 'true' },
|
|
4390
|
+
highlighted || content),
|
|
4391
|
+
h('textarea', {
|
|
4392
|
+
ref: textareaRef,
|
|
4393
|
+
className: 'rw-textarea',
|
|
4394
|
+
readOnly: readOnly || !editable,
|
|
4395
|
+
value: content,
|
|
4396
|
+
spellCheck: false,
|
|
4397
|
+
wrap: 'off',
|
|
4398
|
+
onChange: function (event) {
|
|
4399
|
+
setContent(event.currentTarget.value);
|
|
3987
4400
|
setDirty(true);
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
4401
|
+
setSave({ saving: false, conflict: false, error: '', ok: false });
|
|
4402
|
+
},
|
|
4403
|
+
onScroll: syncScroll,
|
|
4404
|
+
onKeyDown: function (event) {
|
|
4405
|
+
if (!editable) return;
|
|
4406
|
+
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
4407
|
+
event.preventDefault();
|
|
4408
|
+
var target = event.currentTarget;
|
|
4409
|
+
var start = target.selectionStart;
|
|
4410
|
+
var end = target.selectionEnd;
|
|
4411
|
+
var next = content.slice(0, start) + ' ' + content.slice(end);
|
|
4412
|
+
setContent(next);
|
|
4413
|
+
setDirty(true);
|
|
4414
|
+
requestAnimationFrame(function () {
|
|
4415
|
+
target.selectionStart = start + 4;
|
|
4416
|
+
target.selectionEnd = start + 4;
|
|
4417
|
+
});
|
|
4418
|
+
}
|
|
4419
|
+
},
|
|
4420
|
+
})))))
|
|
3995
4421
|
: h('div', { className: 'rw-editor-message' }, notice, h('span', { className: 'rw-muted' }, t('readOnly')));
|
|
3996
4422
|
|
|
3997
4423
|
return h('div', { className: 'rw-editor', style: containerStyle },
|
|
@@ -4495,6 +4921,23 @@ function fileIconSpec(name, isDir) {
|
|
|
4495
4921
|
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('close'))));
|
|
4496
4922
|
}
|
|
4497
4923
|
|
|
4924
|
+
function MoveDialog(props) {
|
|
4925
|
+
var picker = props.side === 'local'
|
|
4926
|
+
? h(LocalPathPicker, {
|
|
4927
|
+
t: props.t, locale: props.locale, initialPath: props.initialPath, root: props.root,
|
|
4928
|
+
onPick: props.onPick,
|
|
4929
|
+
})
|
|
4930
|
+
: h(ScopePathPicker, {
|
|
4931
|
+
t: props.t, locale: props.locale, scope: props.scope, root: props.root,
|
|
4932
|
+
initialPath: props.initialPath, onPick: props.onPick,
|
|
4933
|
+
});
|
|
4934
|
+
return h(DialogShell, { title: props.title, onClose: props.onClose },
|
|
4935
|
+
h('div', { className: 'rw-dialog-message' }, props.message),
|
|
4936
|
+
picker,
|
|
4937
|
+
h('div', { className: 'rw-dialog-actions' },
|
|
4938
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('cancel'))));
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4498
4941
|
function QueueDialog(props) {
|
|
4499
4942
|
return h(DialogShell, { title: props.title, onClose: props.onClose },
|
|
4500
4943
|
props.queue.length === 0
|
|
@@ -5320,6 +5763,56 @@ function fileIconSpec(name, isDir) {
|
|
|
5320
5763
|
}).catch(noticeError);
|
|
5321
5764
|
}
|
|
5322
5765
|
|
|
5766
|
+
function pathUnder(filePath, parentPath) {
|
|
5767
|
+
if (!filePath || !parentPath) return false;
|
|
5768
|
+
var normalized = String(filePath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
5769
|
+
var parent = String(parentPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
5770
|
+
return normalized === parent || normalized.indexOf(parent + '/') === 0 || normalized.indexOf(parent + '\\') === 0;
|
|
5771
|
+
}
|
|
5772
|
+
|
|
5773
|
+
function moveEntry(side, oldPath, targetDir) {
|
|
5774
|
+
var blocked = openFiles.some(function (file) {
|
|
5775
|
+
return file.side === side && pathUnder(file.path, oldPath) && fileDirty[file.key];
|
|
5776
|
+
});
|
|
5777
|
+
if (blocked) {
|
|
5778
|
+
showNotice('error', t('moveDirty'));
|
|
5779
|
+
return;
|
|
5780
|
+
}
|
|
5781
|
+
var method = side === 'remote' ? 'fs.move' : 'local.move';
|
|
5782
|
+
var params = side === 'remote'
|
|
5783
|
+
? Object.assign({ path: oldPath, targetDir: targetDir }, scopeParams(scopeRef))
|
|
5784
|
+
: { path: oldPath, targetDir: targetDir, root: selected.localPath };
|
|
5785
|
+
apiCall(method, params).then(function (result) {
|
|
5786
|
+
var nextPath = result && result.path ? result.path : oldPath;
|
|
5787
|
+
setOpenFiles(function (previous) {
|
|
5788
|
+
return previous.filter(function (file) {
|
|
5789
|
+
return !(file.side === side && pathUnder(file.path, oldPath));
|
|
5790
|
+
});
|
|
5791
|
+
});
|
|
5792
|
+
setActiveFileKey(function (activeKey) {
|
|
5793
|
+
var stillOpen = openFiles.filter(function (file) {
|
|
5794
|
+
return file.key === activeKey && !(file.side === side && pathUnder(file.path, oldPath));
|
|
5795
|
+
});
|
|
5796
|
+
if (stillOpen.length > 0) return activeKey;
|
|
5797
|
+
var remaining = openFiles.filter(function (file) {
|
|
5798
|
+
return !(file.side === side && pathUnder(file.path, oldPath));
|
|
5799
|
+
});
|
|
5800
|
+
return remaining.length > 0 ? remaining[remaining.length - 1].key : null;
|
|
5801
|
+
});
|
|
5802
|
+
setFileDirty(function (previous) {
|
|
5803
|
+
var next = Object.assign({}, previous);
|
|
5804
|
+
openFiles.forEach(function (file) {
|
|
5805
|
+
if (file.side === side && pathUnder(file.path, oldPath)) delete next[file.key];
|
|
5806
|
+
});
|
|
5807
|
+
return next;
|
|
5808
|
+
});
|
|
5809
|
+
setDialog(null);
|
|
5810
|
+
showNotice('success', t('moveDone'));
|
|
5811
|
+
refreshTree();
|
|
5812
|
+
if (nextPath !== oldPath) refreshTree();
|
|
5813
|
+
}).catch(noticeError);
|
|
5814
|
+
}
|
|
5815
|
+
|
|
5323
5816
|
function submitNew(side, dirPath, name, isDir) {
|
|
5324
5817
|
var target = joinChildPath(dirPath, name, side);
|
|
5325
5818
|
var docKind = documentKindOf(name);
|
|
@@ -5399,6 +5892,15 @@ function fileIconSpec(name, isDir) {
|
|
|
5399
5892
|
items.push({ separator: true });
|
|
5400
5893
|
items.push({ label: t('ctxCopyPath'), onSelect: function () { copyPathToClipboard(menu.path); } });
|
|
5401
5894
|
items.push({ separator: true });
|
|
5895
|
+
var rootPath = menu.side === 'remote' ? selected.remotePath : selected.localPath;
|
|
5896
|
+
if (menu.path !== rootPath) {
|
|
5897
|
+
items.push({ label: t('ctxMove'), onSelect: function () {
|
|
5898
|
+
setDialog({
|
|
5899
|
+
kind: 'move', side: menu.side, path: menu.path, name: menu.name, isDir: menu.isDir,
|
|
5900
|
+
initialPath: parentPathOf(menu.path, menu.side),
|
|
5901
|
+
});
|
|
5902
|
+
} });
|
|
5903
|
+
}
|
|
5402
5904
|
items.push({ label: t('ctxRename'), onSelect: function () {
|
|
5403
5905
|
setDialog({ kind: 'rename', side: menu.side, path: menu.path, value: menu.name, title: t('renameTitle'), label: t('renameLabel') });
|
|
5404
5906
|
} });
|
|
@@ -5450,6 +5952,19 @@ function fileIconSpec(name, isDir) {
|
|
|
5450
5952
|
onConfirm: function () { setDialog(null); confirmDelete(dialog); },
|
|
5451
5953
|
});
|
|
5452
5954
|
}
|
|
5955
|
+
if (dialog.kind === 'move') {
|
|
5956
|
+
return h(MoveDialog, {
|
|
5957
|
+
t: t, locale: locale,
|
|
5958
|
+
title: t('moveTitle'),
|
|
5959
|
+
message: t('moveMessage', { name: dialog.name }),
|
|
5960
|
+
side: dialog.side,
|
|
5961
|
+
scope: dialog.side === 'remote' ? sourceScopeOf(selected) : null,
|
|
5962
|
+
root: dialog.side === 'remote' ? selected.remotePath : selected.localPath,
|
|
5963
|
+
initialPath: dialog.initialPath,
|
|
5964
|
+
onClose: function () { setDialog(null); },
|
|
5965
|
+
onPick: function (targetDir) { moveEntry(dialog.side, dialog.path, targetDir); },
|
|
5966
|
+
});
|
|
5967
|
+
}
|
|
5453
5968
|
if (dialog.kind === 'closeDirty') {
|
|
5454
5969
|
return h(ConfirmDialog, {
|
|
5455
5970
|
t: t,
|
|
@@ -5607,7 +6122,21 @@ function fileIconSpec(name, isDir) {
|
|
|
5607
6122
|
var activeFile = openFiles.filter(function (file) { return file.key === activeFileKey; })[0] || null;
|
|
5608
6123
|
var activeDocKind = activeFile ? documentKindOf(activeFile.name) : '';
|
|
5609
6124
|
var contentArea = activeFile
|
|
5610
|
-
? (activeDocKind
|
|
6125
|
+
? (activeDocKind === 'image'
|
|
6126
|
+
? h(ImagePreview, {
|
|
6127
|
+
ctx: ctx, t: t, locale: locale,
|
|
6128
|
+
scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
|
|
6129
|
+
path: activeFile.path,
|
|
6130
|
+
local: activeFile.side === 'local',
|
|
6131
|
+
})
|
|
6132
|
+
: activeDocKind === 'html'
|
|
6133
|
+
? h(HtmlPreview, {
|
|
6134
|
+
ctx: ctx, t: t, locale: locale,
|
|
6135
|
+
scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
|
|
6136
|
+
path: activeFile.path,
|
|
6137
|
+
local: activeFile.side === 'local',
|
|
6138
|
+
})
|
|
6139
|
+
: activeDocKind && activeDocKind !== 'csv'
|
|
5611
6140
|
? h(DocumentPane, {
|
|
5612
6141
|
ctx: ctx, t: t, locale: locale,
|
|
5613
6142
|
scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
|
package/lib/index.js
CHANGED
|
@@ -67,6 +67,18 @@ const EDITABLE_LIMIT = 2 * 1024 * 1024
|
|
|
67
67
|
const PEEK_LIMIT = 512 * 1024
|
|
68
68
|
const READ_CAP = 8 * 1024 * 1024
|
|
69
69
|
const MAX_WRITE_BYTES = 8 * 1024 * 1024
|
|
70
|
+
const MAX_MEDIA_BYTES = 24 * 1024 * 1024
|
|
71
|
+
const IMAGE_MIME = Object.freeze({
|
|
72
|
+
'.png': 'image/png',
|
|
73
|
+
'.jpg': 'image/jpeg',
|
|
74
|
+
'.jpeg': 'image/jpeg',
|
|
75
|
+
'.gif': 'image/gif',
|
|
76
|
+
'.webp': 'image/webp',
|
|
77
|
+
'.bmp': 'image/bmp',
|
|
78
|
+
'.ico': 'image/x-icon',
|
|
79
|
+
'.avif': 'image/avif',
|
|
80
|
+
'.svg': 'image/svg+xml',
|
|
81
|
+
})
|
|
70
82
|
const MAX_NAME_LENGTH = 120
|
|
71
83
|
const CONNECT_TIMEOUT_MS = 20_000
|
|
72
84
|
const OP_TIMEOUT_MS = 60_000
|
|
@@ -87,6 +99,7 @@ const ERROR_STATUS = {
|
|
|
87
99
|
FS_IS_DIR: 400,
|
|
88
100
|
FS_NOT_DIR: 400,
|
|
89
101
|
FS_TOO_LARGE: 413,
|
|
102
|
+
FS_EXISTS: 409,
|
|
90
103
|
CONFLICT: 409,
|
|
91
104
|
INTERNAL: 500,
|
|
92
105
|
}
|
|
@@ -295,6 +308,12 @@ function isInside(root, target) {
|
|
|
295
308
|
return next === base || next.startsWith(`${base}/`)
|
|
296
309
|
}
|
|
297
310
|
|
|
311
|
+
function isInsideLocal(root, target) {
|
|
312
|
+
const base = resolve(root)
|
|
313
|
+
const next = resolve(target)
|
|
314
|
+
return next === base || next.startsWith(`${base}${sep}`)
|
|
315
|
+
}
|
|
316
|
+
|
|
298
317
|
function withTimeout(promise, ms, label) {
|
|
299
318
|
let timer
|
|
300
319
|
const timeout = new Promise((_, reject) => {
|
|
@@ -1825,6 +1844,24 @@ export function apply(ctx) {
|
|
|
1825
1844
|
}
|
|
1826
1845
|
},
|
|
1827
1846
|
|
|
1847
|
+
async 'media.read'(params = {}) {
|
|
1848
|
+
const scope = await requireScope(params)
|
|
1849
|
+
const target = scopeTarget(scope, params.path)
|
|
1850
|
+
const mime = imageMimeOf(target)
|
|
1851
|
+
if (!mime) throw new ApiError('BAD_REQUEST', '该文件不是支持的图片格式')
|
|
1852
|
+
const stat = await scope.remote.stat(target)
|
|
1853
|
+
if (stat.type === 'dir') throw new ApiError('FS_IS_DIR', '目标是目录,无法预览')
|
|
1854
|
+
if (stat.size > MAX_MEDIA_BYTES) throw new ApiError('FS_TOO_LARGE', '图片超过 24 MB,无法预览')
|
|
1855
|
+
const read = await scope.remote.readFile(target, { maxBytes: MAX_MEDIA_BYTES })
|
|
1856
|
+
return {
|
|
1857
|
+
path: target,
|
|
1858
|
+
size: stat.size,
|
|
1859
|
+
mtime: stat.mtime,
|
|
1860
|
+
mime,
|
|
1861
|
+
base64: read.data.toString('base64'),
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
|
|
1828
1865
|
async 'fs.write'(params = {}) {
|
|
1829
1866
|
const scope = await requireScope(params)
|
|
1830
1867
|
if (typeof params.content !== 'string') throw new ApiError('BAD_REQUEST', 'content is required')
|
|
@@ -2036,6 +2073,27 @@ export function apply(ctx) {
|
|
|
2036
2073
|
return {}
|
|
2037
2074
|
},
|
|
2038
2075
|
|
|
2076
|
+
async 'fs.move'(params = {}) {
|
|
2077
|
+
const scope = await requireScope(params)
|
|
2078
|
+
const source = scopeTarget(scope, params.path)
|
|
2079
|
+
if (source === scope.root) throw new ApiError('BAD_REQUEST', '不能移动工作区根目录')
|
|
2080
|
+
const sourceStat = await scope.remote.stat(source)
|
|
2081
|
+
const targetDir = params.targetDir === undefined || params.targetDir === ''
|
|
2082
|
+
? posix.dirname(source)
|
|
2083
|
+
: scopeTarget(scope, params.targetDir)
|
|
2084
|
+
const dirStat = await scope.remote.stat(targetDir)
|
|
2085
|
+
if (dirStat.type !== 'dir') throw new ApiError('FS_NOT_DIR', '目标不是目录')
|
|
2086
|
+
if (sourceStat.type === 'dir' && (targetDir === source || targetDir.startsWith(`${source}/`))) {
|
|
2087
|
+
throw new ApiError('BAD_REQUEST', '不能把目录移动到自身内部')
|
|
2088
|
+
}
|
|
2089
|
+
const target = posix.join(targetDir, posix.basename(source))
|
|
2090
|
+
if (target === source) return { path: target }
|
|
2091
|
+
if (await scope.remote.stat(target).catch(() => null)) throw new ApiError('FS_EXISTS', '目标目录中已存在同名文件或文件夹')
|
|
2092
|
+
await scope.remote.rename(source, target)
|
|
2093
|
+
await scopeMirrorRemove(scope, source)
|
|
2094
|
+
return { path: target }
|
|
2095
|
+
},
|
|
2096
|
+
|
|
2039
2097
|
async 'fs.delete'(params = {}) {
|
|
2040
2098
|
const scope = await requireScope(params)
|
|
2041
2099
|
const target = scopeTarget(scope, params.path)
|
|
@@ -2209,6 +2267,29 @@ export function apply(ctx) {
|
|
|
2209
2267
|
}
|
|
2210
2268
|
},
|
|
2211
2269
|
|
|
2270
|
+
async 'local.media'(params = {}) {
|
|
2271
|
+
const target = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2272
|
+
if (target === '' || !isAbsolute(target)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
2273
|
+
const absolute = resolve(target)
|
|
2274
|
+
const mime = imageMimeOf(absolute)
|
|
2275
|
+
if (!mime) throw new ApiError('BAD_REQUEST', '该文件不是支持的图片格式')
|
|
2276
|
+
let info
|
|
2277
|
+
try {
|
|
2278
|
+
info = await fsStat(absolute)
|
|
2279
|
+
} catch (error) {
|
|
2280
|
+
throw mapLocalFsError(error)
|
|
2281
|
+
}
|
|
2282
|
+
if (info.isDirectory()) throw new ApiError('FS_IS_DIR', '目标是目录,无法预览')
|
|
2283
|
+
if (info.size > MAX_MEDIA_BYTES) throw new ApiError('FS_TOO_LARGE', '图片超过 24 MB,无法预览')
|
|
2284
|
+
let data
|
|
2285
|
+
try {
|
|
2286
|
+
data = readFileSync(absolute)
|
|
2287
|
+
} catch (error) {
|
|
2288
|
+
throw mapLocalFsError(error)
|
|
2289
|
+
}
|
|
2290
|
+
return { path: absolute, size: info.size, mtime: info.mtimeMs, mime, base64: data.toString('base64') }
|
|
2291
|
+
},
|
|
2292
|
+
|
|
2212
2293
|
async 'local.write'(params = {}) {
|
|
2213
2294
|
const target = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2214
2295
|
if (target === '' || !isAbsolute(target)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
@@ -2255,6 +2336,46 @@ export function apply(ctx) {
|
|
|
2255
2336
|
return { path: resolve(to) }
|
|
2256
2337
|
},
|
|
2257
2338
|
|
|
2339
|
+
async 'local.move'(params = {}) {
|
|
2340
|
+
const source = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2341
|
+
const targetDir = typeof params.targetDir === 'string' ? params.targetDir.trim() : ''
|
|
2342
|
+
if (source === '' || targetDir === '' || !isAbsolute(source) || !isAbsolute(targetDir)) {
|
|
2343
|
+
throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
2344
|
+
}
|
|
2345
|
+
const root = typeof params.root === 'string' && params.root.trim() !== '' ? resolve(params.root.trim()) : ''
|
|
2346
|
+
const sourcePath = resolve(source)
|
|
2347
|
+
const dirPath = resolve(targetDir)
|
|
2348
|
+
if (root && sourcePath === root) throw new ApiError('BAD_REQUEST', '不能移动工作区根目录')
|
|
2349
|
+
if (root && (!isInsideLocal(root, sourcePath) || !isInsideLocal(root, dirPath))) {
|
|
2350
|
+
throw new ApiError('BAD_REQUEST', '移动范围超出本地目录')
|
|
2351
|
+
}
|
|
2352
|
+
let sourceInfo
|
|
2353
|
+
try {
|
|
2354
|
+
sourceInfo = await fsStat(sourcePath)
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
throw mapLocalFsError(error)
|
|
2357
|
+
}
|
|
2358
|
+
let dirInfo
|
|
2359
|
+
try {
|
|
2360
|
+
dirInfo = await fsStat(dirPath)
|
|
2361
|
+
} catch (error) {
|
|
2362
|
+
throw mapLocalFsError(error)
|
|
2363
|
+
}
|
|
2364
|
+
if (!dirInfo.isDirectory()) throw new ApiError('FS_NOT_DIR', '目标不是目录')
|
|
2365
|
+
if (sourceInfo.isDirectory() && (dirPath === sourcePath || dirPath.startsWith(sourcePath + sep))) {
|
|
2366
|
+
throw new ApiError('BAD_REQUEST', '不能把目录移动到自身内部')
|
|
2367
|
+
}
|
|
2368
|
+
const target = join(dirPath, basename(sourcePath))
|
|
2369
|
+
if (target === sourcePath) return { path: target }
|
|
2370
|
+
if (existsSync(target)) throw new ApiError('FS_EXISTS', '目标目录中已存在同名文件或文件夹')
|
|
2371
|
+
try {
|
|
2372
|
+
renameSync(sourcePath, target)
|
|
2373
|
+
} catch (error) {
|
|
2374
|
+
throw mapLocalFsError(error)
|
|
2375
|
+
}
|
|
2376
|
+
return { path: target, type: sourceInfo.isDirectory() ? 'dir' : 'file' }
|
|
2377
|
+
},
|
|
2378
|
+
|
|
2258
2379
|
async 'local.delete'(params = {}) {
|
|
2259
2380
|
const target = typeof params.path === 'string' ? params.path.trim() : ''
|
|
2260
2381
|
if (target === '' || !isAbsolute(target)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
|
|
@@ -3099,6 +3220,10 @@ function loopbackRejection(req) {
|
|
|
3099
3220
|
|
|
3100
3221
|
// ── file helpers ────────────────────────────────────────────────────────────
|
|
3101
3222
|
|
|
3223
|
+
function imageMimeOf(filePath) {
|
|
3224
|
+
return IMAGE_MIME[extname(String(filePath || '')).toLowerCase()] || ''
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3102
3227
|
function looksBinary(buffer) {
|
|
3103
3228
|
const sample = buffer.subarray(0, Math.min(buffer.length, 8192))
|
|
3104
3229
|
for (let index = 0; index < sample.length; index += 1) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-linked-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|