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 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`)、下载到默认文件夹、队列、打开、用记事本编辑、复制、粘贴、复制路径、重命名、删除、更改权限、新建(文件夹/文件)、属性。远端操作走 SFTP;本地目录的同类操作走本机文件系统。传输队列一次执行一个任务并显示排队/传输中/完成/失败状态。
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
- ? h('div', { className: 'rw-editor-body' },
3957
- h('div', { className: 'rw-code' },
3958
- h('div', { className: 'rw-gutter', ref: gutterRef },
3959
- gutterLines
3960
- ? h('div', { className: 'rw-gutter-inner' }, Array.from({ length: lineCount }, function (_, index) { return index + 1; }).join('\n'))
3961
- : null),
3962
- h('div', { className: 'rw-code-area' },
3963
- h('pre', { className: 'rw-highlight', ref: highlightRef, 'aria-hidden': 'true' },
3964
- highlighted || content),
3965
- h('textarea', {
3966
- ref: textareaRef,
3967
- className: 'rw-textarea',
3968
- readOnly: readOnly || !editable,
3969
- value: content,
3970
- spellCheck: false,
3971
- wrap: 'off',
3972
- onChange: function (event) {
3973
- setContent(event.currentTarget.value);
3974
- setDirty(true);
3975
- setSave({ saving: false, conflict: false, error: '', ok: false });
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
- requestAnimationFrame(function () {
3989
- target.selectionStart = start + 4;
3990
- target.selectionEnd = start + 4;
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.7.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",