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 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`)、下载到默认文件夹、队列、打开、用记事本编辑、复制、粘贴、复制路径、重命名、删除、更改权限、新建(文件夹/文件)、属性。远端操作走 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,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
- ? 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);
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
- requestAnimationFrame(function () {
3989
- target.selectionStart = start + 4;
3990
- target.selectionEnd = start + 4;
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.7.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",