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 +14 -0
- package/README.en.md +13 -2
- package/README.md +5 -2
- package/docs/API.md +4 -3
- package/docs/VERIFY.md +12 -8
- package/lib/client.js +366 -31
- package/lib/index.js +177 -4
- package/package.json +1 -1
- package/scripts/doc_tool.py +17 -8
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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.
|
|
80
|
-
10.
|
|
81
|
-
11.
|
|
82
|
-
12.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
4571
|
-
var
|
|
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 () {
|
|
4582
|
-
h('span', { className: 'rw-hint' },
|
|
4583
|
-
h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: state.page >=
|
|
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[
|
|
4586
|
-
h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', disabled:
|
|
4587
|
-
|
|
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
|
|
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
|
-
|
|
4917
|
-
|
|
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();
|
|
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 () {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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",
|
package/scripts/doc_tool.py
CHANGED
|
@@ -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=
|
|
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=
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 >
|
|
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":
|