@code_tin/dsh-sync 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +87 -2
- package/client.js +458 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +12 -0
- package/index.js +167 -0
- package/lib/remote.js +62 -0
- package/lib/sync.js +581 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jie Wu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
通过 Git 在多个设备之间同步 DeepSeek Harness(DSH)的会话。
|
|
4
|
+
|
|
5
|
+
- npm:`@code_tin/dsh-sync`;安装:`plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.1.0" }`
|
|
6
|
+
- 设置页:**设置 → 会话 Git 同步**,填写 Git 仓库地址与分支,查看本地/镜像状态,直接拉取或推送。
|
|
7
|
+
- 命令:`/sync`、`/sync pull`、`/sync push`、`/sync test`。
|
|
8
|
+
- 数据:本机会话仍在 `~/.dsh/sessions`;插件在 `~/.dsh/dsh-sync/repo` 维护一个专用 Git 镜像仓库,不往会话目录里写 `.git`。
|
|
9
|
+
|
|
10
|
+
## 工作原理
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
本机 ~/.dsh/sessions ──复制──> ~/.dsh/dsh-sync/repo/sessions ──git push──> 远程仓库
|
|
14
|
+
本机 ~/.dsh/sessions <──复制── ~/.dsh/dsh-sync/repo/sessions <──git pull── 远程仓库
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **push**:先 flush 活跃会话,把本地 `session.*.jsonl.zstd` 复制进镜像,提交并推送。推送被拒绝(其他设备刚推过)时会自动 fetch 重试一次;同一会话冲突时本地文件优先。
|
|
18
|
+
- **pull**:把镜像快进到远程分支,只复制本地**不存在**的会话文件。本地已有的会话一律保留,并计入“跳过”数量。
|
|
19
|
+
- 只同步会话日志文件;`session.lock`、`.DS_Store` 等不会进入仓库。
|
|
20
|
+
|
|
21
|
+
## 使用
|
|
22
|
+
|
|
23
|
+
1. 在设置页填写仓库地址,例如 `https://github.com/me/dsh-sessions.git` 或 `git@github.com:me/dsh-sessions.git`,保存。
|
|
24
|
+
2. 在设备 A 执行 `/sync push`(或点“推送到远程”)。
|
|
25
|
+
3. 在设备 B 保存同一地址后执行 `/sync pull`,刷新侧边栏(或重新打开窗口)即可看到新会话。
|
|
26
|
+
|
|
27
|
+
`/sync` 显示状态;`/sync test` 测试连接。Git 认证沿用本机的 SSH agent / credential helper,插件不会保存凭据。若 HTTPS 需要账号密码,请先配置好 credential helper,否则命令会快速失败而不是弹出交互提示。
|
|
28
|
+
|
|
29
|
+
## 安装(开发目录)
|
|
30
|
+
|
|
31
|
+
在 DSH 中把本目录作为本地 bundle 安装:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
plugin_manager install_bundle { "target": "/path/to/dsh-sync" }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`package.json` 里 `dsh.bundle.patch` 插入 `dsh-sync` 行,`dsh.client` 声明 Web 设置页。
|
|
38
|
+
|
|
39
|
+
## 已知限制
|
|
40
|
+
|
|
41
|
+
- 同一个会话如果在两台设备上继续写入,pull 不会合并日志:保留本地版本并跳过该文件。需要时请在镜像仓库里手动取出远端版本。
|
|
42
|
+
- 首次 pull 之前远程必须至少有一次 push(远程分支不存在时 pull 会明确报错)。
|
|
43
|
+
- 镜像仓库会保留历史上推送过的会话;本机删除会话不会删除镜像中的副本。
|
|
44
|
+
- Git 地址保存在 `~/.dsh/dsh-sync/settings.json`(权限 0600)。不要把带 token 的 URL 提交到任何地方。
|
|
45
|
+
|
|
46
|
+
## 分发给其他设备
|
|
47
|
+
|
|
48
|
+
插件是纯 JavaScript,没有构建步骤;`npm pack` 生成的内容就是完整插件。
|
|
49
|
+
|
|
50
|
+
### 本地安装包(离线分发)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm pack
|
|
54
|
+
# 生成 code_tin-dsh-sync-0.1.0.tgz
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
把 tgz 拷到目标机器,在 DSH 里安装:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
plugin_manager install_bundle { "target": "/绝对路径/code_tin-dsh-sync-0.1.0.tgz" }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Plugin Manager 支持目录、tarball、git 地址和 npm 包名这几类安装 spec。
|
|
64
|
+
|
|
65
|
+
### 发布到 npm
|
|
66
|
+
|
|
67
|
+
包名:**`@code_tin/dsh-sync`**(public)。发布:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm login
|
|
71
|
+
npm publish --access public
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
发布后其他机器直接安装:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
plugin_manager install_bundle { "target": "@code_tin/dsh-sync@0.1.0" }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
注意:发布到 npm 只解决“可安装”,不会自动让插件出现在 DSH 插件市场的 Discover 列表;市场收录通常还需要公开仓库和索引源。
|
|
81
|
+
|
|
82
|
+
## 测试
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
node --test test/*.test.mjs
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
测试使用本地 bare 仓库,不需要网络。
|
package/client.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `dsh-sync`.
|
|
3
|
+
*
|
|
4
|
+
* Adds one page under Settings → 会话 Git 同步 (Settings → Session Git Sync).
|
|
5
|
+
* The page writes the repository URL and branch, and triggers pull/push through
|
|
6
|
+
* the host Remote service registered by `index.js`. All session bytes and git
|
|
7
|
+
* work stay on the host; this module only renders state and calls endpoints.
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: '@code_tin/dsh-sync',
|
|
11
|
+
factory(require) {
|
|
12
|
+
const React = require('react')
|
|
13
|
+
const h = React.createElement
|
|
14
|
+
|
|
15
|
+
const NS = 'dsh-sync'
|
|
16
|
+
|
|
17
|
+
const ZH = {
|
|
18
|
+
nav: '会话 Git 同步',
|
|
19
|
+
title: '会话 Git 同步',
|
|
20
|
+
subtitle: '把本机的 DSH 会话镜像到一个 Git 仓库,在多个设备之间共享。拉取只新增本地不存在的会话,不会覆盖本地已有会话。',
|
|
21
|
+
remoteLabel: 'Git 仓库地址',
|
|
22
|
+
remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
|
|
23
|
+
branchLabel: '分支',
|
|
24
|
+
branchPlaceholder: 'main',
|
|
25
|
+
save: '保存',
|
|
26
|
+
test: '测试连接',
|
|
27
|
+
pull: '从远程拉取',
|
|
28
|
+
push: '推送到远程',
|
|
29
|
+
refresh: '刷新状态',
|
|
30
|
+
unsaved: '有未保存的修改;拉取或推送前会自动保存。',
|
|
31
|
+
saved: '已保存 Git 地址。',
|
|
32
|
+
statusTitle: '同步状态',
|
|
33
|
+
commandsTitle: '命令',
|
|
34
|
+
cmdStatus: '查看同步状态',
|
|
35
|
+
cmdPull: '拉取远程会话到本地',
|
|
36
|
+
cmdPush: '把本地会话推送到远程',
|
|
37
|
+
cmdTest: '测试连接',
|
|
38
|
+
remoteStatus: '远程仓库',
|
|
39
|
+
branchStatus: '分支',
|
|
40
|
+
localStatus: '本地会话 / 文件',
|
|
41
|
+
mirrorStatus: '镜像仓库',
|
|
42
|
+
lastPush: '上次推送',
|
|
43
|
+
lastPull: '上次拉取',
|
|
44
|
+
dataDir: '本地会话目录',
|
|
45
|
+
repoDir: '镜像仓库目录',
|
|
46
|
+
notConfigured: '未配置',
|
|
47
|
+
mirrorMissing: '尚未初始化(首次推送时创建)',
|
|
48
|
+
never: '从未',
|
|
49
|
+
refreshed: '状态已刷新。',
|
|
50
|
+
loading: '正在读取同步状态…',
|
|
51
|
+
failed: '操作失败。',
|
|
52
|
+
pullDone: '拉取完成:新增 {sessions} 个会话({files} 个文件),跳过 {skipped} 个本地已存在的文件。',
|
|
53
|
+
pushDone: '推送完成:{sessions} 个会话({files} 个文件)。',
|
|
54
|
+
pushNoChanges: '推送完成:远程已是最新,无需更新。',
|
|
55
|
+
testOk: '连接成功:远程有 {branches} 个分支。',
|
|
56
|
+
testBranchMissing: '连接成功,但远程还没有分支 “{branch}”;第一次推送会创建它。',
|
|
57
|
+
noRpc: '浏览器连接服务不可用,设置页面无法调用宿主。',
|
|
58
|
+
loadFailed: '无法读取同步状态',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const EN = {
|
|
62
|
+
nav: 'Session Git Sync',
|
|
63
|
+
title: 'Session Git Sync',
|
|
64
|
+
subtitle: 'Mirror this device’s DSH sessions into a git repository and share them across devices. Pull only adds sessions missing locally; it never overwrites an existing local session.',
|
|
65
|
+
remoteLabel: 'Git repository URL',
|
|
66
|
+
remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
|
|
67
|
+
branchLabel: 'Branch',
|
|
68
|
+
branchPlaceholder: 'main',
|
|
69
|
+
save: 'Save',
|
|
70
|
+
test: 'Test connection',
|
|
71
|
+
pull: 'Pull from remote',
|
|
72
|
+
push: 'Push to remote',
|
|
73
|
+
refresh: 'Refresh status',
|
|
74
|
+
unsaved: 'Unsaved changes; pull or push saves them first.',
|
|
75
|
+
saved: 'Git URL saved.',
|
|
76
|
+
statusTitle: 'Sync status',
|
|
77
|
+
commandsTitle: 'Commands',
|
|
78
|
+
cmdStatus: 'show sync status',
|
|
79
|
+
cmdPull: 'copy remote sessions to this device',
|
|
80
|
+
cmdPush: 'push local sessions to the remote',
|
|
81
|
+
cmdTest: 'test the connection',
|
|
82
|
+
remoteStatus: 'Remote',
|
|
83
|
+
branchStatus: 'Branch',
|
|
84
|
+
localStatus: 'Local sessions / files',
|
|
85
|
+
mirrorStatus: 'Mirror',
|
|
86
|
+
lastPush: 'Last push',
|
|
87
|
+
lastPull: 'Last pull',
|
|
88
|
+
dataDir: 'Local session directory',
|
|
89
|
+
repoDir: 'Mirror directory',
|
|
90
|
+
notConfigured: 'Not configured',
|
|
91
|
+
mirrorMissing: 'Not initialized (created by the first push)',
|
|
92
|
+
never: 'Never',
|
|
93
|
+
refreshed: 'Status refreshed.',
|
|
94
|
+
loading: 'Reading sync status…',
|
|
95
|
+
failed: 'The operation failed.',
|
|
96
|
+
pullDone: 'Pulled {sessions} new sessions ({files} files); skipped {skipped} files that already exist locally.',
|
|
97
|
+
pushDone: 'Pushed {sessions} sessions ({files} files).',
|
|
98
|
+
pushNoChanges: 'Push complete: the remote is already up to date.',
|
|
99
|
+
testOk: 'Connected: the remote has {branches} branches.',
|
|
100
|
+
testBranchMissing: 'Connected, but the remote has no branch “{branch}” yet; the first push creates it.',
|
|
101
|
+
noRpc: 'The browser connection service is unavailable, so this page cannot reach the host.',
|
|
102
|
+
loadFailed: 'Could not read sync status',
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const CSS = `
|
|
106
|
+
.dshsync { display: flex; flex-direction: column; gap: 16px; max-width: 760px; padding: 4px 0 24px; }
|
|
107
|
+
.dshsync-header { display: flex; flex-direction: column; gap: 6px; }
|
|
108
|
+
.dshsync-title { margin: 0; font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
109
|
+
.dshsync-subtitle { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
|
|
110
|
+
.dshsync-card { display: flex; flex-direction: column; gap: 14px; padding: 16px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); }
|
|
111
|
+
.dshsync-field { display: flex; flex-direction: column; gap: 6px; }
|
|
112
|
+
.dshsync-row { display: flex; gap: 10px; }
|
|
113
|
+
.dshsync-grow { flex: 1 1 auto; min-width: 0; }
|
|
114
|
+
.dshsync-branch { width: 170px; flex: 0 0 auto; }
|
|
115
|
+
.dshsync-label { font-size: 12px; font-weight: 500; color: var(--dsw-alias-label-secondary); }
|
|
116
|
+
.dshsync-input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; outline: none; }
|
|
117
|
+
.dshsync-input::placeholder { color: var(--dsw-alias-state-idle-primary); }
|
|
118
|
+
.dshsync-input:focus { border-color: var(--dsw-alias-brand-primary); }
|
|
119
|
+
.dshsync-input:disabled { opacity: 0.6; }
|
|
120
|
+
.dshsync-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
121
|
+
.dshsync-btn { height: 32px; padding: 0 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1; cursor: pointer; }
|
|
122
|
+
.dshsync-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
|
|
123
|
+
.dshsync-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
|
|
124
|
+
.dshsync-btn:disabled { opacity: 0.45; cursor: default; }
|
|
125
|
+
.dshsync-btn-primary { border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); font-weight: 600; }
|
|
126
|
+
.dshsync-btn-ghost { color: var(--dsw-alias-label-secondary); border-color: transparent; }
|
|
127
|
+
.dshsync-hint { margin: 0; font-size: 12px; color: var(--dsw-alias-state-warn-primary); }
|
|
128
|
+
.dshsync-notice { margin: 0; padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-primary); white-space: pre-wrap; word-break: break-word; }
|
|
129
|
+
.dshsync-notice-ok { color: var(--dsw-alias-state-success-primary); }
|
|
130
|
+
.dshsync-notice-error { color: var(--dsw-alias-state-error-primary); }
|
|
131
|
+
.dshsync-notice-info { color: var(--dsw-alias-label-secondary); }
|
|
132
|
+
.dshsync-section-title { margin: 0; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
133
|
+
.dshsync-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 16px; margin: 0; font-size: 13px; }
|
|
134
|
+
.dshsync-k { margin: 0; color: var(--dsw-alias-label-secondary); }
|
|
135
|
+
.dshsync-v { margin: 0; color: var(--dsw-alias-label-primary); word-break: break-all; }
|
|
136
|
+
.dshsync-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
137
|
+
.dshsync-commands { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
138
|
+
.dshsync-commands li { display: flex; gap: 8px; align-items: baseline; }
|
|
139
|
+
.dshsync-commands code { padding: 1px 5px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
140
|
+
.dshsync-empty { margin: 0; font-size: 13px; color: var(--dsw-alias-label-secondary); }
|
|
141
|
+
`
|
|
142
|
+
|
|
143
|
+
function format(template, values) {
|
|
144
|
+
return String(template).replace(/\{(\w+)\}/g, (match, key) => (
|
|
145
|
+
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match
|
|
146
|
+
))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function errorText(error) {
|
|
150
|
+
if (error instanceof Error && error.message) return error.message
|
|
151
|
+
return String(error)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function timeText(value) {
|
|
155
|
+
if (!value) return ''
|
|
156
|
+
const date = new Date(value)
|
|
157
|
+
if (Number.isNaN(date.getTime())) return value
|
|
158
|
+
return date.toLocaleString()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function useLocaleTick(locale) {
|
|
162
|
+
const [, setVersion] = React.useState(0)
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
if (!locale || typeof locale.subscribe !== 'function') return undefined
|
|
165
|
+
return locale.subscribe(() => setVersion((value) => value + 1))
|
|
166
|
+
}, [locale])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createRpc(connection) {
|
|
170
|
+
const rpc = connection && connection.rpc
|
|
171
|
+
const call = async (method, args) => {
|
|
172
|
+
if (!rpc || typeof rpc.call !== 'function') {
|
|
173
|
+
throw new Error('Browser connection.rpc is unavailable.')
|
|
174
|
+
}
|
|
175
|
+
const response = await rpc.call('/api', `dsh-sync/${method}`, { args: args || {} })
|
|
176
|
+
if (!response || response.ok !== true) {
|
|
177
|
+
const detail = response && response.error
|
|
178
|
+
const message = typeof detail === 'string'
|
|
179
|
+
? detail
|
|
180
|
+
: (detail && detail.message) || 'The host did not answer.'
|
|
181
|
+
throw new Error(message)
|
|
182
|
+
}
|
|
183
|
+
return response.value
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
status: () => call('status'),
|
|
187
|
+
save: (remote, branch) => call('save', { remote, branch }),
|
|
188
|
+
test: (remote) => call('test', { remote }),
|
|
189
|
+
pull: () => call('pull'),
|
|
190
|
+
push: () => call('push'),
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function Section(props) {
|
|
195
|
+
const { t, rpc, locale } = props
|
|
196
|
+
const [snapshot, setSnapshot] = React.useState(null)
|
|
197
|
+
const [remote, setRemote] = React.useState('')
|
|
198
|
+
const [branch, setBranch] = React.useState('main')
|
|
199
|
+
const [dirty, setDirty] = React.useState(false)
|
|
200
|
+
const [busy, setBusy] = React.useState('')
|
|
201
|
+
const [notice, setNotice] = React.useState(null)
|
|
202
|
+
|
|
203
|
+
useLocaleTick(locale)
|
|
204
|
+
|
|
205
|
+
React.useEffect(() => {
|
|
206
|
+
let alive = true
|
|
207
|
+
setBusy('load')
|
|
208
|
+
Promise.resolve()
|
|
209
|
+
.then(() => rpc.status())
|
|
210
|
+
.then((value) => {
|
|
211
|
+
if (!alive) return
|
|
212
|
+
setSnapshot(value)
|
|
213
|
+
setRemote(value.remote || '')
|
|
214
|
+
setBranch(value.branch || 'main')
|
|
215
|
+
})
|
|
216
|
+
.catch((error) => {
|
|
217
|
+
if (alive) setNotice({ kind: 'error', text: `${t('loadFailed')}: ${errorText(error)}` })
|
|
218
|
+
})
|
|
219
|
+
.finally(() => {
|
|
220
|
+
if (alive) setBusy('')
|
|
221
|
+
})
|
|
222
|
+
return () => { alive = false }
|
|
223
|
+
}, [rpc])
|
|
224
|
+
|
|
225
|
+
const perform = async (name, task) => {
|
|
226
|
+
setBusy(name)
|
|
227
|
+
setNotice(null)
|
|
228
|
+
try {
|
|
229
|
+
const result = await task()
|
|
230
|
+
if (result && result.status) setSnapshot(result.status)
|
|
231
|
+
if (result && result.ok === false) {
|
|
232
|
+
setNotice({ kind: 'error', text: result.message || t('failed') })
|
|
233
|
+
return result
|
|
234
|
+
}
|
|
235
|
+
return result
|
|
236
|
+
} catch (error) {
|
|
237
|
+
setNotice({ kind: 'error', text: errorText(error) })
|
|
238
|
+
return undefined
|
|
239
|
+
} finally {
|
|
240
|
+
setBusy('')
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const saveFirst = async () => {
|
|
245
|
+
if (!dirty) return true
|
|
246
|
+
setBusy('save')
|
|
247
|
+
try {
|
|
248
|
+
const saved = await rpc.save(remote.trim(), branch.trim())
|
|
249
|
+
if (saved && saved.status) setSnapshot(saved.status)
|
|
250
|
+
if (saved && saved.ok === false) {
|
|
251
|
+
setNotice({ kind: 'error', text: saved.message || t('failed') })
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
setDirty(false)
|
|
255
|
+
setRemote(saved && saved.settings ? saved.settings.remote : remote.trim())
|
|
256
|
+
setBranch(saved && saved.settings ? saved.settings.branch : branch.trim())
|
|
257
|
+
return true
|
|
258
|
+
} catch (error) {
|
|
259
|
+
setNotice({ kind: 'error', text: errorText(error) })
|
|
260
|
+
return false
|
|
261
|
+
} finally {
|
|
262
|
+
setBusy('')
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const onSave = async () => {
|
|
267
|
+
const result = await perform('save', () => rpc.save(remote.trim(), branch.trim()))
|
|
268
|
+
if (!result || result.ok === false) return
|
|
269
|
+
setDirty(false)
|
|
270
|
+
if (result.settings) {
|
|
271
|
+
setRemote(result.settings.remote)
|
|
272
|
+
setBranch(result.settings.branch)
|
|
273
|
+
}
|
|
274
|
+
setNotice({ kind: 'ok', text: t('saved') })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const onTest = async () => {
|
|
278
|
+
const result = await perform('test', () => rpc.test(remote.trim()))
|
|
279
|
+
if (!result || result.ok === false) return
|
|
280
|
+
const wanted = branch.trim() || 'main'
|
|
281
|
+
const branches = Array.isArray(result.branches) ? result.branches : []
|
|
282
|
+
setNotice({
|
|
283
|
+
kind: branches.includes(wanted) ? 'ok' : 'info',
|
|
284
|
+
text: branches.includes(wanted)
|
|
285
|
+
? format(t('testOk'), { branches: branches.length })
|
|
286
|
+
: format(t('testBranchMissing'), { branch: wanted }),
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const onPull = async () => {
|
|
291
|
+
const saved = await saveFirst()
|
|
292
|
+
if (!saved) return
|
|
293
|
+
const result = await perform('pull', () => rpc.pull())
|
|
294
|
+
if (!result || result.ok === false) return
|
|
295
|
+
setNotice({
|
|
296
|
+
kind: 'ok',
|
|
297
|
+
text: format(t('pullDone'), {
|
|
298
|
+
sessions: result.addedSessions || 0,
|
|
299
|
+
files: result.addedFiles || 0,
|
|
300
|
+
skipped: result.skippedFiles || 0,
|
|
301
|
+
}),
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const onPush = async () => {
|
|
306
|
+
const saved = await saveFirst()
|
|
307
|
+
if (!saved) return
|
|
308
|
+
const result = await perform('push', () => rpc.push())
|
|
309
|
+
if (!result || result.ok === false) return
|
|
310
|
+
const base = result.pushed
|
|
311
|
+
? format(t('pushDone'), { sessions: result.sessions || 0, files: result.files || 0 })
|
|
312
|
+
: t('pushNoChanges')
|
|
313
|
+
setNotice({ kind: 'ok', text: result.warning ? `${base}\n${result.warning}` : base })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const onRefresh = async () => {
|
|
317
|
+
setBusy('refresh')
|
|
318
|
+
try {
|
|
319
|
+
const value = await rpc.status()
|
|
320
|
+
setSnapshot(value)
|
|
321
|
+
if (!dirty) {
|
|
322
|
+
setRemote(value.remote || '')
|
|
323
|
+
setBranch(value.branch || 'main')
|
|
324
|
+
}
|
|
325
|
+
setNotice({ kind: 'info', text: t('refreshed') })
|
|
326
|
+
} catch (error) {
|
|
327
|
+
setNotice({ kind: 'error', text: errorText(error) })
|
|
328
|
+
} finally {
|
|
329
|
+
setBusy('')
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const disabled = busy !== ''
|
|
334
|
+
const state = snapshot && snapshot.state ? snapshot.state : {}
|
|
335
|
+
const local = snapshot && snapshot.local ? snapshot.local : { sessions: 0, files: 0 }
|
|
336
|
+
const mirror = snapshot && snapshot.mirror ? snapshot.mirror : { initialized: false, sessions: 0, files: 0 }
|
|
337
|
+
|
|
338
|
+
const row = (key, label, value, mono) => [
|
|
339
|
+
h('dt', { className: 'dshsync-k', key: `${key}-k` }, label),
|
|
340
|
+
h('dd', { className: `dshsync-v${mono ? ' dshsync-mono' : ''}`, key: `${key}-v` }, value),
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
return h('div', { className: 'dshsync' },
|
|
344
|
+
h('style', { key: 'style' }, CSS),
|
|
345
|
+
h('header', { className: 'dshsync-header', key: 'header' },
|
|
346
|
+
h('h2', { className: 'dshsync-title' }, t('title')),
|
|
347
|
+
h('p', { className: 'dshsync-subtitle' }, t('subtitle')),
|
|
348
|
+
),
|
|
349
|
+
h('section', { className: 'dshsync-card', key: 'config' },
|
|
350
|
+
h('div', { className: 'dshsync-row' },
|
|
351
|
+
h('div', { className: 'dshsync-field dshsync-grow' },
|
|
352
|
+
h('label', { className: 'dshsync-label', htmlFor: 'dshsync-remote' }, t('remoteLabel')),
|
|
353
|
+
h('input', {
|
|
354
|
+
id: 'dshsync-remote',
|
|
355
|
+
className: 'dshsync-input',
|
|
356
|
+
type: 'text',
|
|
357
|
+
value: remote,
|
|
358
|
+
placeholder: t('remotePlaceholder'),
|
|
359
|
+
spellCheck: false,
|
|
360
|
+
autoCapitalize: 'off',
|
|
361
|
+
autoCorrect: 'off',
|
|
362
|
+
disabled: busy === 'save',
|
|
363
|
+
onChange: (event) => {
|
|
364
|
+
setRemote(event.target.value)
|
|
365
|
+
setDirty(true)
|
|
366
|
+
},
|
|
367
|
+
}),
|
|
368
|
+
),
|
|
369
|
+
h('div', { className: 'dshsync-field dshsync-branch' },
|
|
370
|
+
h('label', { className: 'dshsync-label', htmlFor: 'dshsync-branch' }, t('branchLabel')),
|
|
371
|
+
h('input', {
|
|
372
|
+
id: 'dshsync-branch',
|
|
373
|
+
className: 'dshsync-input',
|
|
374
|
+
type: 'text',
|
|
375
|
+
value: branch,
|
|
376
|
+
placeholder: t('branchPlaceholder'),
|
|
377
|
+
spellCheck: false,
|
|
378
|
+
autoCapitalize: 'off',
|
|
379
|
+
autoCorrect: 'off',
|
|
380
|
+
disabled: busy === 'save',
|
|
381
|
+
onChange: (event) => {
|
|
382
|
+
setBranch(event.target.value)
|
|
383
|
+
setDirty(true)
|
|
384
|
+
},
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
),
|
|
388
|
+
h('div', { className: 'dshsync-actions' },
|
|
389
|
+
h('button', { type: 'button', className: 'dshsync-btn dshsync-btn-primary', onClick: onSave, disabled }, t('save')),
|
|
390
|
+
h('button', { type: 'button', className: 'dshsync-btn', onClick: onTest, disabled }, t('test')),
|
|
391
|
+
h('button', { type: 'button', className: 'dshsync-btn', onClick: onPull, disabled }, t('pull')),
|
|
392
|
+
h('button', { type: 'button', className: 'dshsync-btn', onClick: onPush, disabled }, t('push')),
|
|
393
|
+
h('button', { type: 'button', className: 'dshsync-btn dshsync-btn-ghost', onClick: onRefresh, disabled }, t('refresh')),
|
|
394
|
+
),
|
|
395
|
+
dirty ? h('p', { className: 'dshsync-hint' }, t('unsaved')) : null,
|
|
396
|
+
notice ? h('p', {
|
|
397
|
+
className: `dshsync-notice dshsync-notice-${notice.kind}`,
|
|
398
|
+
role: 'status',
|
|
399
|
+
'aria-live': 'polite',
|
|
400
|
+
}, notice.text) : null,
|
|
401
|
+
),
|
|
402
|
+
h('section', { className: 'dshsync-card', key: 'status' },
|
|
403
|
+
h('h3', { className: 'dshsync-section-title' }, t('statusTitle')),
|
|
404
|
+
snapshot === null
|
|
405
|
+
? h('p', { className: 'dshsync-empty' }, t('loading'))
|
|
406
|
+
: h('dl', { className: 'dshsync-grid' },
|
|
407
|
+
row('remote', t('remoteStatus'), snapshot.remote || t('notConfigured')),
|
|
408
|
+
row('branch', t('branchStatus'), snapshot.branch),
|
|
409
|
+
row('local', t('localStatus'), `${local.sessions} / ${local.files}`),
|
|
410
|
+
row('mirror', t('mirrorStatus'), mirror.initialized
|
|
411
|
+
? `${mirror.sessions} / ${mirror.files}`
|
|
412
|
+
: t('mirrorMissing')),
|
|
413
|
+
row('push', t('lastPush'), state.lastPushAt
|
|
414
|
+
? `${timeText(state.lastPushAt)}${state.lastPushOk === false ? ' ✕' : ''}`
|
|
415
|
+
: t('never')),
|
|
416
|
+
row('pull', t('lastPull'), state.lastPullAt
|
|
417
|
+
? `${timeText(state.lastPullAt)}${state.lastPullOk === false ? ' ✕' : ''}`
|
|
418
|
+
: t('never')),
|
|
419
|
+
row('sessionsRoot', t('dataDir'), snapshot.sessionsRoot, true),
|
|
420
|
+
row('repoDir', t('repoDir'), snapshot.repoDir, true),
|
|
421
|
+
),
|
|
422
|
+
),
|
|
423
|
+
h('section', { className: 'dshsync-card', key: 'commands' },
|
|
424
|
+
h('h3', { className: 'dshsync-section-title' }, t('commandsTitle')),
|
|
425
|
+
h('ul', { className: 'dshsync-commands' },
|
|
426
|
+
h('li', { key: 'status' }, h('code', null, '/sync'), h('span', null, t('cmdStatus'))),
|
|
427
|
+
h('li', { key: 'pull' }, h('code', null, '/sync pull'), h('span', null, t('cmdPull'))),
|
|
428
|
+
h('li', { key: 'push' }, h('code', null, '/sync push'), h('span', null, t('cmdPush'))),
|
|
429
|
+
h('li', { key: 'test' }, h('code', null, '/sync test'), h('span', null, t('cmdTest'))),
|
|
430
|
+
),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function apply(ctx) {
|
|
436
|
+
ctx.effect(
|
|
437
|
+
() => ctx.locale.register(NS, { zh: ZH, en: EN }),
|
|
438
|
+
`${NS}: locale dictionaries`,
|
|
439
|
+
)
|
|
440
|
+
const t = ctx.locale.bind(NS)
|
|
441
|
+
const connection = ctx.get('connection')
|
|
442
|
+
const rpc = createRpc(connection)
|
|
443
|
+
if (!connection || !connection.rpc) {
|
|
444
|
+
ctx.logger?.warn?.(`${NS}: connection.rpc is unavailable; the settings page cannot call the host`)
|
|
445
|
+
}
|
|
446
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
447
|
+
name: 'settings.section',
|
|
448
|
+
id: NS,
|
|
449
|
+
order: 55,
|
|
450
|
+
label: () => t('nav'),
|
|
451
|
+
}, function SyncSettingsPage(ownerProps) {
|
|
452
|
+
return h(Section, { ...ownerProps, t, rpc, locale: ctx.locale })
|
|
453
|
+
}))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { inject: ['slots', 'locale', 'connection'], apply }
|
|
457
|
+
},
|
|
458
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# `@code_tin/dsh-sync` bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# One host row plus the browser half declared by `dsh.client` in package.json.
|
|
4
|
+
# The row id is the plugin's own so a higher-priority profile patch can override
|
|
5
|
+
# this row's `config` (there is none today) without renaming the plugin.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: dsh-sync
|
|
8
|
+
name: '@code_tin/dsh-sync'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session Git Sync">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
|
|
3
|
+
<g fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<path d="M21 16v23a6 6 0 0 0 6 6h14"/>
|
|
5
|
+
<path d="M43 16v8"/>
|
|
6
|
+
</g>
|
|
7
|
+
<circle cx="21" cy="13" r="5" fill="#ffffff"/>
|
|
8
|
+
<circle cx="43" cy="13" r="5" fill="#ffffff"/>
|
|
9
|
+
<circle cx="15" cy="47" r="5" fill="#ffffff"/>
|
|
10
|
+
<path d="M44 36a9 9 0 0 1 6 15" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round"/>
|
|
11
|
+
<path d="M52 44v7h-7" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
</svg>
|