dsh-linked-workspace 0.6.2 → 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 +8 -0
- package/README.en.md +9 -2
- package/README.md +5 -2
- package/docs/API.md +4 -3
- package/docs/VERIFY.md +12 -8
- package/lib/client.js +326 -20
- package/lib/index.js +116 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.6.2 — 2026-11-05
|
|
4
12
|
|
|
5
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.
|
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,10 @@ 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
|
+
|
|
118
125
|
### 0.6.2
|
|
119
126
|
|
|
120
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.
|
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,6 +4519,7 @@ 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,
|
|
@@ -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 });
|
|
@@ -4835,6 +4963,7 @@ function fileIconSpec(name, isDir) {
|
|
|
4835
4963
|
var fileDirtyState = useState({});
|
|
4836
4964
|
var fileDirty = fileDirtyState[0];
|
|
4837
4965
|
var setFileDirty = fileDirtyState[1];
|
|
4966
|
+
var saveHandlersRef = useRef({});
|
|
4838
4967
|
var directoryTabId = props.tab && props.tab.id;
|
|
4839
4968
|
|
|
4840
4969
|
useEffect(function () {
|
|
@@ -4917,7 +5046,25 @@ function fileIconSpec(name, isDir) {
|
|
|
4917
5046
|
});
|
|
4918
5047
|
}
|
|
4919
5048
|
|
|
4920
|
-
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) {
|
|
4921
5068
|
if (!selected || !filePath) return;
|
|
4922
5069
|
var openMode = settings.fileOpenMode || 'editor';
|
|
4923
5070
|
if (openMode === 'copy') {
|
|
@@ -4936,14 +5083,34 @@ function fileIconSpec(name, isDir) {
|
|
|
4936
5083
|
});
|
|
4937
5084
|
return;
|
|
4938
5085
|
}
|
|
4939
|
-
openInternalFile(side, filePath);
|
|
5086
|
+
openInternalFile(side, filePath, options || { pinned: true });
|
|
4940
5087
|
}
|
|
4941
5088
|
|
|
4942
|
-
function openInternalFile(side, filePath) {
|
|
5089
|
+
function openInternalFile(side, filePath, options) {
|
|
5090
|
+
var opts = options || { pinned: true };
|
|
4943
5091
|
var key = side + ':' + (side === 'remote' ? scopeKeyOf(scopeRef) : 'local') + ':' + filePath;
|
|
4944
5092
|
setOpenFiles(function (previous) {
|
|
4945
|
-
|
|
4946
|
-
|
|
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) }]);
|
|
4947
5114
|
});
|
|
4948
5115
|
setActiveFileKey(key);
|
|
4949
5116
|
setDirectoryOpen(true);
|
|
@@ -4953,11 +5120,67 @@ function fileIconSpec(name, isDir) {
|
|
|
4953
5120
|
setOpenFiles(function (previous) {
|
|
4954
5121
|
var next = previous.filter(function (file) { return file.key !== key; });
|
|
4955
5122
|
setFileDirty(function (dirtyMap) { var copy = Object.assign({}, dirtyMap); delete copy[key]; return copy; });
|
|
5123
|
+
delete saveHandlersRef.current[key];
|
|
4956
5124
|
if (activeFileKey === key) setActiveFileKey(next.length > 0 ? next[next.length - 1].key : '');
|
|
4957
5125
|
return next;
|
|
4958
5126
|
});
|
|
4959
5127
|
}
|
|
4960
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
|
+
|
|
4961
5184
|
function closeContextMenu() { setCtxMenu(null); }
|
|
4962
5185
|
|
|
4963
5186
|
function openContextMenu(side, info) {
|
|
@@ -5227,6 +5450,18 @@ function fileIconSpec(name, isDir) {
|
|
|
5227
5450
|
onConfirm: function () { setDialog(null); confirmDelete(dialog); },
|
|
5228
5451
|
});
|
|
5229
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
|
+
}
|
|
5230
5465
|
return h(PromptDialog, {
|
|
5231
5466
|
t: t,
|
|
5232
5467
|
title: dialog.title,
|
|
@@ -5340,7 +5575,8 @@ function fileIconSpec(name, isDir) {
|
|
|
5340
5575
|
showHidden: showHidden,
|
|
5341
5576
|
selectedPath: selectedEntry && selectedEntry.side === 'remote' ? selectedEntry.path : '',
|
|
5342
5577
|
onSelectPath: function (info) { setSelectedEntry(Object.assign({ side: 'remote' }, info)); },
|
|
5343
|
-
onSelect: function (filePath) { openByMode('remote', filePath); },
|
|
5578
|
+
onSelect: function (filePath) { openByMode('remote', filePath, { pinned: false, preview: true }); },
|
|
5579
|
+
onPin: function (filePath) { pinByMode('remote', filePath); },
|
|
5344
5580
|
onContextMenu: function (info) { setSelectedEntry(Object.assign({ side: 'remote' }, info)); openContextMenu('remote', info); },
|
|
5345
5581
|
})),
|
|
5346
5582
|
selected.kind === 'linked'
|
|
@@ -5360,7 +5596,8 @@ function fileIconSpec(name, isDir) {
|
|
|
5360
5596
|
showHidden: showHidden,
|
|
5361
5597
|
selectedPath: selectedEntry && selectedEntry.side === 'local' ? selectedEntry.path : '',
|
|
5362
5598
|
onSelectPath: function (info) { setSelectedEntry(Object.assign({ side: 'local' }, info)); },
|
|
5363
|
-
onSelect: function (filePath) { openByMode('local', filePath); },
|
|
5599
|
+
onSelect: function (filePath) { openByMode('local', filePath, { pinned: false, preview: true }); },
|
|
5600
|
+
onPin: function (filePath) { pinByMode('local', filePath); },
|
|
5364
5601
|
refreshTick: localTick,
|
|
5365
5602
|
onContextMenu: function (info) { setSelectedEntry(Object.assign({ side: 'local' }, info)); openContextMenu('local', info); },
|
|
5366
5603
|
}))
|
|
@@ -5377,6 +5614,10 @@ function fileIconSpec(name, isDir) {
|
|
|
5377
5614
|
path: activeFile.path,
|
|
5378
5615
|
local: activeFile.side === 'local',
|
|
5379
5616
|
onSaved: refreshTree,
|
|
5617
|
+
onSaveReady: function (handler) {
|
|
5618
|
+
if (handler) saveHandlersRef.current[activeFile.key] = handler;
|
|
5619
|
+
else delete saveHandlersRef.current[activeFile.key];
|
|
5620
|
+
},
|
|
5380
5621
|
onDirtyChange: function (dirty) {
|
|
5381
5622
|
setFileDirty(function (previous) {
|
|
5382
5623
|
if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
|
|
@@ -5393,6 +5634,10 @@ function fileIconSpec(name, isDir) {
|
|
|
5393
5634
|
local: activeFile.side === 'local',
|
|
5394
5635
|
readOnly: remoteOnly,
|
|
5395
5636
|
hideHeader: true,
|
|
5637
|
+
onSaveReady: function (handler) {
|
|
5638
|
+
if (handler) saveHandlersRef.current[activeFile.key] = handler;
|
|
5639
|
+
else delete saveHandlersRef.current[activeFile.key];
|
|
5640
|
+
},
|
|
5396
5641
|
onDirtyChange: function (dirty) {
|
|
5397
5642
|
setFileDirty(function (previous) {
|
|
5398
5643
|
if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
|
|
@@ -5416,6 +5661,8 @@ function fileIconSpec(name, isDir) {
|
|
|
5416
5661
|
openMode: settings.fileOpenMode || 'editor',
|
|
5417
5662
|
directoryOpen: directoryOpen,
|
|
5418
5663
|
onToggleDirectory: function () { setDirectoryOpen(!directoryOpen); },
|
|
5664
|
+
onOpenTerminal: openTerminal,
|
|
5665
|
+
localEnabled: !remoteOnly && selected.kind === 'linked',
|
|
5419
5666
|
showHidden: showHidden,
|
|
5420
5667
|
onToggleHidden: function () { setShowHidden(!showHidden); },
|
|
5421
5668
|
transferEnabled: !remoteOnly && Boolean(selectedEntry),
|
|
@@ -5474,7 +5721,7 @@ function fileIconSpec(name, isDir) {
|
|
|
5474
5721
|
h('span', { className: 'rw-file-tab-name' }, file.name),
|
|
5475
5722
|
h('button', {
|
|
5476
5723
|
type: 'button', className: 'rw-file-tab-close', title: t('closeFile'),
|
|
5477
|
-
onClick: function (event) { event.stopPropagation();
|
|
5724
|
+
onClick: function (event) { event.stopPropagation(); requestCloseInternalFile(file.key); },
|
|
5478
5725
|
}, IconClose(10)));
|
|
5479
5726
|
}))
|
|
5480
5727
|
: null,
|
|
@@ -5535,6 +5782,7 @@ function fileIconSpec(name, isDir) {
|
|
|
5535
5782
|
var toggle = props.toggle;
|
|
5536
5783
|
var state = props.state;
|
|
5537
5784
|
var onSelect = props.onSelect;
|
|
5785
|
+
var onPin = props.onPin;
|
|
5538
5786
|
var onSelectPath = props.onSelectPath;
|
|
5539
5787
|
var onContextMenu = props.onContextMenu;
|
|
5540
5788
|
var selectedPath = props.selectedPath;
|
|
@@ -5572,6 +5820,7 @@ function fileIconSpec(name, isDir) {
|
|
|
5572
5820
|
state: state,
|
|
5573
5821
|
load: props.load,
|
|
5574
5822
|
onSelect: onSelect,
|
|
5823
|
+
onPin: onPin,
|
|
5575
5824
|
onSelectPath: onSelectPath,
|
|
5576
5825
|
onContextMenu: onContextMenu,
|
|
5577
5826
|
selectedPath: selectedPath,
|
|
@@ -5584,10 +5833,11 @@ function fileIconSpec(name, isDir) {
|
|
|
5584
5833
|
className: 'rw-row' + (active ? ' is-active' : ''),
|
|
5585
5834
|
style: { paddingLeft: 6 + (depth + 1) * 12 },
|
|
5586
5835
|
title: child.path,
|
|
5587
|
-
onClick: function () {
|
|
5836
|
+
onClick: function (event) {
|
|
5588
5837
|
if (onSelectPath) onSelectPath({ path: child.path, name: child.name, isDir: false });
|
|
5838
|
+
if (event.detail !== 2) onSelect(child.path);
|
|
5589
5839
|
},
|
|
5590
|
-
onDoubleClick: function () {
|
|
5840
|
+
onDoubleClick: function () { if (onPin) onPin(child.path); },
|
|
5591
5841
|
onContextMenu: function (event) {
|
|
5592
5842
|
if (!onContextMenu) return;
|
|
5593
5843
|
event.preventDefault();
|
|
@@ -5610,10 +5860,10 @@ function fileIconSpec(name, isDir) {
|
|
|
5610
5860
|
className: 'rw-row' + (selectedPath === path ? ' is-active' : ''),
|
|
5611
5861
|
style: { paddingLeft: 6 + depth * 12 },
|
|
5612
5862
|
title: path,
|
|
5613
|
-
onClick: function () {
|
|
5863
|
+
onClick: function (event) {
|
|
5614
5864
|
if (onSelectPath) onSelectPath({ path: path, name: name, isDir: true });
|
|
5865
|
+
if (event.detail !== 2) toggle(path);
|
|
5615
5866
|
},
|
|
5616
|
-
onDoubleClick: function () { toggle(path); },
|
|
5617
5867
|
onContextMenu: function (event) {
|
|
5618
5868
|
if (!onContextMenu) return;
|
|
5619
5869
|
event.preventDefault();
|
|
@@ -6408,6 +6658,61 @@ function fileIconSpec(name, isDir) {
|
|
|
6408
6658
|
: null);
|
|
6409
6659
|
}
|
|
6410
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
|
+
|
|
6411
6716
|
function ConnectionsPage(props) {
|
|
6412
6717
|
var ctx = props.ctx;
|
|
6413
6718
|
var locale = useLocale(ctx);
|
|
@@ -6449,6 +6754,7 @@ function fileIconSpec(name, isDir) {
|
|
|
6449
6754
|
}, adding === 'remote' ? IconClose(13) : IconServer(13), t('addRemoteDir')))),
|
|
6450
6755
|
!sidebarAvailable ? h(Banner, { kind: 'warn' }, t('needsSidebar')) : null,
|
|
6451
6756
|
h(ThemeGroup, { t: t, locale: locale }),
|
|
6757
|
+
h(EditorSettingsGroup, { t: t, locale: locale }),
|
|
6452
6758
|
adding !== ''
|
|
6453
6759
|
? h(AddWorkspaceFlow, {
|
|
6454
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
|
}
|
|
@@ -2279,6 +2365,35 @@ export function apply(ctx) {
|
|
|
2279
2365
|
return { opened: true, localPath, size: read.data.length }
|
|
2280
2366
|
},
|
|
2281
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
|
+
|
|
2282
2397
|
async 'session.info'(params = {}) {
|
|
2283
2398
|
const sessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : ''
|
|
2284
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",
|