dsh-linked-workspace 0.6.1 → 0.7.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — 2026-11-09
4
+
5
+ - **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.
6
+ - **Terminal shortcuts in the directory toolbar**: one button opens a system terminal at the current remote directory (via `ssh`), and linked workspaces get a second button for the local directory.
7
+ - **Keyboard shortcuts**: `Ctrl/Cmd+S` save, `Ctrl/Cmd+W` close tab, `Ctrl+Tab` / `Shift+Ctrl+Tab` cycle tabs, `Ctrl/Cmd+B` toggle the directory panel, `Ctrl/Cmd+Shift+F` file search.
8
+ - **Unsaved-close prompt**: closing a dirty tab now offers Save / Discard / Cancel instead of silently losing edits.
9
+ - **Auto save setting**: Settings → Connections → Editor behavior adds an auto-save switch; when enabled, edits are saved about one second after typing stops. Default remains off.
10
+
11
+ ## 0.6.2 — 2026-11-05
12
+
13
+ - Fixed Markdown/document JSON transport failing with a Windows GBK codec error for characters such as the superscript minus (U+207B); the Python bridge now emits UTF-8/ASCII-safe JSON and configures UTF-8 stdout.
14
+ - PDF files now load lazily: only the current page is rendered; other pages are fetched on demand (`docs.pdfPage`), and a large PDF is downloaded/opened once and cached. This removes the long “加载中” hang on large (30 MB+) PDFs.
15
+ - PDF page navigation, rotation, deletion and metadata remain available.
16
+
3
17
  ## 0.6.1 — 2026-11-05
4
18
 
5
19
  - Fixed the linked-workspace directory tab disappearing for a new or blank linked-workspace session: the client session store is now consulted for cwd/workspaceId before falling back to session.info, so the tab and the start-page option are registered immediately when the workspace session opens.
package/README.en.md CHANGED
@@ -49,18 +49,21 @@ The content panel now edits **Markdown / PDF / DOCX / XLSX / PPTX** files on bot
49
49
  ### Right-sidebar linked-workspace directory
50
50
 
51
51
  - The directory is a fixed panel at the far right of the sidebar: **remote tree on top, local tree below**, both rooted at “All files”. Plugin internals (`.local`/`.remote`) are hidden by default but can be shown with the hidden-files toggle.
52
- - **Single click selects, double click opens**: a single click highlights a row; double-clicking a folder expands/collapses it; double-clicking a file opens it in the content panel while the directory panel stays visible.
52
+ - **Single click opens, double click pins**: clicking a folder expands/collapses it immediately; clicking a file opens it in the content panel, and clicking another file replaces the current unpinned preview tab. Double-click a file to pin its tab so later single clicks do not replace it; double-clicking a folder only keeps it expanded.
53
53
  - **Layout**: conversation → content panel (internal file tabs) → directory panel. The content panel shows “Open a file / Select a file from the workspace directory tree” when nothing is open. A toolbar button shows/hides the directory panel.
54
54
  - Toolbar:
55
55
  - **Tree switch**: all files / changed files / all changes (git status from remote and local sides).
56
56
  - **Search in files**: at least two characters; remote uses `find`, local walks the tree. Results are grouped by remote/local.
57
57
  - **Open options menu**: editor / system default app / copy path.
58
+ - **Open terminal**: the remote button in the remote tree toolbar opens the system terminal and SSHes into the current remote directory; linked workspaces also show a local button that opens a terminal in the local directory. The remote terminal uses the system `ssh` client, so it may ask for a password or host-key confirmation.
58
59
  - **Transfer**: bidirectional. Select a remote file/folder to copy it into the local `.remote/` folder; select a local file/folder to copy it to the corresponding relative remote path. Jobs run one at a time through a queue.
59
60
  - **Show/hide hidden files**: applies to both trees.
60
61
  - **Refresh**: reload roots, git status and search.
61
62
  - File-type icons for JS/TS/Python/JSON/Markdown/Shell/YAML/images/archives/config files, etc.
62
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.
63
- - Multiple files can be open at once (up to 12 slots). Local files use the same internal editor and can be edited and saved.
64
+ - **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
+ - **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
+ - 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.
64
67
 
65
68
  ### Running indicator and local skills
66
69
 
@@ -115,6 +118,14 @@ Old “Remote directories” tabs are restored after refresh through hidden lega
115
118
 
116
119
  See [CHANGELOG.md](CHANGELOG.md) for the full version history.
117
120
 
121
+ ### 0.7.0
122
+
123
+ Single-click tree navigation with preview tabs and double-click pinning, remote/local terminal buttons with `cd`, VSCode-like shortcuts, unsaved-close prompts, and an auto-save switch under Settings → Connections.
124
+
125
+ ### 0.6.2
126
+
127
+ Fix Windows GBK JSON transport errors for Markdown characters such as the superscript minus; PDF pages now load lazily and the source PDF is cached, avoiding long loading hangs on large files.
128
+
118
129
  ### 0.6.1
119
130
 
120
131
  Fix the linked-workspace directory tab and start-page entry disappearing in a new blank linked-workspace session by consulting the client session store for cwd and workspaceId first.
package/README.md CHANGED
@@ -50,18 +50,21 @@
50
50
  ### 右侧栏「联动工作区目录」
51
51
 
52
52
  - 面板固定在右侧栏最右边:**上方远程目录树,下方本地目录树**,两棵树根部都显示「所有文件」;本地树自动隐藏插件的 `.local`/`.remote` 内部目录。
53
- - **单击选中,双击打开**:单击行只选中(高亮),双击文件夹展开/折叠,双击文件在**文件内容面板**的新标签中打开;目录面板固定在最右侧,始终可见。
53
+ - **单击打开,双击固定**:单击文件夹立即展开/折叠;单击文件直接在文件内容面板打开,再次单击其他文件会替换当前未固定的预览标签。双击文件可固定该标签,之后单击其他文件不会覆盖它;双击文件夹只保持展开。目录面板固定在最右侧,始终可见。
54
54
  - **面板布局**:目录树是右侧固定面板(带工具栏按钮开/关,不再是标签页);会话面板与目录面板之间是文件内容面板,没有打开文件时显示「打开文件 / 从工作区目录树中选择文件」占位。
55
55
  - 工具栏(右键栏右上角):
56
56
  - **切换文件树**:所有文件 / 已更改的文件 / 所有更改。更改列表来自远端与本地的 `git status`(分支、状态、暂存标记;删除项置灰)。
57
57
  - **在文件中搜索**:搜索框输入 ≥2 个字符后,远端用 `find`、本地用目录遍历按文件名过滤,结果分「远程目录 / 本地目录」列出。
58
58
  - **打开选项菜单**:编辑器 / 系统默认应用(把远端文件下载到临时目录后交给本机应用打开)/ 复制路径。
59
59
  - **传输按钮(双向)**:选中远端文件/文件夹时传输到本地 `.remote`;选中本地文件/文件夹时传输到远端对应相对路径(进入传输队列,一次一个任务并显示状态)。
60
+ - **打开终端**:远程按钮位于远程目录树右上角,打开本机终端并用系统 `ssh` 直接进入当前远程目录;联动工作区还会显示本地按钮,直接打开本地目录。远程终端首次连接可能需要确认主机指纹或输入密码。
60
61
  - **显示/隐藏隐藏文件**:同时约束远程目录和本地目录;打开目录时默认显示隐藏项(包括 `.local`/`.remote`),可用按钮隐藏。
61
62
  - **刷新**:重新拉取根目录、git 状态与搜索。
62
63
  - 不同文件类型使用不同彩色图标(JS/TS/Python/JSON/Markdown/Shell/YAML/图片/压缩包/配置文件等)。
63
64
  - 编辑器:行号、语法高亮(叠层渲染)、主题配色、`Ctrl/Cmd+S` 保存、冲突提示(重新加载/强制保存)、二进制与 >2 MB 文件只读保护、UTF-8/LF/CRLF/行数状态栏;内嵌内容面板不显示文件路径/保存工具条,未保存修改在文件标签前显示灰点。
64
- - **多个文件同时打开**:文件内容面板内最多同时打开 12 个标签;点文件新增标签,重复点同一文件聚焦原标签;本地文件与远端文件共用内部编辑器,本地文件可编辑保存。
65
+ - **快捷键**:`Ctrl/Cmd+S` 保存当前文件;`Ctrl/Cmd+W` 关闭当前标签(未保存时提示保存/放弃/取消);`Ctrl+Tab` / `Shift+Ctrl+Tab` 切换标签;`Ctrl/Cmd+B` 显示/隐藏目录面板;`Ctrl/Cmd+Shift+F` 打开文件搜索。
66
+ - **自动保存**:设置 → 连接 →「编辑器行为」中开启;停止输入约 1 秒后自动保存。关闭时使用 `Ctrl+S` 手动保存;未保存文件关闭前会弹窗,不会静默丢弃。
67
+ - **多个文件同时打开**:文件内容面板内最多同时打开 12 个标签;单击文件打开预览标签并替换未固定的预览,双击固定标签,重复点击同一文件聚焦原标签;本地文件与远端文件共用内部编辑器,本地文件可编辑保存。
65
68
 
66
69
  ### 运行中图标 / 本地技能
67
70
 
package/docs/API.md CHANGED
@@ -66,8 +66,8 @@ relative to the scope root, or absolute inside it.
66
66
  | `fs.rename` | `{ workspaceId?/linkedId?, from, to }` | `{}` |
67
67
  | `fs.delete` | `{ workspaceId?/linkedId?, path }` | `{}` |
68
68
  | `fs.home` | `{ workspaceId?/linkedId? }` | `{ home }` |
69
- | `settings.get` | `{}` | `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode }` |
70
- | `settings.update` | `{ editorTheme?, editorThemeFamily?, editorThemeMode?, fileOpenMode? }` | `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode }` |
69
+ | `settings.get` | `{}` | `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode, autoSave }` |
70
+ | `settings.update` | `{ editorTheme?, editorThemeFamily?, editorThemeMode?, fileOpenMode?, autoSave? }` | `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode, autoSave }` |
71
71
  | `local.browse` | `{ path? }` | `{ path, parent, home, entries: [{ name, path }] }` |
72
72
  | `local.mkdir` | `{ path, name }` | `{ path }` |
73
73
  | `linked.list` | `{}` | `{ links: LinkInfo[] }` (each link carries `running`) |
@@ -79,6 +79,7 @@ relative to the scope root, or absolute inside it.
79
79
  | `linked.syncCancel` | `{ id }` | `{ cancelled: boolean }` |
80
80
  | `local.list` | `{ path }` | `{ path, entries: [{ name, path, type, size, mtime }] }` |
81
81
  | `local.openExternal` | `{ path }` | `{ opened, path }` |
82
+ | `terminal.open` | `{ workspaceId?/linkedId?, side: 'remote'\|'local' }` | `{ opened, side, path, host? }` |
82
83
  | `fs.openExternal` | `{ workspaceId?/linkedId?, path }` | `{ opened, localPath, size }` |
83
84
  | `localWorkspace.create` | `{ path, name? }` | `{ workspaceId, title, path }` |
84
85
  | `session.info` | `{ sessionId }` | `{ sessionId, cwd, running }` |
@@ -112,7 +113,7 @@ Under `$DSH_HOME/linked-workspace/`:
112
113
 
113
114
  - `workspaces.json` — direct remote directories (no credentials).
114
115
  - `linked.json` — linked workspaces (no credentials).
115
- - `settings.json` — `{ editorTheme, fileOpenMode }`.
116
+ - `settings.json` — `{ editorTheme, editorThemeFamily, editorThemeMode, fileOpenMode, autoSave }`.
116
117
  - `known_hosts.json` — trust-on-first-use host key fingerprints.
117
118
  - Local linked directories also hold `.local/linked.json` (same binding without
118
119
  secrets) and the optional `.remote/` mirror.
package/docs/VERIFY.md CHANGED
@@ -71,18 +71,22 @@ Host code (lib/) reloads live with `plugin_manager set_plugin include:linked-wor
71
71
  1. 会话标题栏「打开右侧边栏」→「+」→「联动工作区目录」;旧标签页刷新后标题也会自动改成该名字。
72
72
  2. 面板:右侧固定目录面板包含**上方「远程目录」、下方「本地目录」**;工具栏的面板按钮可开/关整个目录面板。中间是文件内容面板;未打开文件时显示「打开文件 / 从工作区目录树中选择文件」。本地树默认隐藏 `.local`/`.remote`,开「显示隐藏文件」后可见。
73
73
  3. 展开工作区 → 目录树懒加载;文件行有按类型区分的彩色图标(.py/.ts/.json/.md/.png 等)。
74
- 4. **单击**文件/文件夹只选中(高亮,不打开);**双击**文件夹展开/折叠,**双击**文件在中间的文件内容面板新标签中打开;目录面板保持最右侧可见。本地文件标签不显示图标,并且可在内容面板中编辑保存。
74
+ 4. **单击**文件夹立即展开/折叠;**单击**文件在中间内容面板打开;单击另一个文件会替换未固定的预览标签。**双击**文件固定标签,之后单击其他文件不会覆盖;双击文件夹保持展开。目录面板保持最右侧可见。本地文件标签不显示图标,并且可在内容面板中编辑保存。
75
75
  5. 工具栏「切换文件树」:所有文件 / 已更改的文件 / 所有更改;远端仓库(如 nsl-wp)应列出 `git status` 修改与未跟踪文件,本地非 git 目录提示「该目录不是 Git 仓库」。
76
76
  6. 工具栏「在文件中搜索」:输入 `paradis` → 结果按「远程目录/本地目录」分组,点击结果打开文件;清空/关闭后回到原树。
77
77
  7. 工具栏「打开选项菜单」:编辑器 / 系统默认应用(远端文件下载到临时目录并用本机默认应用打开)/ 复制路径(剪贴板得到远端绝对路径)。
78
78
  8. 刷新按钮:目录、git 状态与搜索重新加载。
79
- 9. **多标签**:依次点开两个远端文件 → 出现两个独立文件标签,目录标签仍在;重复点同一文件聚焦原标签;关闭一个标签后其槽位可复用(最多 12 个)。
80
- 10. **传输按钮**:在树中选中远端文件/文件夹(行高亮),点工具栏传输按钮 → `.remote/` 出现对应文件;右键「队列」可看到排队/传输中/完成状态。
81
- 11. **显示隐藏文件**:打开目录时默认显示 `.git`、`.gitignore`、`.local`/`.remote` 等隐藏项;点眼睛按钮可隐藏,再点恢复。目录面板默认开启。
82
- 12. **右键菜单**:文件/文件夹/根节点右击 → 传输、下载到默认文件夹、队列、打开、用记事本编辑、复制、粘贴、复制路径、重命名、删除、更改权限、新建(文件夹/文件)、属性;在远端与本地两侧各验证一次重命名/删除/新建/属性。
83
- 14. **运行中图标**:在该联动工作区的会话里发起一轮对话,运行期间左侧工作区的链环图标应变蓝;结束后(约 5 秒内)恢复原色。
84
- 15. **本地技能**:在联动工作区会话里让模型列出本地技能(或调用 `linked_skills` list/read);应能读到本机 `~/.dsh/skills` / `<local>/.dsh/skills` 下的 SKILL.md,且无需在远端安装;`skills.list` API 返回非空。
85
- 16. 编辑器:内部内容面板不显示路径/保存头部(Ctrl+S 保存);修改未保存时文件标签名前出现灰色圆点;行号、语法高亮(字符串/注释/关键字有颜色)、主题配色、
79
+ 9. **多标签/固定**:单击文件 A → 预览标签;单击文件 B → A 被替换为 B;双击 B → B 固定;再单击文件 C → B 保留且新增 C 标签。重复点击已打开文件聚焦原标签;关闭一个标签后其槽位可复用(最多 12 个)。
80
+ 10. **关闭未保存文件**:修改文件但不保存(关闭自动保存时),点标签上的 × → 出现「保存 / 放弃 / 取消」;点取消留在编辑器,点放弃关闭,点保存先写盘再关闭。
81
+ 11. **快捷键**:`Ctrl+S` 保存;`Ctrl+W` 关闭当前标签(未保存时弹窗);`Ctrl+Tab` / `Shift+Ctrl+Tab` 切换标签;`Ctrl+B` 显示/隐藏目录面板;`Ctrl+Shift+F` 打开文件搜索。
82
+ 12. **终端按钮**:远程树工具栏的终端按钮打开外部终端并自动 `cd` 到当前远程根目录;联动工作区的本地按钮打开本地终端并 `cd` 到本地目录。远程终端需使用系统 `ssh`,首次可能要求确认主机指纹/输入密码。
83
+ 13. **自动保存**:设置 → 连接 →「编辑器行为」开启自动保存 → 修改文件后停止输入约 1 秒应自动写盘、灰点消失;关闭自动保存后再修改,不应自动写盘,`Ctrl+S` 才保存。设置重载后开关状态保持。
84
+ 14. **传输按钮**:在树中选中远端文件/文件夹(行高亮),点工具栏传输按钮 → `.remote/` 出现对应文件;右键「队列」可看到排队/传输中/完成状态。
85
+ 15. **显示隐藏文件**:打开目录时默认显示 `.git`、`.gitignore`、`.local`/`.remote` 等隐藏项;点眼睛按钮可隐藏,再点恢复。目录面板默认开启。
86
+ 16. **右键菜单**:文件/文件夹/根节点右击 → 传输、下载到默认文件夹、队列、打开、用记事本编辑、复制、粘贴、复制路径、重命名、删除、更改权限、新建(文件夹/文件)、属性;在远端与本地两侧各验证一次重命名/删除/新建/属性。
87
+ 17. **运行中图标**:在该联动工作区的会话里发起一轮对话,运行期间左侧工作区的链环图标应变蓝;结束后(约 5 秒内)恢复原色。
88
+ 18. **本地技能**:在联动工作区会话里让模型列出本地技能(或调用 `linked_skills` list/read);应能读到本机 `~/.dsh/skills` / `<local>/.dsh/skills` 下的 SKILL.md,且无需在远端安装;`skills.list` API 返回非空。
89
+ 19. 编辑器:内部内容面板不显示路径/保存头部(Ctrl+S 保存);修改未保存时文件标签名前出现灰色圆点;行号、语法高亮(字符串/注释/关键字有颜色)、主题配色、
86
90
  远端并发修改 → 冲突提示(重新加载/强制保存)、二进制/大文件只读;底部状态栏完整可见(不再被视口裁切)。
87
91
 
88
92
  ## 联动会话(模型工具)
package/lib/client.js CHANGED
@@ -2148,6 +2148,26 @@ function fileIconSpec(name, isDir) {
2148
2148
  openWithSystem: '系统默认应用',
2149
2149
  openWithCopy: '复制路径',
2150
2150
  openModeSaved: '打开方式已保存',
2151
+ autoSaveTitle: '自动保存',
2152
+ autoSaveHint: '编辑停顿约 1 秒后自动保存;关闭后请按 Ctrl+S 手动保存。',
2153
+ autoSaveLabel: '开启自动保存',
2154
+ autoSaveSaved: '自动保存设置已保存',
2155
+ shortcutTitle: '常用快捷键',
2156
+ shortcutHint: '与 VS Code 类似的默认快捷键;macOS 上把 Ctrl 换成 ⌘。',
2157
+ shortcutSave: '保存当前文件',
2158
+ shortcutClose: '关闭当前文件',
2159
+ shortcutSwitch: '切换文件标签',
2160
+ shortcutDirectory: '显示/隐藏目录面板',
2161
+ shortcutSearch: '在文件中搜索',
2162
+ terminalRemote: '打开远程终端',
2163
+ terminalLocal: '打开本地终端',
2164
+ terminalOpened: '已打开终端',
2165
+ terminalOpenFailed: '打开终端失败',
2166
+ dirtyCloseTitle: '文件尚未保存',
2167
+ dirtyCloseMessage: '「{{name}}」有未保存的修改。关闭前要保存吗?',
2168
+ dirtyCloseSave: '保存并关闭',
2169
+ dirtyCloseDiscard: '不保存关闭',
2170
+ saveFailed: '保存失败,文件未关闭',
2151
2171
  noChanges: '没有检测到更改',
2152
2172
  notGitRepo: '该目录不是 Git 仓库',
2153
2173
  changesRemote: '远端更改',
@@ -2438,6 +2458,26 @@ function fileIconSpec(name, isDir) {
2438
2458
  openWithSystem: 'System default app',
2439
2459
  openWithCopy: 'Copy path',
2440
2460
  openModeSaved: 'Open mode saved',
2461
+ autoSaveTitle: 'Auto save',
2462
+ autoSaveHint: 'Saves about one second after you stop typing. When off, press Ctrl+S to save.',
2463
+ autoSaveLabel: 'Enable auto save',
2464
+ autoSaveSaved: 'Auto-save setting saved',
2465
+ shortcutTitle: 'Keyboard shortcuts',
2466
+ shortcutHint: 'VS Code-like defaults; use ⌘ instead of Ctrl on macOS.',
2467
+ shortcutSave: 'Save the current file',
2468
+ shortcutClose: 'Close the current file',
2469
+ shortcutSwitch: 'Switch file tabs',
2470
+ shortcutDirectory: 'Show/hide the directory pane',
2471
+ shortcutSearch: 'Search in files',
2472
+ terminalRemote: 'Open remote terminal',
2473
+ terminalLocal: 'Open local terminal',
2474
+ terminalOpened: 'Terminal opened',
2475
+ terminalOpenFailed: 'Could not open terminal',
2476
+ dirtyCloseTitle: 'Unsaved changes',
2477
+ dirtyCloseMessage: '“{{name}}” has unsaved changes. Save before closing?',
2478
+ dirtyCloseSave: 'Save and close',
2479
+ dirtyCloseDiscard: 'Close without saving',
2480
+ saveFailed: 'Save failed; the file was left open',
2441
2481
  noChanges: 'No changes detected',
2442
2482
  notGitRepo: 'Not a Git repository',
2443
2483
  changesRemote: 'Remote changes',
@@ -2718,7 +2758,7 @@ function fileIconSpec(name, isDir) {
2718
2758
  };
2719
2759
 
2720
2760
  var runtimeSettings = {
2721
- value: { editorTheme: DEFAULT_THEME_ID, editorThemeFamily: DEFAULT_THEME_FAMILY, editorThemeMode: 'system', appThemeDark: false, fileOpenMode: 'editor' },
2761
+ value: { editorTheme: DEFAULT_THEME_ID, editorThemeFamily: DEFAULT_THEME_FAMILY, editorThemeMode: 'system', appThemeDark: false, fileOpenMode: 'editor', autoSave: false },
2722
2762
  loaded: false,
2723
2763
  loading: null,
2724
2764
  listeners: new Set(),
@@ -2741,7 +2781,7 @@ function fileIconSpec(name, isDir) {
2741
2781
  runtimeSettings.loading = null;
2742
2782
  var openMode = settings && (settings.fileOpenMode === 'system' || settings.fileOpenMode === 'copy') ? settings.fileOpenMode : 'editor';
2743
2783
  var mode = settings && (settings.editorThemeMode === 'light' || settings.editorThemeMode === 'dark') ? settings.editorThemeMode : 'system';
2744
- runtimeSettings.apply({ editorTheme: settings && settings.editorTheme ? settings.editorTheme : DEFAULT_THEME_ID, editorThemeFamily: themeFamilyFor(settings), editorThemeMode: mode, fileOpenMode: openMode });
2784
+ runtimeSettings.apply({ editorTheme: settings && settings.editorTheme ? settings.editorTheme : DEFAULT_THEME_ID, editorThemeFamily: themeFamilyFor(settings), editorThemeMode: mode, fileOpenMode: openMode, autoSave: Boolean(settings && settings.autoSave) });
2745
2785
  return runtimeSettings.value;
2746
2786
  }).catch(function () {
2747
2787
  runtimeSettings.loaded = true;
@@ -2764,6 +2804,13 @@ function fileIconSpec(name, isDir) {
2764
2804
  return runtimeSettings.value;
2765
2805
  });
2766
2806
  },
2807
+ saveAutoSave: function (enabled) {
2808
+ return apiCall('settings.update', { autoSave: Boolean(enabled) }).then(function (settings) {
2809
+ runtimeSettings.loaded = true;
2810
+ runtimeSettings.apply({ autoSave: Boolean(settings && settings.autoSave) });
2811
+ return runtimeSettings.value;
2812
+ });
2813
+ },
2767
2814
  };
2768
2815
 
2769
2816
  function themeFamilyFor(settings) {
@@ -2968,6 +3015,10 @@ function fileIconSpec(name, isDir) {
2968
3015
  '.rw-option-title{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;font-weight:600}',
2969
3016
  '.rw-option-desc{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary)}',
2970
3017
  '.rw-warn-box{padding:8px 10px;border-radius:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 8%,var(--dsw-alias-bg-layer-1));font-size:12px;line-height:18px}',
3018
+ '.rw-setting-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}',
3019
+ '.rw-shortcut-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px}',
3020
+ '.rw-shortcut-row{display:flex;align-items:center;gap:10px;min-height:28px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
3021
+ '.rw-kbd{display:inline-flex;align-items:center;justify-content:center;min-width:64px;padding:2px 7px;border:1px solid var(--dsw-alias-border-l2);border-bottom-width:2px;border-radius:5px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap}',
2971
3022
  '@media (max-width:720px){.rw-theme-grid{grid-template-columns:repeat(auto-fill,minmax(132px,1fr))}.rw-pair{grid-template-columns:minmax(0,1fr)}}',
2972
3023
  '.rw-linked-toolbar{display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);min-width:0;position:relative;z-index:5}',
2973
3024
  '.rw-menu-wrap{position:relative}',
@@ -3184,6 +3235,12 @@ function fileIconSpec(name, isDir) {
3184
3235
  h('circle', { key: 'd', cx: 4.6, cy: 11.3, r: 0.8, fill: 'currentColor' }),
3185
3236
  ]);
3186
3237
  }
3238
+ function IconTerminal(size) {
3239
+ return svg(size || 14, [
3240
+ h('rect', { key: 'a', x: 1.8, y: 2.4, width: 12.4, height: 11.2, rx: 1.8, stroke: 'currentColor', strokeWidth: 1.25 }),
3241
+ h('path', { key: 'b', d: 'm4.4 6 2.1 2-2.1 2M8.2 10h3.3', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' }),
3242
+ ]);
3243
+ }
3187
3244
  function IconEye(size) {
3188
3245
  return svg(size || 14, [
3189
3246
  h('path', { key: 'a', d: 'M1.6 8s2.4-4.1 6.4-4.1S14.4 8 14.4 8 12 12.1 8 12.1 1.6 8 1.6 8Z', stroke: 'currentColor', strokeWidth: 1.25, strokeLinejoin: 'round' }),
@@ -3750,11 +3807,11 @@ function fileIconSpec(name, isDir) {
3750
3807
  }, [dirty, baseTitle]);
3751
3808
 
3752
3809
  function saveFile(force) {
3753
- if (readOnly || !load.file || load.file.binary || load.file.tooLarge || save.saving) return;
3810
+ if (readOnly || !load.file || load.file.binary || load.file.tooLarge || save.saving) return Promise.resolve(false);
3754
3811
  setSave({ saving: true, conflict: false, error: '', ok: false });
3755
3812
  var payload = local ? { path: filePath, content: content } : Object.assign({ path: filePath, content: content }, params);
3756
3813
  if (!force && load.file && typeof load.file.mtime === 'number') payload.expectedMtime = load.file.mtime;
3757
- apiCall(local ? 'local.write' : 'fs.write', payload).then(function (result) {
3814
+ return apiCall(local ? 'local.write' : 'fs.write', payload).then(function (result) {
3758
3815
  setLoad(function (previous) {
3759
3816
  if (!previous.file) return previous;
3760
3817
  return { loading: false, error: null, file: Object.assign({}, previous.file, { mtime: result.mtime, size: result.size }) };
@@ -3764,15 +3821,33 @@ function fileIconSpec(name, isDir) {
3764
3821
  setTimeout(function () {
3765
3822
  setSave(function (previous) { return previous.ok ? { saving: false, conflict: false, error: '', ok: false } : previous; });
3766
3823
  }, 2200);
3824
+ return true;
3767
3825
  }).catch(function (error) {
3768
3826
  if (error && error.code === 'CONFLICT') {
3769
3827
  setSave({ saving: false, conflict: true, error: '', ok: false });
3770
3828
  } else {
3771
3829
  setSave({ saving: false, conflict: false, error: messageOf(error, t, locale), ok: false });
3772
3830
  }
3831
+ return false;
3773
3832
  });
3774
3833
  }
3775
3834
 
3835
+ var saveRef = useRef(null);
3836
+ saveRef.current = saveFile;
3837
+ useEffect(function () {
3838
+ var notify = props.onSaveReady;
3839
+ if (typeof notify === 'function') notify(function () { return saveRef.current(false); });
3840
+ return function () {
3841
+ if (typeof notify === 'function') notify(null);
3842
+ };
3843
+ }, [filePath, local]);
3844
+
3845
+ useEffect(function () {
3846
+ if (!settings.autoSave || !dirty || !editable || save.saving || save.conflict) return undefined;
3847
+ var timer = setTimeout(function () { saveRef.current(false); }, 900);
3848
+ return function () { clearTimeout(timer); };
3849
+ }, [content, dirty, settings.autoSave, editable, save.saving, save.conflict]);
3850
+
3776
3851
  useEffect(function () {
3777
3852
  if (props.visible === false) return undefined;
3778
3853
  function onKey(event) {
@@ -4056,6 +4131,7 @@ function fileIconSpec(name, isDir) {
4056
4131
  state: { entries: cache.entries },
4057
4132
  load: load,
4058
4133
  onSelect: props.onSelect,
4134
+ onPin: props.onPin,
4059
4135
  onSelectPath: props.onSelectPath,
4060
4136
  onContextMenu: props.onContextMenu,
4061
4137
  selectedPath: props.selectedPath || '',
@@ -4142,6 +4218,20 @@ function fileIconSpec(name, isDir) {
4142
4218
  });
4143
4219
  })))
4144
4220
  : null),
4221
+ h('button', {
4222
+ type: 'button',
4223
+ className: 'rw-icon-btn',
4224
+ title: t('terminalRemote'),
4225
+ onClick: function () { props.onOpenTerminal('remote'); },
4226
+ }, IconTerminal(14)),
4227
+ props.localEnabled
4228
+ ? h('button', {
4229
+ type: 'button',
4230
+ className: 'rw-icon-btn',
4231
+ title: t('terminalLocal'),
4232
+ onClick: function () { props.onOpenTerminal('local'); },
4233
+ }, IconTerminal(14))
4234
+ : null,
4145
4235
  h('button', {
4146
4236
  type: 'button',
4147
4237
  className: 'rw-icon-btn' + (props.directoryOpen ? ' is-on' : ''),
@@ -4388,6 +4478,9 @@ function fileIconSpec(name, isDir) {
4388
4478
  h('div', { className: 'rw-dialog-message' }, props.message),
4389
4479
  h('div', { className: 'rw-dialog-actions' },
4390
4480
  h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('cancel')),
4481
+ props.secondaryLabel
4482
+ ? h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: props.onSecondary }, props.secondaryLabel)
4483
+ : null,
4391
4484
  h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', onClick: props.onConfirm }, props.confirmLabel)));
4392
4485
  }
4393
4486
 
@@ -4426,11 +4519,12 @@ function fileIconSpec(name, isDir) {
4426
4519
  var path = props.path;
4427
4520
  var local = props.local === true;
4428
4521
  var onSaved = props.onSaved;
4522
+ var settings = useRuntimeSettings();
4429
4523
  var kind = documentKindOf(path);
4430
4524
  var stateRef = useState({
4431
4525
  loading: true, error: '', data: null, text: '', dirty: false, saving: false,
4432
4526
  preview: false, html: '', previewLoading: false, page: 0, sheet: 0, slide: 0,
4433
- updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, notice: '',
4527
+ updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, pdfLoading: false, notice: '',
4434
4528
  });
4435
4529
  var state = stateRef[0];
4436
4530
  var setState = stateRef[1];
@@ -4451,7 +4545,7 @@ function fileIconSpec(name, isDir) {
4451
4545
  loading: false, error: '', data: result,
4452
4546
  text: result.kind === 'markdown' ? String(result.text || '') : '',
4453
4547
  preview: false, html: '', page: 0, sheet: 0, slide: 0,
4454
- updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, dirty: false,
4548
+ updates: {}, pptxUpdates: {}, rotations: {}, deleted: {}, pdfLoading: false, dirty: false,
4455
4549
  });
4456
4550
  }).catch(function (error) {
4457
4551
  patch({ loading: false, error: messageOf(error, t, locale) });
@@ -4465,7 +4559,7 @@ function fileIconSpec(name, isDir) {
4465
4559
  }, [state.dirty]);
4466
4560
 
4467
4561
  function save() {
4468
- if (state.saving) return;
4562
+ if (state.saving) return Promise.resolve(false);
4469
4563
  patch({ saving: true, error: '', notice: '' });
4470
4564
  var data = {};
4471
4565
  if (kind === 'markdown') data = { text: state.text };
@@ -4479,15 +4573,49 @@ function fileIconSpec(name, isDir) {
4479
4573
  deleted: Object.keys(state.deleted).filter(function (index) { return state.deleted[index]; }).map(Number),
4480
4574
  };
4481
4575
  }
4482
- apiCall('docs.write', params({ data: data, expectedMtime: state.data ? state.data.mtime : undefined })).then(function () {
4483
- patch({ saving: false, dirty: false, notice: t('saved') });
4576
+ return apiCall('docs.write', params({ data: data, expectedMtime: state.data ? state.data.mtime : undefined })).then(function (result) {
4577
+ patch({
4578
+ saving: false,
4579
+ dirty: false,
4580
+ notice: t('saved'),
4581
+ data: state.data ? Object.assign({}, state.data, { mtime: result.mtime, size: result.size }) : state.data,
4582
+ });
4484
4583
  if (onSaved) onSaved();
4485
- load();
4584
+ return true;
4486
4585
  }).catch(function (error) {
4487
4586
  patch({ saving: false, error: messageOf(error, t, locale) });
4587
+ return false;
4488
4588
  });
4489
4589
  }
4490
4590
 
4591
+ var saveRef = useRef(null);
4592
+ saveRef.current = save;
4593
+ useEffect(function () {
4594
+ var notify = props.onSaveReady;
4595
+ if (typeof notify === 'function') notify(function () { return saveRef.current(); });
4596
+ return function () {
4597
+ if (typeof notify === 'function') notify(null);
4598
+ };
4599
+ }, [path, local]);
4600
+
4601
+ useEffect(function () {
4602
+ if (props.visible === false) return undefined;
4603
+ function onKey(event) {
4604
+ var mod = event.ctrlKey || event.metaKey;
4605
+ if (!mod || String(event.key || '').toLowerCase() !== 's') return;
4606
+ event.preventDefault();
4607
+ saveRef.current();
4608
+ }
4609
+ window.addEventListener('keydown', onKey, true);
4610
+ return function () { window.removeEventListener('keydown', onKey, true); };
4611
+ }, [props.visible]);
4612
+
4613
+ useEffect(function () {
4614
+ if (!settings.autoSave || !state.dirty || state.saving) return undefined;
4615
+ var timer = setTimeout(function () { saveRef.current(); }, 900);
4616
+ return function () { clearTimeout(timer); };
4617
+ }, [settings.autoSave, state.dirty, state.saving, state.text, state.data, state.updates, state.pptxUpdates, state.rotations, state.deleted]);
4618
+
4491
4619
  function showPreview() {
4492
4620
  var next = !state.preview;
4493
4621
  patch({ preview: next, previewLoading: next && !state.html });
@@ -4538,6 +4666,35 @@ function fileIconSpec(name, isDir) {
4538
4666
  patch({ pptxUpdates: updates, dirty: true });
4539
4667
  }
4540
4668
 
4669
+ function goPdfPage(index) {
4670
+ if (index < 0 || index >= (state.data.pageCount || 0)) return;
4671
+ var cached = (state.data.pages || []).filter(function (page) { return page.index === index; })[0];
4672
+ if (cached) {
4673
+ patch({ page: index });
4674
+ return;
4675
+ }
4676
+ patch({ pdfLoading: true, error: '' });
4677
+ apiCall('docs.pdfPage', params({ page: index })).then(function (result) {
4678
+ var pages = (state.data.pages || []).filter(function (page) { return page.index !== result.pages[0].index; }).concat(result.pages);
4679
+ pages.sort(function (left, right) { return left.index - right.index; });
4680
+ patch({ data: Object.assign({}, state.data, { pages: pages }), page: index, pdfLoading: false });
4681
+ }).catch(function (error) {
4682
+ patch({ pdfLoading: false, error: messageOf(error, t, locale) });
4683
+ });
4684
+ }
4685
+
4686
+ function stepPdfPage(delta) {
4687
+ var total = state.data.pageCount || 0;
4688
+ var next = state.page + delta;
4689
+ while (next >= 0 && next < total) {
4690
+ if (!state.deleted[next]) {
4691
+ goPdfPage(next);
4692
+ return;
4693
+ }
4694
+ next += delta;
4695
+ }
4696
+ }
4697
+
4541
4698
  var toolbar = h('div', { className: 'rw-doc-toolbar' },
4542
4699
  h('span', { className: 'rw-chip' }, kind.toUpperCase()),
4543
4700
  h('span', { className: 'rw-hint', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: path }, path),
@@ -4567,9 +4724,8 @@ function fileIconSpec(name, isDir) {
4567
4724
  onChange: function (event) { patch({ text: event.currentTarget.value, dirty: true, html: '' }); },
4568
4725
  }));
4569
4726
  } else if (kind === 'pdf' && state.data) {
4570
- var pages = state.data.pages || [];
4571
- var visible = pages.filter(function (page) { return !state.deleted[page.index]; });
4572
- var current = visible[Math.min(state.page, Math.max(visible.length - 1, 0))] || null;
4727
+ var total = state.data.pageCount || 0;
4728
+ var current = (state.data.pages || []).filter(function (page) { return page.index === state.page; })[0] || null;
4573
4729
  body = h('div', { className: 'rw-doc-body' },
4574
4730
  h('div', { className: 'rw-doc-field' },
4575
4731
  h('label', null, t('docPdfTitle')),
@@ -4578,13 +4734,14 @@ function fileIconSpec(name, isDir) {
4578
4734
  h('input', { className: 'rw-doc-input', style: { flex: '1 1 auto' }, value: (state.data.metadata && state.data.metadata.author) || '', onChange: function (event) { patch({ data: Object.assign({}, state.data, { metadata: Object.assign({}, state.data.metadata, { author: event.currentTarget.value }) }), dirty: true }); } })),
4579
4735
  h('div', { className: 'rw-inline', style: { justifyContent: 'space-between', margin: '6px 0' } },
4580
4736
  h('div', { className: 'rw-inline' },
4581
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page <= 0, onClick: function () { patch({ page: state.page - 1 }); } }, '‹'),
4582
- h('span', { className: 'rw-hint' }, current ? t('docPageOf', { page: current.index + 1, total: state.data.pageCount }) : ''),
4583
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page >= visible.length - 1, onClick: function () { patch({ page: state.page + 1 }); } }, '›')),
4737
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page <= 0, onClick: function () { stepPdfPage(-1); } }, '‹'),
4738
+ h('span', { className: 'rw-hint' }, t('docPageOf', { page: state.page + 1, total: total })),
4739
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page >= total - 1, onClick: function () { stepPdfPage(1); } }, '›')),
4584
4740
  h('div', { className: 'rw-inline' },
4585
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: !current, onClick: function () { var rotations = Object.assign({}, state.rotations); rotations[current.index] = ((rotations[current.index] || current.rotation || 0) + 90) % 360; patch({ rotations: rotations, dirty: true }); } }, t('docRotate')),
4586
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', disabled: !current || visible.length <= 1, onClick: function () { var deleted = Object.assign({}, state.deleted); deleted[current.index] = true; patch({ deleted: deleted, page: Math.max(0, state.page - 1), dirty: true }); } }, t('docDeletePage')))),
4587
- current ? h('img', { className: 'rw-doc-pdf-page', src: current.png, style: { transform: 'rotate(' + ((state.rotations[current.index] || current.rotation || 0) % 360) + 'deg)' } }) : h('div', { className: 'rw-empty' }, t('noEntries')));
4741
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: !current, onClick: function () { var rotations = Object.assign({}, state.rotations); rotations[state.page] = ((rotations[state.page] || (current && current.rotation) || 0) + 90) % 360; patch({ rotations: rotations, dirty: true }); } }, t('docRotate')),
4742
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', disabled: total <= 1, onClick: function () { var deleted = Object.assign({}, state.deleted); deleted[state.page] = true; var next = state.page + 1; while (next < total && deleted[next]) next += 1; if (next >= total) { next = state.page - 1; while (next >= 0 && deleted[next]) next -= 1; } var nextPage = next >= 0 ? next : state.page; patch({ deleted: deleted, dirty: true, page: nextPage }); if (next >= 0 && !((state.data.pages || []).some(function (page) { return page.index === next; }))) goPdfPage(next); } }, t('docDeletePage')))),
4743
+ state.pdfLoading ? h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')) : null,
4744
+ current ? h('img', { className: 'rw-doc-pdf-page', src: current.png, style: { transform: 'rotate(' + ((state.rotations[current.index] || current.rotation || 0) % 360) + 'deg)' } }) : null);
4588
4745
  } else if (kind === 'docx' && state.data) {
4589
4746
  var paragraphs = state.data.paragraphs || [];
4590
4747
  var tables = state.data.tables || [];
@@ -4806,6 +4963,7 @@ function fileIconSpec(name, isDir) {
4806
4963
  var fileDirtyState = useState({});
4807
4964
  var fileDirty = fileDirtyState[0];
4808
4965
  var setFileDirty = fileDirtyState[1];
4966
+ var saveHandlersRef = useRef({});
4809
4967
  var directoryTabId = props.tab && props.tab.id;
4810
4968
 
4811
4969
  useEffect(function () {
@@ -4888,7 +5046,25 @@ function fileIconSpec(name, isDir) {
4888
5046
  });
4889
5047
  }
4890
5048
 
4891
- function openByMode(side, filePath) {
5049
+ function openTerminal(side) {
5050
+ if (!selected) return;
5051
+ var request = { side: side };
5052
+ if (selected.kind === 'linked') request.linkedId = selected.id;
5053
+ else request.workspaceId = selected.id;
5054
+ apiCall('terminal.open', request).then(function () {
5055
+ showNotice('success', t('terminalOpened'));
5056
+ }).catch(function (error) {
5057
+ showNotice('error', t('terminalOpenFailed') + ':' + messageOf(error, t, locale));
5058
+ });
5059
+ }
5060
+
5061
+ function pinByMode(side, filePath) {
5062
+ var mode = settings.fileOpenMode || 'editor';
5063
+ if (mode !== 'editor') return;
5064
+ openByMode(side, filePath, { pinned: true, preview: false });
5065
+ }
5066
+
5067
+ function openByMode(side, filePath, options) {
4892
5068
  if (!selected || !filePath) return;
4893
5069
  var openMode = settings.fileOpenMode || 'editor';
4894
5070
  if (openMode === 'copy') {
@@ -4907,14 +5083,34 @@ function fileIconSpec(name, isDir) {
4907
5083
  });
4908
5084
  return;
4909
5085
  }
4910
- openInternalFile(side, filePath);
5086
+ openInternalFile(side, filePath, options || { pinned: true });
4911
5087
  }
4912
5088
 
4913
- function openInternalFile(side, filePath) {
5089
+ function openInternalFile(side, filePath, options) {
5090
+ var opts = options || { pinned: true };
4914
5091
  var key = side + ':' + (side === 'remote' ? scopeKeyOf(scopeRef) : 'local') + ':' + filePath;
4915
5092
  setOpenFiles(function (previous) {
4916
- if (previous.some(function (file) { return file.key === key; })) return previous;
4917
- return previous.concat([{ key: key, side: side, path: filePath, name: basename(filePath) }]);
5093
+ var existing = previous.filter(function (file) { return file.key === key; })[0];
5094
+ if (existing) {
5095
+ if (opts.pinned && !existing.pinned) {
5096
+ return previous.map(function (file) { return file.key === key ? Object.assign({}, file, { pinned: true }) : file; });
5097
+ }
5098
+ return previous;
5099
+ }
5100
+ if (opts.preview === true) {
5101
+ var activeIndex = -1;
5102
+ previous.forEach(function (file, index) { if (file.key === activeFileKey) activeIndex = index; });
5103
+ if (activeIndex >= 0) {
5104
+ var active = previous[activeIndex];
5105
+ if (!active.pinned && !fileDirty[active.key]) {
5106
+ var replaced = previous.slice();
5107
+ replaced[activeIndex] = { key: key, side: side, path: filePath, name: basename(filePath), pinned: false };
5108
+ setFileDirty(function (dirtyMap) { var copy = Object.assign({}, dirtyMap); delete copy[active.key]; return copy; });
5109
+ return replaced;
5110
+ }
5111
+ }
5112
+ }
5113
+ return previous.concat([{ key: key, side: side, path: filePath, name: basename(filePath), pinned: Boolean(opts.pinned) }]);
4918
5114
  });
4919
5115
  setActiveFileKey(key);
4920
5116
  setDirectoryOpen(true);
@@ -4924,11 +5120,67 @@ function fileIconSpec(name, isDir) {
4924
5120
  setOpenFiles(function (previous) {
4925
5121
  var next = previous.filter(function (file) { return file.key !== key; });
4926
5122
  setFileDirty(function (dirtyMap) { var copy = Object.assign({}, dirtyMap); delete copy[key]; return copy; });
5123
+ delete saveHandlersRef.current[key];
4927
5124
  if (activeFileKey === key) setActiveFileKey(next.length > 0 ? next[next.length - 1].key : '');
4928
5125
  return next;
4929
5126
  });
4930
5127
  }
4931
5128
 
5129
+ function requestCloseInternalFile(key) {
5130
+ if (!fileDirty[key]) { closeInternalFile(key); return; }
5131
+ var file = openFiles.filter(function (entry) { return entry.key === key; })[0];
5132
+ setDialog({ kind: 'closeDirty', key: key, name: file ? file.name : '' });
5133
+ }
5134
+
5135
+ function saveAndCloseInternalFile(key) {
5136
+ var handler = saveHandlersRef.current[key];
5137
+ setDialog(null);
5138
+ if (!handler) { closeInternalFile(key); return; }
5139
+ handler().then(function (ok) {
5140
+ if (ok) closeInternalFile(key);
5141
+ else showNotice('error', t('saveFailed'));
5142
+ });
5143
+ }
5144
+
5145
+ useEffect(function () {
5146
+ function onKey(event) {
5147
+ var mod = event.ctrlKey || event.metaKey;
5148
+ if (!mod) return;
5149
+ var key = String(event.key || '').toLowerCase();
5150
+ if (key === 'w') {
5151
+ if (!activeFileKey) return;
5152
+ event.preventDefault();
5153
+ event.stopPropagation();
5154
+ requestCloseInternalFile(activeFileKey);
5155
+ return;
5156
+ }
5157
+ if (key === 'tab') {
5158
+ if (openFiles.length < 2) return;
5159
+ event.preventDefault();
5160
+ event.stopPropagation();
5161
+ var index = -1;
5162
+ openFiles.forEach(function (file, i) { if (file.key === activeFileKey) index = i; });
5163
+ var step = event.shiftKey ? -1 : 1;
5164
+ var nextIndex = (index + step + openFiles.length) % openFiles.length;
5165
+ setActiveFileKey(openFiles[nextIndex].key);
5166
+ return;
5167
+ }
5168
+ if (key === 'b') {
5169
+ event.preventDefault();
5170
+ event.stopPropagation();
5171
+ setDirectoryOpen(function (open) { return !open; });
5172
+ return;
5173
+ }
5174
+ if (key === 'f' && event.shiftKey) {
5175
+ event.preventDefault();
5176
+ event.stopPropagation();
5177
+ setSearchOpen(true);
5178
+ }
5179
+ }
5180
+ window.addEventListener('keydown', onKey, true);
5181
+ return function () { window.removeEventListener('keydown', onKey, true); };
5182
+ }, [activeFileKey, openFiles, directoryOpen]);
5183
+
4932
5184
  function closeContextMenu() { setCtxMenu(null); }
4933
5185
 
4934
5186
  function openContextMenu(side, info) {
@@ -5198,6 +5450,18 @@ function fileIconSpec(name, isDir) {
5198
5450
  onConfirm: function () { setDialog(null); confirmDelete(dialog); },
5199
5451
  });
5200
5452
  }
5453
+ if (dialog.kind === 'closeDirty') {
5454
+ return h(ConfirmDialog, {
5455
+ t: t,
5456
+ title: t('dirtyCloseTitle'),
5457
+ message: t('dirtyCloseMessage', { name: dialog.name }),
5458
+ secondaryLabel: t('dirtyCloseSave'),
5459
+ confirmLabel: t('dirtyCloseDiscard'),
5460
+ onClose: function () { setDialog(null); },
5461
+ onSecondary: function () { saveAndCloseInternalFile(dialog.key); },
5462
+ onConfirm: function () { setDialog(null); closeInternalFile(dialog.key); },
5463
+ });
5464
+ }
5201
5465
  return h(PromptDialog, {
5202
5466
  t: t,
5203
5467
  title: dialog.title,
@@ -5311,7 +5575,8 @@ function fileIconSpec(name, isDir) {
5311
5575
  showHidden: showHidden,
5312
5576
  selectedPath: selectedEntry && selectedEntry.side === 'remote' ? selectedEntry.path : '',
5313
5577
  onSelectPath: function (info) { setSelectedEntry(Object.assign({ side: 'remote' }, info)); },
5314
- onSelect: function (filePath) { openByMode('remote', filePath); },
5578
+ onSelect: function (filePath) { openByMode('remote', filePath, { pinned: false, preview: true }); },
5579
+ onPin: function (filePath) { pinByMode('remote', filePath); },
5315
5580
  onContextMenu: function (info) { setSelectedEntry(Object.assign({ side: 'remote' }, info)); openContextMenu('remote', info); },
5316
5581
  })),
5317
5582
  selected.kind === 'linked'
@@ -5331,7 +5596,8 @@ function fileIconSpec(name, isDir) {
5331
5596
  showHidden: showHidden,
5332
5597
  selectedPath: selectedEntry && selectedEntry.side === 'local' ? selectedEntry.path : '',
5333
5598
  onSelectPath: function (info) { setSelectedEntry(Object.assign({ side: 'local' }, info)); },
5334
- onSelect: function (filePath) { openByMode('local', filePath); },
5599
+ onSelect: function (filePath) { openByMode('local', filePath, { pinned: false, preview: true }); },
5600
+ onPin: function (filePath) { pinByMode('local', filePath); },
5335
5601
  refreshTick: localTick,
5336
5602
  onContextMenu: function (info) { setSelectedEntry(Object.assign({ side: 'local' }, info)); openContextMenu('local', info); },
5337
5603
  }))
@@ -5348,6 +5614,10 @@ function fileIconSpec(name, isDir) {
5348
5614
  path: activeFile.path,
5349
5615
  local: activeFile.side === 'local',
5350
5616
  onSaved: refreshTree,
5617
+ onSaveReady: function (handler) {
5618
+ if (handler) saveHandlersRef.current[activeFile.key] = handler;
5619
+ else delete saveHandlersRef.current[activeFile.key];
5620
+ },
5351
5621
  onDirtyChange: function (dirty) {
5352
5622
  setFileDirty(function (previous) {
5353
5623
  if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
@@ -5364,6 +5634,10 @@ function fileIconSpec(name, isDir) {
5364
5634
  local: activeFile.side === 'local',
5365
5635
  readOnly: remoteOnly,
5366
5636
  hideHeader: true,
5637
+ onSaveReady: function (handler) {
5638
+ if (handler) saveHandlersRef.current[activeFile.key] = handler;
5639
+ else delete saveHandlersRef.current[activeFile.key];
5640
+ },
5367
5641
  onDirtyChange: function (dirty) {
5368
5642
  setFileDirty(function (previous) {
5369
5643
  if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
@@ -5387,6 +5661,8 @@ function fileIconSpec(name, isDir) {
5387
5661
  openMode: settings.fileOpenMode || 'editor',
5388
5662
  directoryOpen: directoryOpen,
5389
5663
  onToggleDirectory: function () { setDirectoryOpen(!directoryOpen); },
5664
+ onOpenTerminal: openTerminal,
5665
+ localEnabled: !remoteOnly && selected.kind === 'linked',
5390
5666
  showHidden: showHidden,
5391
5667
  onToggleHidden: function () { setShowHidden(!showHidden); },
5392
5668
  transferEnabled: !remoteOnly && Boolean(selectedEntry),
@@ -5445,7 +5721,7 @@ function fileIconSpec(name, isDir) {
5445
5721
  h('span', { className: 'rw-file-tab-name' }, file.name),
5446
5722
  h('button', {
5447
5723
  type: 'button', className: 'rw-file-tab-close', title: t('closeFile'),
5448
- onClick: function (event) { event.stopPropagation(); closeInternalFile(file.key); },
5724
+ onClick: function (event) { event.stopPropagation(); requestCloseInternalFile(file.key); },
5449
5725
  }, IconClose(10)));
5450
5726
  }))
5451
5727
  : null,
@@ -5506,6 +5782,7 @@ function fileIconSpec(name, isDir) {
5506
5782
  var toggle = props.toggle;
5507
5783
  var state = props.state;
5508
5784
  var onSelect = props.onSelect;
5785
+ var onPin = props.onPin;
5509
5786
  var onSelectPath = props.onSelectPath;
5510
5787
  var onContextMenu = props.onContextMenu;
5511
5788
  var selectedPath = props.selectedPath;
@@ -5543,6 +5820,7 @@ function fileIconSpec(name, isDir) {
5543
5820
  state: state,
5544
5821
  load: props.load,
5545
5822
  onSelect: onSelect,
5823
+ onPin: onPin,
5546
5824
  onSelectPath: onSelectPath,
5547
5825
  onContextMenu: onContextMenu,
5548
5826
  selectedPath: selectedPath,
@@ -5555,10 +5833,11 @@ function fileIconSpec(name, isDir) {
5555
5833
  className: 'rw-row' + (active ? ' is-active' : ''),
5556
5834
  style: { paddingLeft: 6 + (depth + 1) * 12 },
5557
5835
  title: child.path,
5558
- onClick: function () {
5836
+ onClick: function (event) {
5559
5837
  if (onSelectPath) onSelectPath({ path: child.path, name: child.name, isDir: false });
5838
+ if (event.detail !== 2) onSelect(child.path);
5560
5839
  },
5561
- onDoubleClick: function () { onSelect(child.path); },
5840
+ onDoubleClick: function () { if (onPin) onPin(child.path); },
5562
5841
  onContextMenu: function (event) {
5563
5842
  if (!onContextMenu) return;
5564
5843
  event.preventDefault();
@@ -5581,10 +5860,10 @@ function fileIconSpec(name, isDir) {
5581
5860
  className: 'rw-row' + (selectedPath === path ? ' is-active' : ''),
5582
5861
  style: { paddingLeft: 6 + depth * 12 },
5583
5862
  title: path,
5584
- onClick: function () {
5863
+ onClick: function (event) {
5585
5864
  if (onSelectPath) onSelectPath({ path: path, name: name, isDir: true });
5865
+ if (event.detail !== 2) toggle(path);
5586
5866
  },
5587
- onDoubleClick: function () { toggle(path); },
5588
5867
  onContextMenu: function (event) {
5589
5868
  if (!onContextMenu) return;
5590
5869
  event.preventDefault();
@@ -6379,6 +6658,61 @@ function fileIconSpec(name, isDir) {
6379
6658
  : null);
6380
6659
  }
6381
6660
 
6661
+ function EditorSettingsGroup(props) {
6662
+ var t = props.t;
6663
+ var locale = props.locale;
6664
+ var settings = useRuntimeSettings();
6665
+ var state = useState({ saving: false, message: null });
6666
+ var local = state[0];
6667
+ var setLocal = state[1];
6668
+ var shortcuts = [
6669
+ ['Ctrl+S', t('shortcutSave')],
6670
+ ['Ctrl+W', t('shortcutClose')],
6671
+ ['Ctrl+Tab', t('shortcutSwitch')],
6672
+ ['Ctrl+B', t('shortcutDirectory')],
6673
+ ['Ctrl+Shift+F', t('shortcutSearch')],
6674
+ ];
6675
+
6676
+ function toggleAutoSave(next) {
6677
+ if (local.saving) return;
6678
+ setLocal({ saving: true, message: null });
6679
+ runtimeSettings.saveAutoSave(next).then(function () {
6680
+ setLocal({ saving: false, message: { kind: 'success', text: t('autoSaveSaved') } });
6681
+ bus.emit();
6682
+ }).catch(function (error) {
6683
+ setLocal({ saving: false, message: { kind: 'error', text: messageOf(error, t, locale) } });
6684
+ });
6685
+ }
6686
+
6687
+ return h('div', { className: 'rw-group' },
6688
+ h('div', { className: 'rw-group-head' },
6689
+ h('span', { className: 'rw-group-title' }, t('autoSaveTitle')),
6690
+ h('span', { className: 'rw-group-hint' }, t('autoSaveHint'))),
6691
+ h('div', { className: 'rw-setting-row' },
6692
+ h(Switch, {
6693
+ checked: Boolean(settings.autoSave),
6694
+ onChange: toggleAutoSave,
6695
+ label: t('autoSaveLabel'),
6696
+ text: t('autoSaveLabel'),
6697
+ }),
6698
+ local.saving ? Spinner(12) : null),
6699
+ h('div', { className: 'rw-group-head', style: { marginTop: 14 } },
6700
+ h('span', { className: 'rw-group-title' }, t('shortcutTitle')),
6701
+ h('span', { className: 'rw-group-hint' }, t('shortcutHint'))),
6702
+ h('div', { className: 'rw-shortcut-grid' },
6703
+ shortcuts.map(function (item) {
6704
+ return h('div', { key: item[0], className: 'rw-shortcut-row' },
6705
+ h('kbd', { className: 'rw-kbd' }, item[0]),
6706
+ h('span', null, item[1]));
6707
+ })),
6708
+ local.message
6709
+ ? h(Banner, {
6710
+ kind: local.message.kind === 'success' ? 'success' : 'error',
6711
+ onClose: function () { setLocal({ saving: false, message: null }); },
6712
+ }, local.message.text)
6713
+ : null);
6714
+ }
6715
+
6382
6716
  function ConnectionsPage(props) {
6383
6717
  var ctx = props.ctx;
6384
6718
  var locale = useLocale(ctx);
@@ -6420,6 +6754,7 @@ function fileIconSpec(name, isDir) {
6420
6754
  }, adding === 'remote' ? IconClose(13) : IconServer(13), t('addRemoteDir')))),
6421
6755
  !sidebarAvailable ? h(Banner, { kind: 'warn' }, t('needsSidebar')) : null,
6422
6756
  h(ThemeGroup, { t: t, locale: locale }),
6757
+ h(EditorSettingsGroup, { t: t, locale: locale }),
6423
6758
  adding !== ''
6424
6759
  ? h(AddWorkspaceFlow, {
6425
6760
  ctx: ctx, t: t, locale: locale, initialMode: adding, lockedMode: adding,
package/lib/index.js CHANGED
@@ -34,7 +34,7 @@ export const inject = ['webServer', 'tools', 'systemPrompt', 'workingDirectory']
34
34
 
35
35
  const DATA_VERSION = 1
36
36
  const SETTINGS_VERSION = 1
37
- const SETTINGS_DEFAULTS = Object.freeze({ editorTheme: 'github-light', editorThemeFamily: 'github', editorThemeMode: 'system', fileOpenMode: 'editor' })
37
+ const SETTINGS_DEFAULTS = Object.freeze({ editorTheme: 'github-light', editorThemeFamily: 'github', editorThemeMode: 'system', fileOpenMode: 'editor', autoSave: false })
38
38
  const EDITOR_THEME_FAMILIES = new Set(['github', 'solarized', 'one', 'dracula', 'monokai', 'nord', 'vscode', 'xcode', 'catppuccin'])
39
39
  const THEME_FAMILY_OF = (id) => {
40
40
  const value = String(id || '')
@@ -183,6 +183,85 @@ function openWithSystem(targetPath) {
183
183
  }
184
184
  }
185
185
 
186
+ /** Spawn a detached console process and report whether the executable started. */
187
+ function launchDetached(command, args, extra = {}) {
188
+ return new Promise((resolveLaunch) => {
189
+ let child
190
+ try {
191
+ child = spawn(command, args, { detached: true, stdio: 'ignore', ...extra })
192
+ } catch {
193
+ resolveLaunch(false)
194
+ return
195
+ }
196
+ let settled = false
197
+ child.once('error', () => {
198
+ if (settled) return
199
+ settled = true
200
+ resolveLaunch(false)
201
+ })
202
+ child.once('spawn', () => {
203
+ if (settled) return
204
+ settled = true
205
+ try { child.unref() } catch { /* already detached */ }
206
+ resolveLaunch(true)
207
+ })
208
+ })
209
+ }
210
+
211
+ function quotePosix(text) {
212
+ return `'${String(text).replace(/'/g, `'\\''`)}'`
213
+ }
214
+
215
+ function quotePowerShell(text) {
216
+ return `'${String(text).replace(/'/g, "''")}'`
217
+ }
218
+
219
+ function quoteAppleScript(text) {
220
+ return `"${String(text).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
221
+ }
222
+
223
+ function remoteTerminalCommand(remotePath) {
224
+ return `cd ${quotePosix(remotePath)} && exec bash -l`
225
+ }
226
+
227
+ function sshTarget(host, username) {
228
+ return `${username || ''}@${host}`
229
+ }
230
+
231
+ /** Open a local terminal whose working directory is `dir`. */
232
+ async function openLocalTerminal(dir) {
233
+ if (process.platform === 'win32') {
234
+ if (await launchDetached('wt.exe', ['-d', dir], { windowsHide: false })) return true
235
+ const script = `Set-Location -LiteralPath ${quotePowerShell(dir)}`
236
+ return launchDetached('powershell.exe', ['-NoLogo', '-NoExit', '-Command', script], { windowsHide: false })
237
+ }
238
+ if (process.platform === 'darwin') {
239
+ return launchDetached('open', ['-a', 'Terminal', dir])
240
+ }
241
+ if (await launchDetached('x-terminal-emulator', [`--working-directory=${dir}`])) return true
242
+ return launchDetached('gnome-terminal', [`--working-directory=${dir}`])
243
+ }
244
+
245
+ /** Open a terminal that SSHes into `host` and cd's to `remotePath`. */
246
+ async function openRemoteTerminal(host, port, username, remotePath) {
247
+ const target = sshTarget(host, username)
248
+ const command = remoteTerminalCommand(remotePath)
249
+ if (!username) return false
250
+ if (process.platform === 'win32') {
251
+ const sshArgs = ['-p', String(port || 22), '-t', `${username}@${host}`, command]
252
+ if (await launchDetached('wt.exe', ['new-tab', '--', 'ssh.exe', ...sshArgs], { windowsHide: false })) return true
253
+ const script = `& ssh.exe -p ${Number(port) || 22} -t ${quotePowerShell(`${username}@${host}`)} ${quotePowerShell(command)}`
254
+ return launchDetached('powershell.exe', ['-NoLogo', '-NoExit', '-Command', script], { windowsHide: false })
255
+ }
256
+ if (process.platform === 'darwin') {
257
+ const line = `ssh -p ${Number(port) || 22} -t ${target} ${command}`
258
+ return launchDetached('osascript', ['-e', `tell application "Terminal" to do script ${quoteAppleScript(line)}`])
259
+ }
260
+ const sshArgs = ['-p', String(port || 22), '-t', target, command]
261
+ if (await launchDetached('x-terminal-emulator', ['-e', 'ssh', ...sshArgs])) return true
262
+ return launchDetached('gnome-terminal', ['--', 'ssh', ...sshArgs])
263
+ }
264
+
186
265
  function readJsonFile(file, fallback) {
187
266
  try {
188
267
  return JSON.parse(readFileSync(file, 'utf8'))
@@ -336,6 +415,9 @@ class SettingsStore {
336
415
  if (settings && typeof settings === 'object' && FILE_OPEN_MODES.has(settings.fileOpenMode)) {
337
416
  this.value.fileOpenMode = settings.fileOpenMode
338
417
  }
418
+ if (settings && typeof settings === 'object' && typeof settings.autoSave === 'boolean') {
419
+ this.value.autoSave = settings.autoSave
420
+ }
339
421
  if (settings && typeof settings === 'object' && settings.themeExplicit === true) {
340
422
  this.themeExplicit = true
341
423
  }
@@ -388,6 +470,10 @@ class SettingsStore {
388
470
  if (!FILE_OPEN_MODES.has(patch.fileOpenMode)) throw new ApiError('BAD_REQUEST', '文件打开方式无效')
389
471
  this.value.fileOpenMode = patch.fileOpenMode
390
472
  }
473
+ if (patch && typeof patch === 'object' && patch.autoSave !== undefined) {
474
+ if (typeof patch.autoSave !== 'boolean') throw new ApiError('BAD_REQUEST', '自动保存设置无效')
475
+ this.value.autoSave = patch.autoSave
476
+ }
391
477
  writeJsonAtomic(this.file, { version: SETTINGS_VERSION, settings: { ...this.value, themeExplicit: this.themeExplicit === true } })
392
478
  return this.get()
393
479
  }
@@ -1505,7 +1591,8 @@ export function apply(ctx) {
1505
1591
  return { data: readFileSync(current.path), mtime: info.mtimeMs, size: info.size }
1506
1592
  }
1507
1593
  const stat = await current.scope.remote.stat(current.path)
1508
- if (stat.type !== 'file') throw new ApiError('FS_NOT_DIR', '只能打开文件')
1594
+ const isFile = target.remote ? stat.type === 'file' : stat.isFile()
1595
+ if (!isFile) throw new ApiError('FS_NOT_DIR', '只能打开文件')
1509
1596
  if (stat.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1510
1597
  const read = await current.scope.remote.readFile(current.path, { maxBytes: DOC_MAX_BYTES })
1511
1598
  return { data: read.data, mtime: stat.mtime, size: stat.size }
@@ -1524,6 +1611,44 @@ export function apply(ctx) {
1524
1611
  return { path: target.path, size: result.size, mtime: result.mtime }
1525
1612
  }
1526
1613
 
1614
+ const docFileCache = new Map()
1615
+
1616
+ async function ensureDocCache(target) {
1617
+ let stat
1618
+ try {
1619
+ stat = target.remote ? await target.scope.remote.stat(target.path) : await fsStat(target.path)
1620
+ } catch (error) {
1621
+ if (target.remote) throw error
1622
+ throw mapLocalFsError(error)
1623
+ }
1624
+ const isFile = target.remote ? stat.type === 'file' : stat.isFile()
1625
+ if (!isFile) throw new ApiError('FS_NOT_DIR', '只能打开文件')
1626
+ if (stat.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1627
+ const mtime = target.remote ? stat.mtime : stat.mtimeMs
1628
+ const key = (target.remote ? String(target.scope.id) : 'local') + '|' + target.path + '|' + String(mtime)
1629
+ const existing = docFileCache.get(key)
1630
+ if (existing && existsSync(existing)) return { file: existing, size: stat.size, mtime }
1631
+ let data
1632
+ if (target.remote) {
1633
+ const read = await target.scope.remote.readFile(target.path, { maxBytes: DOC_MAX_BYTES })
1634
+ data = read.data
1635
+ } else {
1636
+ data = readFileSync(target.path)
1637
+ }
1638
+ const directory = join(tmpdir(), 'dsh-linked-workspace-docs')
1639
+ mkdirSync(directory, { recursive: true })
1640
+ const file = join(directory, randomUUID() + (extname(target.path) || '.bin'))
1641
+ writeFileSync(file, data)
1642
+ docFileCache.set(key, file)
1643
+ if (docFileCache.size > 20) {
1644
+ const oldestKey = docFileCache.keys().next().value
1645
+ const oldestFile = docFileCache.get(oldestKey)
1646
+ docFileCache.delete(oldestKey)
1647
+ try { rmSync(oldestFile, { force: true }) } catch { /* best effort */ }
1648
+ }
1649
+ return { file, size: stat.size, mtime }
1650
+ }
1651
+
1527
1652
  async function makeDocTemp() {
1528
1653
  const directory = mkdtempSync(join(tmpdir(), 'dsh-doc-'))
1529
1654
  return {
@@ -1733,6 +1858,11 @@ export function apply(ctx) {
1733
1858
  async 'docs.read'(params = {}) {
1734
1859
  const target = await docTarget(params)
1735
1860
  const kind = assertDocKind(target.path)
1861
+ if (kind === 'pdf') {
1862
+ const cached = await ensureDocCache(target)
1863
+ const data = await runDocTool(['read', '--kind', 'pdf', '--file', cached.file])
1864
+ return { ...data, path: target.path, size: cached.size, mtime: cached.mtime, cached: true }
1865
+ }
1736
1866
  const current = await readDocBytes(target, target)
1737
1867
  if (kind === 'markdown') {
1738
1868
  if (looksBinary(current.data)) throw new ApiError('DOC_BINARY', 'Markdown 文件包含二进制内容')
@@ -1749,6 +1879,15 @@ export function apply(ctx) {
1749
1879
  }
1750
1880
  },
1751
1881
 
1882
+ async 'docs.pdfPage'(params = {}) {
1883
+ const target = await docTarget(params)
1884
+ assertDocKind(target.path, 'pdf')
1885
+ const page = Number(params.page)
1886
+ if (!Number.isInteger(page) || page < 0) throw new ApiError('BAD_REQUEST', '页码无效')
1887
+ const cached = await ensureDocCache(target)
1888
+ return await runDocTool(['read', '--kind', 'pdf', '--file', cached.file, '--page', String(page)])
1889
+ },
1890
+
1752
1891
  async 'docs.write'(params = {}) {
1753
1892
  const target = await docTarget(params)
1754
1893
  const kind = assertDocKind(target.path)
@@ -1765,13 +1904,18 @@ export function apply(ctx) {
1765
1904
  const written = await writeDocBytes(target, Buffer.from(data.text, 'utf8'))
1766
1905
  return { kind, ...written }
1767
1906
  }
1768
- const current = await readDocBytes(target, target)
1907
+ let sourceFile
1908
+ if (kind === 'pdf') {
1909
+ sourceFile = (await ensureDocCache(target)).file
1910
+ } else {
1911
+ sourceFile = (await readDocBytes(target, target)).data
1912
+ }
1769
1913
  const temp = await makeDocTemp()
1770
1914
  try {
1771
1915
  const source = temp.file('source.' + kind)
1772
1916
  const spec = temp.file('data.json')
1773
1917
  const output = temp.file('output.' + kind)
1774
- writeFileSync(source, current.data)
1918
+ writeFileSync(source, Buffer.isBuffer(sourceFile) ? sourceFile : readFileSync(sourceFile))
1775
1919
  writeFileSync(spec, JSON.stringify(data))
1776
1920
  await runDocTool(['write', '--kind', kind, '--file', source, '--data', spec, '--out', output])
1777
1921
  const written = await writeDocBytes(target, readFileSync(output))
@@ -2221,6 +2365,35 @@ export function apply(ctx) {
2221
2365
  return { opened: true, localPath, size: read.data.length }
2222
2366
  },
2223
2367
 
2368
+ async 'terminal.open'(params = {}) {
2369
+ const side = params.side === 'local' ? 'local' : 'remote'
2370
+ const testMode = process.env.DSH_LINKED_WORKSPACE_NO_TERMINAL === '1'
2371
+ const scope = await requireScope(params)
2372
+ const snapshot = scope.kind === 'linked' ? (scope.record.hostSnapshot || {}) : (scope.workspace.hostSnapshot || {})
2373
+ if (side === 'local') {
2374
+ if (scope.kind !== 'linked') throw new ApiError('BAD_REQUEST', '该工作区没有本地目录')
2375
+ const target = scope.record.localPath
2376
+ let info
2377
+ try {
2378
+ info = await fsStat(target)
2379
+ } catch (error) {
2380
+ throw mapLocalFsError(error)
2381
+ }
2382
+ if (!info.isDirectory()) throw new ApiError('FS_NOT_DIR', '本地目录不存在')
2383
+ if (testMode) return { opened: true, side, path: target, test: true }
2384
+ if (!(await openLocalTerminal(target))) throw new ApiError('INTERNAL', '无法启动本地终端')
2385
+ return { opened: true, side, path: target }
2386
+ }
2387
+ const stat = await scope.remote.stat(scope.root)
2388
+ if (stat.type !== 'dir') throw new ApiError('FS_NOT_DIR', '远程目录不存在')
2389
+ if (!snapshot.host || !snapshot.username) throw new ApiError('AUTH_FAILED', '缺少 SSH 主机或用户名,无法打开终端')
2390
+ if (testMode) return { opened: true, side, path: scope.root, host: snapshot.host, test: true }
2391
+ if (!(await openRemoteTerminal(snapshot.host, snapshot.port, snapshot.username, scope.root))) {
2392
+ throw new ApiError('INTERNAL', '无法启动远程终端(找不到可用的终端程序)')
2393
+ }
2394
+ return { opened: true, side, path: scope.root, host: snapshot.host }
2395
+ },
2396
+
2224
2397
  async 'session.info'(params = {}) {
2225
2398
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : ''
2226
2399
  if (sessionId === '') throw new ApiError('BAD_REQUEST', 'sessionId is required')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-linked-workspace",
3
- "version": "0.6.1",
3
+ "version": "0.7.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",
@@ -13,6 +13,12 @@ import os
13
13
  import re
14
14
  import sys
15
15
 
16
+ try:
17
+ sys.stdout.reconfigure(encoding="utf-8")
18
+ sys.stderr.reconfigure(encoding="utf-8")
19
+ except Exception:
20
+ pass
21
+
16
22
  MAX_PDF_PAGES = 40
17
23
  MAX_PDF_WIDTH = 1100
18
24
  MAX_SHEET_ROWS = 200
@@ -21,12 +27,12 @@ MAX_PPTX_SLIDES = 80
21
27
 
22
28
 
23
29
  def fail(message):
24
- print(json.dumps({"ok": False, "error": str(message)}, ensure_ascii=False))
30
+ print(json.dumps({"ok": False, "error": str(message)}, ensure_ascii=True))
25
31
  sys.exit(1)
26
32
 
27
33
 
28
34
  def emit(data):
29
- print(json.dumps({"ok": True, "data": data}, ensure_ascii=False))
35
+ print(json.dumps({"ok": True, "data": data}, ensure_ascii=True))
30
36
 
31
37
 
32
38
  def json_value(value):
@@ -198,14 +204,16 @@ def write_pptx(source, data, out):
198
204
 
199
205
  # ── PDF ───────────────────────────────────────────────────────────────────
200
206
 
201
- def read_pdf(path):
207
+ def read_pdf(path, page_index=None):
202
208
  import fitz
203
209
 
204
210
  doc = fitz.open(path)
205
211
  pages = []
206
- for index, page in enumerate(doc):
207
- if index >= MAX_PDF_PAGES:
208
- break
212
+ indexes = [page_index] if isinstance(page_index, int) else [0]
213
+ for index in indexes:
214
+ if index < 0 or index >= doc.page_count:
215
+ continue
216
+ page = doc[index]
209
217
  rect = page.rect
210
218
  scale = min(1.5, MAX_PDF_WIDTH / max(rect.width, 1))
211
219
  pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
@@ -226,7 +234,7 @@ def read_pdf(path):
226
234
  "subject": metadata.get("subject", ""),
227
235
  },
228
236
  "pages": pages,
229
- "truncated": doc.page_count > MAX_PDF_PAGES,
237
+ "truncated": doc.page_count > 1,
230
238
  })
231
239
 
232
240
 
@@ -347,6 +355,7 @@ def main():
347
355
  read = sub.add_parser("read")
348
356
  read.add_argument("--kind", required=True)
349
357
  read.add_argument("--file", required=True)
358
+ read.add_argument("--page", type=int, default=None)
350
359
 
351
360
  write = sub.add_parser("write")
352
361
  write.add_argument("--kind", required=True)
@@ -375,7 +384,7 @@ def main():
375
384
  elif args.kind == "pptx":
376
385
  read_pptx(args.file)
377
386
  elif args.kind == "pdf":
378
- read_pdf(args.file)
387
+ read_pdf(args.file, args.page)
379
388
  else:
380
389
  raise ValueError(f"unsupported kind: {args.kind}")
381
390
  elif args.command == "write":