dsh-linked-workspace 0.7.0 → 0.8.0
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 +7 -0
- package/README.en.md +2 -1
- package/README.md +2 -1
- package/docs/API.md +4 -0
- package/docs/VERIFY.md +10 -0
- package/lib/client.js +393 -41
- package/lib/index.js +125 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 — 2026-11-09
|
|
4
|
+
|
|
5
|
+
- **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).
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- New host APIs `media.read`, `fs.move`, `local.media`, `local.move`; `FS_EXISTS` now maps to HTTP 409.
|
|
9
|
+
|
|
3
10
|
## 0.7.0 — 2026-11-09
|
|
4
11
|
|
|
5
12
|
- **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 and CSV preview**: PNG/JPG/GIF/WebP/BMP/ICO/AVIF/SVG render directly in the content panel (up to 24 MB). 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.
|
|
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);CSV/TSV 默认按表格显示,可在「表格 / 源码」间切换,源码视图仍可编辑保存。超过 1000 行或 80 列时只展示截断后的表格。
|
|
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,13 @@ 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. 移动选择器不能浏览到当前工作区/本地目录之外,根目录本身不可作为被移动项。
|
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,19 @@ function fileIconSpec(name, isDir) {
|
|
|
1998
2005
|
tooLarge: '文件过大({{size}}),暂不支持在侧边栏内编辑。',
|
|
1999
2006
|
truncated: '内容已截断',
|
|
2000
2007
|
fileLoadError: '无法打开文件',
|
|
2008
|
+
imageLoadError: '无法预览图片',
|
|
2009
|
+
previewOnly: '图片预览',
|
|
2010
|
+
csvTable: '表格',
|
|
2011
|
+
csvSource: '源码',
|
|
2012
|
+
csvEmpty: '没有可显示的数据',
|
|
2013
|
+
csvTruncated: '仅显示前 1000 行',
|
|
2014
|
+
moveTitle: '移动到…',
|
|
2015
|
+
moveMessage: '选择「{{name}}」要移动到的目录',
|
|
2016
|
+
moveDone: '已移动',
|
|
2017
|
+
moveDirty: '该文件有未保存修改,请先保存或关闭后再移动',
|
|
2018
|
+
moveFailed: '移动失败',
|
|
2019
|
+
moveHint: '进入目标文件夹后点击「使用此目录」',
|
|
2020
|
+
ctxMove: '移动到…',
|
|
2001
2021
|
noWorkspace: '远程工作区不存在,请重新添加。',
|
|
2002
2022
|
noTarget: '没有可用的远程目录。先在设置 → 连接中添加,或点击左侧「添加工作区」。',
|
|
2003
2023
|
treeEmpty: '空目录',
|
|
@@ -2308,6 +2328,19 @@ function fileIconSpec(name, isDir) {
|
|
|
2308
2328
|
tooLarge: 'File too large ({{size}}); editing it in the sidebar is not supported yet.',
|
|
2309
2329
|
truncated: 'Content truncated',
|
|
2310
2330
|
fileLoadError: 'Cannot open the file',
|
|
2331
|
+
imageLoadError: 'Cannot preview this image',
|
|
2332
|
+
previewOnly: 'Image preview',
|
|
2333
|
+
csvTable: 'Table',
|
|
2334
|
+
csvSource: 'Source',
|
|
2335
|
+
csvEmpty: 'No rows to show',
|
|
2336
|
+
csvTruncated: 'Showing the first 1000 rows',
|
|
2337
|
+
moveTitle: 'Move to…',
|
|
2338
|
+
moveMessage: 'Choose the destination folder for "{{name}}"',
|
|
2339
|
+
moveDone: 'Moved',
|
|
2340
|
+
moveDirty: 'This file has unsaved changes; save or close it before moving',
|
|
2341
|
+
moveFailed: 'Move failed',
|
|
2342
|
+
moveHint: 'Open the destination folder, then click “Use this directory”',
|
|
2343
|
+
ctxMove: 'Move to…',
|
|
2311
2344
|
noWorkspace: 'This remote workspace no longer exists. Add it again.',
|
|
2312
2345
|
noTarget: 'No remote directory is available. Add one in Settings → Connections, or click “Add workspace” on the left.',
|
|
2313
2346
|
treeEmpty: 'Empty directory',
|
|
@@ -2679,9 +2712,58 @@ function fileIconSpec(name, isDir) {
|
|
|
2679
2712
|
if (value === '.docx') return 'docx';
|
|
2680
2713
|
if (value === '.xlsx') return 'xlsx';
|
|
2681
2714
|
if (value === '.pptx') return 'pptx';
|
|
2715
|
+
if (value === '.csv' || value === '.tsv') return 'csv';
|
|
2716
|
+
if (value === '.png' || value === '.jpg' || value === '.jpeg' || value === '.gif'
|
|
2717
|
+
|| value === '.webp' || value === '.bmp' || value === '.ico' || value === '.avif'
|
|
2718
|
+
|| value === '.svg') return 'image';
|
|
2682
2719
|
return '';
|
|
2683
2720
|
}
|
|
2684
2721
|
|
|
2722
|
+
function isCsvPath(path) {
|
|
2723
|
+
var ext = String(path || '').toLowerCase().match(/\.[^./\\]+$/);
|
|
2724
|
+
return Boolean(ext && (ext[0] === '.csv' || ext[0] === '.tsv'));
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
function parseDelimited(text, delimiter) {
|
|
2728
|
+
var rows = [];
|
|
2729
|
+
var row = [];
|
|
2730
|
+
var field = '';
|
|
2731
|
+
var quoted = false;
|
|
2732
|
+
var index = 0;
|
|
2733
|
+
var source = String(text || '');
|
|
2734
|
+
function pushField() { row.push(field); field = ''; }
|
|
2735
|
+
function pushRow() {
|
|
2736
|
+
pushField();
|
|
2737
|
+
rows.push(row);
|
|
2738
|
+
row = [];
|
|
2739
|
+
}
|
|
2740
|
+
while (index < source.length) {
|
|
2741
|
+
var ch = source[index];
|
|
2742
|
+
if (quoted) {
|
|
2743
|
+
if (ch === '"') {
|
|
2744
|
+
if (source[index + 1] === '"') { field += '"'; index += 2; continue; }
|
|
2745
|
+
quoted = false; index += 1; continue;
|
|
2746
|
+
}
|
|
2747
|
+
field += ch; index += 1; continue;
|
|
2748
|
+
}
|
|
2749
|
+
if (ch === '"') { quoted = true; index += 1; continue; }
|
|
2750
|
+
if (ch === delimiter) { pushField(); index += 1; continue; }
|
|
2751
|
+
if (ch === '\r') { index += 1; continue; }
|
|
2752
|
+
if (ch === '\n') { pushRow(); index += 1; continue; }
|
|
2753
|
+
field += ch; index += 1;
|
|
2754
|
+
}
|
|
2755
|
+
if (field !== '' || row.length > 0) pushRow();
|
|
2756
|
+
return rows;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
function csvDelimiterOf(path, text) {
|
|
2760
|
+
if (/\.tsv$/i.test(String(path || ''))) return '\t';
|
|
2761
|
+
var firstLine = String(text || '').split(/\r?\n/, 1)[0] || '';
|
|
2762
|
+
var commas = (firstLine.match(/,/g) || []).length;
|
|
2763
|
+
var semicolons = (firstLine.match(/;/g) || []).length;
|
|
2764
|
+
return semicolons > commas ? ';' : ',';
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2685
2767
|
function sanitizeDocumentHtml(html) {
|
|
2686
2768
|
try {
|
|
2687
2769
|
var parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
|
|
@@ -3128,6 +3210,17 @@ function fileIconSpec(name, isDir) {
|
|
|
3128
3210
|
'.rw-props{display:grid;grid-template-columns:70px minmax(0,1fr);gap:5px 9px;font-size:12px;line-height:18px}',
|
|
3129
3211
|
'.rw-props-key{color:var(--dsw-alias-label-tertiary)}',
|
|
3130
3212
|
'.rw-props-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
3213
|
+
'.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)}',
|
|
3214
|
+
'.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}',
|
|
3215
|
+
'.rw-image-head .rw-actions{display:flex;align-items:center;gap:6px;flex:none}',
|
|
3216
|
+
'.rw-image-stage{display:flex;align-items:center;justify-content:center;flex:1 1 auto;min-height:0;overflow:auto;padding:18px;background:repeating-conic-gradient(var(--dsw-alias-bg-layer-1) 0% 25%,var(--dsw-alias-bg-base) 0% 50%) 50%/18px 18px}',
|
|
3217
|
+
'.rw-image{max-width:100%;max-height:100%;object-fit:contain;border-radius:4px;box-shadow:0 2px 16px rgba(0,0,0,.18)}',
|
|
3218
|
+
'.rw-csv-wrap{flex:1 1 auto;min-height:0;overflow:auto;background:var(--dsw-alias-bg-base)}',
|
|
3219
|
+
'.rw-csv-table{border-collapse:collapse;font:12px/18px ui-monospace,"Cascadia Code",Consolas,monospace;min-width:100%}',
|
|
3220
|
+
'.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}',
|
|
3221
|
+
'.rw-csv-table td{padding:3px 8px;border:1px solid var(--dsw-alias-border-l1);vertical-align:top;max-width:360px;overflow-wrap:anywhere}',
|
|
3222
|
+
'.rw-csv-table tr:nth-child(even) td{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 52%,transparent)}',
|
|
3223
|
+
'.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
3224
|
'.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
3225
|
'.rw-queue-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
3133
3226
|
'.rw-queue-state{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}',
|
|
@@ -3484,6 +3577,7 @@ function fileIconSpec(name, isDir) {
|
|
|
3484
3577
|
var t = props.t;
|
|
3485
3578
|
var locale = props.locale;
|
|
3486
3579
|
var initialPath = props.initialPath || '';
|
|
3580
|
+
var root = props.root || '';
|
|
3487
3581
|
var state = useState({ loading: true, listing: null, error: '', input: initialPath, creating: false, folderName: '' });
|
|
3488
3582
|
var local = state[0];
|
|
3489
3583
|
var setLocal = state[1];
|
|
@@ -3505,6 +3599,7 @@ function fileIconSpec(name, isDir) {
|
|
|
3505
3599
|
|
|
3506
3600
|
function navigate(path) {
|
|
3507
3601
|
if (!path) return;
|
|
3602
|
+
if (root && !pathInsideOf(root, path)) return;
|
|
3508
3603
|
load(path);
|
|
3509
3604
|
}
|
|
3510
3605
|
|
|
@@ -3607,7 +3702,7 @@ function fileIconSpec(name, isDir) {
|
|
|
3607
3702
|
h('div', { className: 'rw-actions' },
|
|
3608
3703
|
h('button', {
|
|
3609
3704
|
type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary',
|
|
3610
|
-
disabled: !listing || local.loading,
|
|
3705
|
+
disabled: !listing || local.loading || (root && !pathInsideOf(root, listing.path)),
|
|
3611
3706
|
onClick: function () { if (listing && props.onPick) props.onPick(listing.path); },
|
|
3612
3707
|
}, IconCheck(12), t('useThisDir')))),
|
|
3613
3708
|
local.creating
|
|
@@ -3705,6 +3800,128 @@ function fileIconSpec(name, isDir) {
|
|
|
3705
3800
|
}, IconCheck(12), t('useThisDir'))));
|
|
3706
3801
|
}
|
|
3707
3802
|
|
|
3803
|
+
function ScopePathPicker(props) {
|
|
3804
|
+
var t = props.t;
|
|
3805
|
+
var locale = props.locale;
|
|
3806
|
+
var scope = props.scope;
|
|
3807
|
+
var root = props.root || '';
|
|
3808
|
+
var state = useState({ loading: true, listing: null, error: '' });
|
|
3809
|
+
var view = state[0];
|
|
3810
|
+
var setView = state[1];
|
|
3811
|
+
|
|
3812
|
+
var load = useCallback(function (path) {
|
|
3813
|
+
setView(function (previous) { return Object.assign({}, previous, { loading: true, error: '' }); });
|
|
3814
|
+
var request = Object.assign({ path: path || root }, scopeParams(scope));
|
|
3815
|
+
apiCall('fs.list', request).then(function (result) {
|
|
3816
|
+
setView({ loading: false, listing: result, error: '' });
|
|
3817
|
+
}).catch(function (error) {
|
|
3818
|
+
setView({ loading: false, listing: null, error: messageOf(error, t, locale) });
|
|
3819
|
+
});
|
|
3820
|
+
}, [scopeKeyOf(scope), root]);
|
|
3821
|
+
|
|
3822
|
+
useEffect(function () { load(props.initialPath || root); }, []);
|
|
3823
|
+
|
|
3824
|
+
var listing = view.listing;
|
|
3825
|
+
var dirs = listing ? (listing.entries || []).filter(function (entry) { return entry.type === 'dir'; }) : [];
|
|
3826
|
+
var parent = listing && listing.path !== root
|
|
3827
|
+
? listing.path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
|
|
3828
|
+
: '';
|
|
3829
|
+
return h('div', { className: 'rw-browser rw-move-browser' },
|
|
3830
|
+
h('div', { className: 'rw-browser-head' },
|
|
3831
|
+
h('button', {
|
|
3832
|
+
type: 'button', className: 'rw-icon-btn', title: t('parentDir'),
|
|
3833
|
+
disabled: view.loading || !parent,
|
|
3834
|
+
onClick: function () { if (parent) load(parent); },
|
|
3835
|
+
}, h('span', { style: { transform: 'rotate(180deg)', display: 'inline-flex' } }, IconChevron(13))),
|
|
3836
|
+
h('div', { className: 'rw-breadcrumb rw-input-mono', title: listing ? listing.path : '' },
|
|
3837
|
+
listing ? listing.path : t('loading')),
|
|
3838
|
+
view.loading ? Spinner(13) : null),
|
|
3839
|
+
h('div', { className: 'rw-browser-list', role: 'list' },
|
|
3840
|
+
view.error
|
|
3841
|
+
? h('div', { className: 'rw-row-msg' }, IconAlert(12), view.error)
|
|
3842
|
+
: dirs.length === 0
|
|
3843
|
+
? h('div', { className: 'rw-row-msg' }, t('noEntries'))
|
|
3844
|
+
: dirs.map(function (entry) {
|
|
3845
|
+
return h('button', {
|
|
3846
|
+
key: entry.path, type: 'button', className: 'rw-browser-row', role: 'listitem',
|
|
3847
|
+
onClick: function () { load(entry.path); },
|
|
3848
|
+
}, h('span', { className: 'rw-dir-icon' }, IconFolder(13, false)), h('span', null, entry.name));
|
|
3849
|
+
})),
|
|
3850
|
+
h('div', { className: 'rw-browser-actions', style: { padding: '8px' } },
|
|
3851
|
+
h('span', { className: 'rw-hint' }, t('moveHint')),
|
|
3852
|
+
h('button', {
|
|
3853
|
+
type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary',
|
|
3854
|
+
disabled: !listing || view.loading,
|
|
3855
|
+
onClick: function () { if (listing) props.onPick(listing.path); },
|
|
3856
|
+
}, IconCheck(12), t('useThisDir'))));
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
function ImagePreview(props) {
|
|
3860
|
+
var ctx = props.ctx;
|
|
3861
|
+
var t = props.t;
|
|
3862
|
+
var locale = props.locale;
|
|
3863
|
+
var scope = props.scope;
|
|
3864
|
+
var local = props.local === true;
|
|
3865
|
+
var filePath = props.path;
|
|
3866
|
+
var state = useState({ loading: true, error: null, data: null });
|
|
3867
|
+
var view = state[0];
|
|
3868
|
+
var setView = state[1];
|
|
3869
|
+
var params = scopeParams(scope);
|
|
3870
|
+
|
|
3871
|
+
var load = useCallback(function () {
|
|
3872
|
+
if (!filePath || (!local && !scope)) {
|
|
3873
|
+
setView({ loading: false, error: new Error(t('noWorkspace')), data: null });
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
setView({ loading: true, error: null, data: null });
|
|
3877
|
+
var request = local ? { path: filePath } : Object.assign({ path: filePath }, params);
|
|
3878
|
+
apiCall(local ? 'local.media' : 'media.read', request).then(function (result) {
|
|
3879
|
+
setView({ loading: false, error: null, data: result });
|
|
3880
|
+
}).catch(function (error) {
|
|
3881
|
+
setView({ loading: false, error: error, data: null });
|
|
3882
|
+
});
|
|
3883
|
+
}, [scopeKeyOf(scope), filePath, local]);
|
|
3884
|
+
|
|
3885
|
+
useEffect(function () { load(); }, [load]);
|
|
3886
|
+
|
|
3887
|
+
var name = basename(filePath) || t('fileTabTitle');
|
|
3888
|
+
var header = h('div', { className: 'rw-image-head' },
|
|
3889
|
+
h(FileGlyph, { name: name, size: 15 }),
|
|
3890
|
+
h('div', { className: 'rw-editor-title' },
|
|
3891
|
+
h('span', { className: 'rw-editor-name', title: name }, name),
|
|
3892
|
+
h('span', { className: 'rw-editor-path', title: filePath }, filePath)),
|
|
3893
|
+
h('div', { className: 'rw-actions' },
|
|
3894
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: load, disabled: view.loading }, IconRefresh(12), t('reload'))));
|
|
3895
|
+
|
|
3896
|
+
if (view.loading) {
|
|
3897
|
+
return h('div', { className: 'rw-image-preview' }, header,
|
|
3898
|
+
h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')));
|
|
3899
|
+
}
|
|
3900
|
+
if (view.error) {
|
|
3901
|
+
return h('div', { className: 'rw-image-preview' }, header,
|
|
3902
|
+
h('div', { className: 'rw-editor-message' },
|
|
3903
|
+
h(Banner, { kind: 'error' }, t('imageLoadError') + ':' + messageOf(view.error, t, locale)),
|
|
3904
|
+
h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('retry'))));
|
|
3905
|
+
}
|
|
3906
|
+
var data = view.data;
|
|
3907
|
+
return h('div', { className: 'rw-image-preview' },
|
|
3908
|
+
header,
|
|
3909
|
+
h('div', { className: 'rw-image-stage' },
|
|
3910
|
+
h('img', {
|
|
3911
|
+
className: 'rw-image',
|
|
3912
|
+
src: 'data:' + data.mime + ';base64,' + data.base64,
|
|
3913
|
+
alt: name,
|
|
3914
|
+
onError: function () {
|
|
3915
|
+
setView({ loading: false, error: new Error(t('imageLoadError')), data: null });
|
|
3916
|
+
},
|
|
3917
|
+
})),
|
|
3918
|
+
h('div', { className: 'rw-editor-status' },
|
|
3919
|
+
h('span', { className: 'rw-status-chip' }, data.mime || 'image'),
|
|
3920
|
+
h('span', { className: 'rw-status-chip' }, formatBytes(data.size || 0)),
|
|
3921
|
+
h('span', { className: 'rw-fill', style: { flex: '1 1 auto' } }),
|
|
3922
|
+
h('span', { className: 'rw-status-chip' }, t('previewOnly'))));
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3708
3925
|
// ── editor ─────────────────────────────────────────────────────────────
|
|
3709
3926
|
|
|
3710
3927
|
function colorForToken(colors, type) {
|
|
@@ -3756,6 +3973,10 @@ function fileIconSpec(name, isDir) {
|
|
|
3756
3973
|
var settings = useRuntimeSettings();
|
|
3757
3974
|
var theme = getTheme(resolveEditorTheme(settings));
|
|
3758
3975
|
var language = languageOf(filePath);
|
|
3976
|
+
var csvMode = isCsvPath(filePath);
|
|
3977
|
+
var csvTableState = useState(true);
|
|
3978
|
+
var showCsvTable = csvTableState[0];
|
|
3979
|
+
var setShowCsvTable = csvTableState[1];
|
|
3759
3980
|
|
|
3760
3981
|
var loadState = useState({ loading: true, error: null, file: null });
|
|
3761
3982
|
var load = loadState[0];
|
|
@@ -3880,6 +4101,14 @@ function fileIconSpec(name, isDir) {
|
|
|
3880
4101
|
return count;
|
|
3881
4102
|
}, [content]);
|
|
3882
4103
|
var gutterLines = typeof lineCount === 'number' && lineCount <= 20000;
|
|
4104
|
+
var csvRows = useMemo(function () {
|
|
4105
|
+
if (!csvMode) return [];
|
|
4106
|
+
var text = content.length > 1000000 ? content.slice(0, 1000000) : content;
|
|
4107
|
+
var delimiter = csvDelimiterOf(filePath, text);
|
|
4108
|
+
return parseDelimited(text, delimiter).slice(0, 1000).map(function (row) {
|
|
4109
|
+
return row.slice(0, 80);
|
|
4110
|
+
});
|
|
4111
|
+
}, [content, filePath, csvMode]);
|
|
3883
4112
|
var highlighted = useMemo(function () {
|
|
3884
4113
|
if (!editable) return null;
|
|
3885
4114
|
return highlightNodes(content, language, theme, 180000);
|
|
@@ -3910,13 +4139,20 @@ function fileIconSpec(name, isDir) {
|
|
|
3910
4139
|
props.onClose
|
|
3911
4140
|
? h('button', { type: 'button', className: 'rw-icon-btn', title: t('closeEditor'), onClick: props.onClose }, IconClose(13))
|
|
3912
4141
|
: null,
|
|
4142
|
+
csvMode
|
|
4143
|
+
? h('button', {
|
|
4144
|
+
type: 'button', className: 'rw-btn rw-btn-sm',
|
|
4145
|
+
onClick: function () { setShowCsvTable(function (previous) { return !previous; }); },
|
|
4146
|
+
title: showCsvTable ? t('csvSource') : t('csvTable'),
|
|
4147
|
+
}, showCsvTable ? t('csvSource') : t('csvTable'))
|
|
4148
|
+
: null,
|
|
3913
4149
|
h('button', {
|
|
3914
4150
|
type: 'button', className: 'rw-btn rw-btn-sm', onClick: function () { loadFile(); },
|
|
3915
4151
|
disabled: load.loading || save.saving,
|
|
3916
4152
|
}, IconRefresh(12), t('reload')),
|
|
3917
4153
|
h('button', {
|
|
3918
4154
|
type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: function () { saveFile(false); },
|
|
3919
|
-
disabled: !editable || save.saving,
|
|
4155
|
+
disabled: !editable || save.saving || (csvMode && showCsvTable),
|
|
3920
4156
|
}, save.saving ? Spinner(12) : IconSave(12), save.saving ? t('saving') : t('save'))));
|
|
3921
4157
|
|
|
3922
4158
|
if (load.loading) {
|
|
@@ -3952,46 +4188,66 @@ function fileIconSpec(name, isDir) {
|
|
|
3952
4188
|
: null;
|
|
3953
4189
|
|
|
3954
4190
|
var canView = Boolean(load.file && !load.file.binary && !load.file.tooLarge);
|
|
4191
|
+
var csvColumnCount = 0;
|
|
4192
|
+
csvRows.forEach(function (row) { if (row.length > csvColumnCount) csvColumnCount = row.length; });
|
|
4193
|
+
var csvTable = csvRows.length === 0
|
|
4194
|
+
? h('div', { className: 'rw-editor-message' }, t('csvEmpty'))
|
|
4195
|
+
: h('div', { className: 'rw-csv-wrap' },
|
|
4196
|
+
h('table', { className: 'rw-csv-table' },
|
|
4197
|
+
h('thead', null, h('tr', null,
|
|
4198
|
+
Array.from({ length: csvColumnCount }, function (_, index) {
|
|
4199
|
+
return h('th', { key: 'h' + index, title: csvRows[0][index] || '' }, csvRows[0][index] || '');
|
|
4200
|
+
}))),
|
|
4201
|
+
h('tbody', null, csvRows.slice(1).map(function (row, rowIndex) {
|
|
4202
|
+
return h('tr', { key: 'r' + rowIndex },
|
|
4203
|
+
Array.from({ length: csvColumnCount }, function (_, columnIndex) {
|
|
4204
|
+
var value = row[columnIndex] === undefined ? '' : row[columnIndex];
|
|
4205
|
+
return h('td', { key: 'c' + columnIndex, title: value }, value);
|
|
4206
|
+
}));
|
|
4207
|
+
}))),
|
|
4208
|
+
csvRows.length >= 1000 ? h('div', { className: 'rw-csv-note' }, t('csvTruncated')) : null);
|
|
3955
4209
|
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);
|
|
4210
|
+
? (csvMode && showCsvTable
|
|
4211
|
+
? csvTable
|
|
4212
|
+
: h('div', { className: 'rw-editor-body' },
|
|
4213
|
+
h('div', { className: 'rw-code' },
|
|
4214
|
+
h('div', { className: 'rw-gutter', ref: gutterRef },
|
|
4215
|
+
gutterLines
|
|
4216
|
+
? h('div', { className: 'rw-gutter-inner' }, Array.from({ length: lineCount }, function (_, index) { return index + 1; }).join('\n'))
|
|
4217
|
+
: null),
|
|
4218
|
+
h('div', { className: 'rw-code-area' },
|
|
4219
|
+
h('pre', { className: 'rw-highlight', ref: highlightRef, 'aria-hidden': 'true' },
|
|
4220
|
+
highlighted || content),
|
|
4221
|
+
h('textarea', {
|
|
4222
|
+
ref: textareaRef,
|
|
4223
|
+
className: 'rw-textarea',
|
|
4224
|
+
readOnly: readOnly || !editable,
|
|
4225
|
+
value: content,
|
|
4226
|
+
spellCheck: false,
|
|
4227
|
+
wrap: 'off',
|
|
4228
|
+
onChange: function (event) {
|
|
4229
|
+
setContent(event.currentTarget.value);
|
|
3987
4230
|
setDirty(true);
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
4231
|
+
setSave({ saving: false, conflict: false, error: '', ok: false });
|
|
4232
|
+
},
|
|
4233
|
+
onScroll: syncScroll,
|
|
4234
|
+
onKeyDown: function (event) {
|
|
4235
|
+
if (!editable) return;
|
|
4236
|
+
if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
4237
|
+
event.preventDefault();
|
|
4238
|
+
var target = event.currentTarget;
|
|
4239
|
+
var start = target.selectionStart;
|
|
4240
|
+
var end = target.selectionEnd;
|
|
4241
|
+
var next = content.slice(0, start) + ' ' + content.slice(end);
|
|
4242
|
+
setContent(next);
|
|
4243
|
+
setDirty(true);
|
|
4244
|
+
requestAnimationFrame(function () {
|
|
4245
|
+
target.selectionStart = start + 4;
|
|
4246
|
+
target.selectionEnd = start + 4;
|
|
4247
|
+
});
|
|
4248
|
+
}
|
|
4249
|
+
},
|
|
4250
|
+
})))))
|
|
3995
4251
|
: h('div', { className: 'rw-editor-message' }, notice, h('span', { className: 'rw-muted' }, t('readOnly')));
|
|
3996
4252
|
|
|
3997
4253
|
return h('div', { className: 'rw-editor', style: containerStyle },
|
|
@@ -4495,6 +4751,23 @@ function fileIconSpec(name, isDir) {
|
|
|
4495
4751
|
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('close'))));
|
|
4496
4752
|
}
|
|
4497
4753
|
|
|
4754
|
+
function MoveDialog(props) {
|
|
4755
|
+
var picker = props.side === 'local'
|
|
4756
|
+
? h(LocalPathPicker, {
|
|
4757
|
+
t: props.t, locale: props.locale, initialPath: props.initialPath, root: props.root,
|
|
4758
|
+
onPick: props.onPick,
|
|
4759
|
+
})
|
|
4760
|
+
: h(ScopePathPicker, {
|
|
4761
|
+
t: props.t, locale: props.locale, scope: props.scope, root: props.root,
|
|
4762
|
+
initialPath: props.initialPath, onPick: props.onPick,
|
|
4763
|
+
});
|
|
4764
|
+
return h(DialogShell, { title: props.title, onClose: props.onClose },
|
|
4765
|
+
h('div', { className: 'rw-dialog-message' }, props.message),
|
|
4766
|
+
picker,
|
|
4767
|
+
h('div', { className: 'rw-dialog-actions' },
|
|
4768
|
+
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('cancel'))));
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4498
4771
|
function QueueDialog(props) {
|
|
4499
4772
|
return h(DialogShell, { title: props.title, onClose: props.onClose },
|
|
4500
4773
|
props.queue.length === 0
|
|
@@ -5320,6 +5593,56 @@ function fileIconSpec(name, isDir) {
|
|
|
5320
5593
|
}).catch(noticeError);
|
|
5321
5594
|
}
|
|
5322
5595
|
|
|
5596
|
+
function pathUnder(filePath, parentPath) {
|
|
5597
|
+
if (!filePath || !parentPath) return false;
|
|
5598
|
+
var normalized = String(filePath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
5599
|
+
var parent = String(parentPath).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
5600
|
+
return normalized === parent || normalized.indexOf(parent + '/') === 0 || normalized.indexOf(parent + '\\') === 0;
|
|
5601
|
+
}
|
|
5602
|
+
|
|
5603
|
+
function moveEntry(side, oldPath, targetDir) {
|
|
5604
|
+
var blocked = openFiles.some(function (file) {
|
|
5605
|
+
return file.side === side && pathUnder(file.path, oldPath) && fileDirty[file.key];
|
|
5606
|
+
});
|
|
5607
|
+
if (blocked) {
|
|
5608
|
+
showNotice('error', t('moveDirty'));
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
5611
|
+
var method = side === 'remote' ? 'fs.move' : 'local.move';
|
|
5612
|
+
var params = side === 'remote'
|
|
5613
|
+
? Object.assign({ path: oldPath, targetDir: targetDir }, scopeParams(scopeRef))
|
|
5614
|
+
: { path: oldPath, targetDir: targetDir, root: selected.localPath };
|
|
5615
|
+
apiCall(method, params).then(function (result) {
|
|
5616
|
+
var nextPath = result && result.path ? result.path : oldPath;
|
|
5617
|
+
setOpenFiles(function (previous) {
|
|
5618
|
+
return previous.filter(function (file) {
|
|
5619
|
+
return !(file.side === side && pathUnder(file.path, oldPath));
|
|
5620
|
+
});
|
|
5621
|
+
});
|
|
5622
|
+
setActiveFileKey(function (activeKey) {
|
|
5623
|
+
var stillOpen = openFiles.filter(function (file) {
|
|
5624
|
+
return file.key === activeKey && !(file.side === side && pathUnder(file.path, oldPath));
|
|
5625
|
+
});
|
|
5626
|
+
if (stillOpen.length > 0) return activeKey;
|
|
5627
|
+
var remaining = openFiles.filter(function (file) {
|
|
5628
|
+
return !(file.side === side && pathUnder(file.path, oldPath));
|
|
5629
|
+
});
|
|
5630
|
+
return remaining.length > 0 ? remaining[remaining.length - 1].key : null;
|
|
5631
|
+
});
|
|
5632
|
+
setFileDirty(function (previous) {
|
|
5633
|
+
var next = Object.assign({}, previous);
|
|
5634
|
+
openFiles.forEach(function (file) {
|
|
5635
|
+
if (file.side === side && pathUnder(file.path, oldPath)) delete next[file.key];
|
|
5636
|
+
});
|
|
5637
|
+
return next;
|
|
5638
|
+
});
|
|
5639
|
+
setDialog(null);
|
|
5640
|
+
showNotice('success', t('moveDone'));
|
|
5641
|
+
refreshTree();
|
|
5642
|
+
if (nextPath !== oldPath) refreshTree();
|
|
5643
|
+
}).catch(noticeError);
|
|
5644
|
+
}
|
|
5645
|
+
|
|
5323
5646
|
function submitNew(side, dirPath, name, isDir) {
|
|
5324
5647
|
var target = joinChildPath(dirPath, name, side);
|
|
5325
5648
|
var docKind = documentKindOf(name);
|
|
@@ -5399,6 +5722,15 @@ function fileIconSpec(name, isDir) {
|
|
|
5399
5722
|
items.push({ separator: true });
|
|
5400
5723
|
items.push({ label: t('ctxCopyPath'), onSelect: function () { copyPathToClipboard(menu.path); } });
|
|
5401
5724
|
items.push({ separator: true });
|
|
5725
|
+
var rootPath = menu.side === 'remote' ? selected.remotePath : selected.localPath;
|
|
5726
|
+
if (menu.path !== rootPath) {
|
|
5727
|
+
items.push({ label: t('ctxMove'), onSelect: function () {
|
|
5728
|
+
setDialog({
|
|
5729
|
+
kind: 'move', side: menu.side, path: menu.path, name: menu.name, isDir: menu.isDir,
|
|
5730
|
+
initialPath: parentPathOf(menu.path, menu.side),
|
|
5731
|
+
});
|
|
5732
|
+
} });
|
|
5733
|
+
}
|
|
5402
5734
|
items.push({ label: t('ctxRename'), onSelect: function () {
|
|
5403
5735
|
setDialog({ kind: 'rename', side: menu.side, path: menu.path, value: menu.name, title: t('renameTitle'), label: t('renameLabel') });
|
|
5404
5736
|
} });
|
|
@@ -5450,6 +5782,19 @@ function fileIconSpec(name, isDir) {
|
|
|
5450
5782
|
onConfirm: function () { setDialog(null); confirmDelete(dialog); },
|
|
5451
5783
|
});
|
|
5452
5784
|
}
|
|
5785
|
+
if (dialog.kind === 'move') {
|
|
5786
|
+
return h(MoveDialog, {
|
|
5787
|
+
t: t, locale: locale,
|
|
5788
|
+
title: t('moveTitle'),
|
|
5789
|
+
message: t('moveMessage', { name: dialog.name }),
|
|
5790
|
+
side: dialog.side,
|
|
5791
|
+
scope: dialog.side === 'remote' ? sourceScopeOf(selected) : null,
|
|
5792
|
+
root: dialog.side === 'remote' ? selected.remotePath : selected.localPath,
|
|
5793
|
+
initialPath: dialog.initialPath,
|
|
5794
|
+
onClose: function () { setDialog(null); },
|
|
5795
|
+
onPick: function (targetDir) { moveEntry(dialog.side, dialog.path, targetDir); },
|
|
5796
|
+
});
|
|
5797
|
+
}
|
|
5453
5798
|
if (dialog.kind === 'closeDirty') {
|
|
5454
5799
|
return h(ConfirmDialog, {
|
|
5455
5800
|
t: t,
|
|
@@ -5607,7 +5952,14 @@ function fileIconSpec(name, isDir) {
|
|
|
5607
5952
|
var activeFile = openFiles.filter(function (file) { return file.key === activeFileKey; })[0] || null;
|
|
5608
5953
|
var activeDocKind = activeFile ? documentKindOf(activeFile.name) : '';
|
|
5609
5954
|
var contentArea = activeFile
|
|
5610
|
-
? (activeDocKind
|
|
5955
|
+
? (activeDocKind === 'image'
|
|
5956
|
+
? h(ImagePreview, {
|
|
5957
|
+
ctx: ctx, t: t, locale: locale,
|
|
5958
|
+
scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
|
|
5959
|
+
path: activeFile.path,
|
|
5960
|
+
local: activeFile.side === 'local',
|
|
5961
|
+
})
|
|
5962
|
+
: activeDocKind && activeDocKind !== 'csv'
|
|
5611
5963
|
? h(DocumentPane, {
|
|
5612
5964
|
ctx: ctx, t: t, locale: locale,
|
|
5613
5965
|
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.0",
|
|
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",
|