@caozeal/dsh-next-git 0.1.5 → 0.1.6
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/README.md +19 -7
- package/assets/screenshots/01-changes.png +0 -0
- package/assets/screenshots/02-commit-graph.png +0 -0
- package/assets/screenshots/03-word-level-diff.png +0 -0
- package/assets/screenshots/04-branches.png +0 -0
- package/assets/screenshots/05-graph-multibranch.png +0 -0
- package/assets/screenshots/06-fullscreen.png +0 -0
- package/assets/screenshots/README.md +24 -0
- package/lib/client.js +291 -12
- package/package.json +3 -1
- package/screenshots.json +8 -0
- package/src/ai-commit.js +65 -22
- package/src/operations.js +122 -3
package/README.md
CHANGED
|
@@ -10,6 +10,10 @@ https://github.com/user-attachments/assets/c57169b4-df00-450d-b4f0-7a2c64db03b6
|
|
|
10
10
|
|
|
11
11
|
*全片界面为渲染示意,不含真实会话;[完整播放页](https://dsh-next-git.1017835747.workers.dev/) 另有下载版。*
|
|
12
12
|
|
|
13
|
+
**不想先装再看?** [在线 Demo](https://dsh.caozeal.cn/next-git/demo/) 可以直接点——
|
|
14
|
+
它加载的是发布包里同一份 `lib/client.js`,只补了一个最小假宿主和一份真实仓库的录制快照
|
|
15
|
+
(详见 [`demo/README.md`](https://github.com/caozeal/dsh-plugins/blob/main/dsh-next-git/demo/README.md))。写操作在 Demo 里是禁用的,会明确提示。
|
|
16
|
+
|
|
13
17
|
在 DSH Web GUI **会话的右侧边栏**加一个 Git tab(2026-10-22 从左侧全局面板迁移而来):
|
|
14
18
|
会话头部点 **Git 图标**直接打开(会自动展开右栏),或在右栏的「+ → 开始」指南页里
|
|
15
19
|
选「Git 管理」。面板内容:
|
|
@@ -17,12 +21,20 @@ https://github.com/user-attachments/assets/c57169b4-df00-450d-b4f0-7a2c64db03b6
|
|
|
17
21
|
- **便捷分支切换**:顶栏下拉出全部本地/远程分支(含 ahead/behind 与最近提交),
|
|
18
22
|
一键切换;点远程分支自动建同名跟踪分支;支持新建、删除分支。
|
|
19
23
|
- **变更管理**:staged / unstaged / untracked / conflicted 四组文件列表,
|
|
20
|
-
单文件或全部暂存/取消暂存,点击文件看 diff
|
|
24
|
+
单文件或全部暂存/取消暂存,点击文件看 diff。每行还带**丢弃**按钮:
|
|
25
|
+
未暂存回到 HEAD、未跟踪直接删除、已暂存连暂存区一起回退(都会二次确认)。
|
|
26
|
+
冲突文件不给丢弃——那等于放弃冲突解决状态,容易误伤。
|
|
27
|
+
- **Stash**:分支页底部可直接 stash 当前改动(有未跟踪文件时自动带 `-u`),
|
|
28
|
+
列出全部 stash,一键恢复(pop)或删除(drop)。
|
|
21
29
|
- **提交 / 推送 / 拉取**:Fetch、Pull、Push 按钮,提交信息输入框,
|
|
22
30
|
支持「提交」「提交并推送」;无 upstream 时自动 `push -u origin <branch>`。
|
|
23
|
-
- **AI 提交信息**:读取已暂存 diff +
|
|
24
|
-
Conventional Commits
|
|
25
|
-
`chore: 更新 N 个文件`。
|
|
31
|
+
- **AI 提交信息**:读取已暂存 diff + 最近提交风格,调用当前默认模型生成
|
|
32
|
+
Conventional Commits 提交信息(标题 + 可选正文,正文用来说清动机与影响面),
|
|
33
|
+
填入输入框供编辑;模型不可用时降级为 `chore: 更新 N 个文件`。
|
|
34
|
+
- **可自定义的提交提示词**:提交区齿轮按钮可改写系统提示词,存在
|
|
35
|
+
`git config dsh.commitPrompt`(本地优先、可全局兜底,不产生工作区文件)。
|
|
36
|
+
自定义只改「怎么写」,diff 与变更文件列表始终由 Host 装配后传给模型——
|
|
37
|
+
提示词写坏也不会让模型凭空编造。
|
|
26
38
|
- **彩色提交图(graph)**:`git log --graph --all` 的图形化渲染。每条分支线一种颜色,
|
|
27
39
|
跨行保持同色;分支名徽章与它自己的线条严格同色,滚到哪都认得出。同一屏出现过的
|
|
28
40
|
分支保证互不撞色(按首次出现顺序分配,不是对名字取哈希——8 色槽下取模约 1/8 会撞)。
|
|
@@ -175,7 +187,7 @@ op 一览:`snapshot` `graph` `diff` `stage` `unstage` `switchBranch` `deleteBr
|
|
|
175
187
|
`fallback: true`(UI 显示「模型输出不可用,已生成降级文案」)。
|
|
176
188
|
- **错误码分层**:`invalid-params`(含未知 op)HTTP 400;git 业务失败 HTTP 200 +
|
|
177
189
|
`ok:false`,UI 统一按 body 判定。
|
|
178
|
-
- **文案全部走 Client locale**(zh/en 共
|
|
190
|
+
- **文案全部走 Client locale**(zh/en 共 134 个 key),tab 标题与头部按钮都是
|
|
179
191
|
「Git 管理 / 打开 Git 管理」;面板只用 `--dsw-alias-*` 主题 token。
|
|
180
192
|
|
|
181
193
|
## 已知限制
|
|
@@ -185,5 +197,5 @@ op 一览:`snapshot` `graph` `diff` `stage` `unstage` `switchBranch` `deleteBr
|
|
|
185
197
|
- 大仓库的 graph 默认取 80 条(上限 300);diff 超过 400k 字符会截断。
|
|
186
198
|
- 会话的工作目录被删除/不可访问时,面板显示「工作目录不存在或无法访问」空态
|
|
187
199
|
(Host 返回 `cwd-unavailable`),不会展示原始 spawn 错误。
|
|
188
|
-
-
|
|
189
|
-
- 网络操作超时 180s;无凭据的私有仓库会失败并把 git stderr 显示出来。
|
|
200
|
+
- 冲突只做展示(conflicted 分组 + 横幅提示),不提供 accept ours/theirs 之类的解决 UI。
|
|
201
|
+
- 网络操作超时 180s;无凭据的私有仓库会失败并把 git stderr 显示出来。
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 插件市场截图素材
|
|
2
|
+
|
|
3
|
+
`screenshots.json`(本目录上一级)里声明的 6 张图,供 dsh-market 等插件市场详情页展示。
|
|
4
|
+
它们由 `test/live/artifacts/` 里的真机验证截图加工而来,**都做过打码**。
|
|
5
|
+
|
|
6
|
+
| 文件 | 来源 | 处理 |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| `01-changes.png` | `V19-style-changes.png` | 左栏 + 对话区(x < 708)高斯模糊 σ=28 |
|
|
9
|
+
| `02-commit-graph.png` | `V25-colored-graph-refs.png` | 同上 |
|
|
10
|
+
| `03-word-level-diff.png` | `V24-word-level-diff.png` | 同上 |
|
|
11
|
+
| `04-branches.png` | `V20-whale-branches.png` | 同上 |
|
|
12
|
+
| `05-graph-multibranch.png` | `V26-colored-graph-multibranch.png` | 纯提交图渲染,裁掉下方空白(1280×320) |
|
|
13
|
+
| `06-fullscreen.png` | `V18-right-tab-fullscreen.png` | 面板全屏,不含界面外内容,无需处理 |
|
|
14
|
+
|
|
15
|
+
重新生成(`ffmpeg`,需 `gblur` 滤镜):
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
IN=test/live/artifacts
|
|
19
|
+
ffmpeg -i "$IN/V24-word-level-diff.png" -filter_complex \
|
|
20
|
+
"[0:v]split[a][b];[b]crop=708:577:0:0,gblur=sigma=28[c];[a][c]overlay=0:0" \
|
|
21
|
+
assets/screenshots/03-word-level-diff.png
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
换图时保持文件名不变,市场下次构建自动生效;增删条目改 `screenshots.json`,1–8 张。
|
package/lib/client.js
CHANGED
|
@@ -64,6 +64,11 @@ window.__ModuleLoader__.load({
|
|
|
64
64
|
'changes.unstageAll': '全部取消暂存',
|
|
65
65
|
'changes.stage': '暂存',
|
|
66
66
|
'changes.unstage': '取消暂存',
|
|
67
|
+
'changes.discard': '丢弃改动',
|
|
68
|
+
'changes.discardUntracked': '删除文件',
|
|
69
|
+
'changes.discardConfirm': '丢弃 {path} 的改动?此操作不可恢复。',
|
|
70
|
+
'changes.discardUntrackedConfirm': '删除未跟踪文件 {path}?此操作不可恢复。',
|
|
71
|
+
'changes.discardStagedConfirm': '丢弃 {path} 的暂存与工作区改动?此操作不可恢复。',
|
|
67
72
|
'commit.title': '提交信息',
|
|
68
73
|
'commit.placeholder': '填写提交信息,或先点「AI 生成信息」',
|
|
69
74
|
'commit.hintPlaceholder': '给 AI 的补充说明(可选)',
|
|
@@ -75,6 +80,13 @@ window.__ModuleLoader__.load({
|
|
|
75
80
|
'commit.push': '提交并推送',
|
|
76
81
|
'commit.messageRequired': '请先填写提交信息',
|
|
77
82
|
'commit.empty': '没有可提交的暂存变更',
|
|
83
|
+
'commit.prompt': 'AI 提示词',
|
|
84
|
+
'commit.promptTitle': '自定义 AI 提交提示词',
|
|
85
|
+
'commit.promptHint': '留空使用默认提示词。这里只改「怎么写」,diff 与文件列表始终会传给模型。',
|
|
86
|
+
'commit.promptSave': '保存',
|
|
87
|
+
'commit.promptReset': '恢复默认',
|
|
88
|
+
'commit.promptCancel': '取消',
|
|
89
|
+
'commit.promptCustom': '自定义提示词',
|
|
78
90
|
'tabs.changes': '变更',
|
|
79
91
|
'tabs.graph': '提交图',
|
|
80
92
|
'tabs.branches': '分支',
|
|
@@ -112,6 +124,12 @@ window.__ModuleLoader__.load({
|
|
|
112
124
|
'status.branchCreated': '已创建分支 {branch}',
|
|
113
125
|
'status.branchDeleted': '已删除分支 {branch}',
|
|
114
126
|
'status.deleteConfirm': '确定删除分支 {branch}?',
|
|
127
|
+
'status.discardOk': '已丢弃 {path}',
|
|
128
|
+
'status.stashSaveOk': '已 stash 当前改动',
|
|
129
|
+
'status.stashPopOk': '已恢复 stash@{n}',
|
|
130
|
+
'status.stashDropOk': '已删除 stash@{n}',
|
|
131
|
+
'status.promptSaved': '提示词已保存',
|
|
132
|
+
'status.promptReset': '已恢复默认提示词',
|
|
115
133
|
'status.aiModel': '由 {model} 生成',
|
|
116
134
|
'status.aiFallback': '模型输出不可用,已生成降级文案',
|
|
117
135
|
'toast.error': '失败',
|
|
@@ -131,6 +149,14 @@ window.__ModuleLoader__.load({
|
|
|
131
149
|
'status.aiModelEdit': '由 {model} 生成,可继续编辑',
|
|
132
150
|
'status.ready': '就绪',
|
|
133
151
|
'changes.stagedCount': '已暂存 {n}',
|
|
152
|
+
'stash.title': 'Stash',
|
|
153
|
+
'stash.empty': '没有 stash 记录',
|
|
154
|
+
'stash.placeholder': 'stash 说明(可选)',
|
|
155
|
+
'stash.save': '暂存改动',
|
|
156
|
+
'stash.pop': '恢复',
|
|
157
|
+
'stash.drop': '删除',
|
|
158
|
+
'stash.popConfirm': '恢复 stash@{n} 并从列表移除?',
|
|
159
|
+
'stash.dropConfirm': '删除 stash@{n}?此操作不可恢复。',
|
|
134
160
|
'changes.conflictBanner': '存在 {n} 个冲突文件,解决后再提交',
|
|
135
161
|
'branch.selectTitle': '切换分支',
|
|
136
162
|
'header.refreshing': '刷新中…',
|
|
@@ -175,6 +201,11 @@ window.__ModuleLoader__.load({
|
|
|
175
201
|
'changes.unstageAll': 'Unstage all',
|
|
176
202
|
'changes.stage': 'Stage',
|
|
177
203
|
'changes.unstage': 'Unstage',
|
|
204
|
+
'changes.discard': 'Discard changes',
|
|
205
|
+
'changes.discardUntracked': 'Delete file',
|
|
206
|
+
'changes.discardConfirm': 'Discard changes to {path}? This cannot be undone.',
|
|
207
|
+
'changes.discardUntrackedConfirm': 'Delete untracked file {path}? This cannot be undone.',
|
|
208
|
+
'changes.discardStagedConfirm': 'Discard staged and working-tree changes to {path}? This cannot be undone.',
|
|
178
209
|
'commit.title': 'Commit message',
|
|
179
210
|
'commit.placeholder': 'Write a commit message, or generate one with AI',
|
|
180
211
|
'commit.hintPlaceholder': 'Optional hint for the AI',
|
|
@@ -186,6 +217,13 @@ window.__ModuleLoader__.load({
|
|
|
186
217
|
'commit.push': 'Commit & push',
|
|
187
218
|
'commit.messageRequired': 'Commit message is required',
|
|
188
219
|
'commit.empty': 'Nothing staged to commit',
|
|
220
|
+
'commit.prompt': 'AI prompt',
|
|
221
|
+
'commit.promptTitle': 'Custom AI commit prompt',
|
|
222
|
+
'commit.promptHint': 'Leave empty to use the default. This only changes how it writes; the diff and file list are always sent.',
|
|
223
|
+
'commit.promptSave': 'Save',
|
|
224
|
+
'commit.promptReset': 'Reset to default',
|
|
225
|
+
'commit.promptCancel': 'Cancel',
|
|
226
|
+
'commit.promptCustom': 'Custom prompt',
|
|
189
227
|
'tabs.changes': 'Changes',
|
|
190
228
|
'tabs.graph': 'Graph',
|
|
191
229
|
'tabs.branches': 'Branches',
|
|
@@ -223,6 +261,12 @@ window.__ModuleLoader__.load({
|
|
|
223
261
|
'status.branchCreated': 'Created branch {branch}',
|
|
224
262
|
'status.branchDeleted': 'Deleted branch {branch}',
|
|
225
263
|
'status.deleteConfirm': 'Delete branch {branch}?',
|
|
264
|
+
'status.discardOk': 'Discarded {path}',
|
|
265
|
+
'status.stashSaveOk': 'Stashed current changes',
|
|
266
|
+
'status.stashPopOk': 'Restored stash@{n}',
|
|
267
|
+
'status.stashDropOk': 'Dropped stash@{n}',
|
|
268
|
+
'status.promptSaved': 'Prompt saved',
|
|
269
|
+
'status.promptReset': 'Restored the default prompt',
|
|
226
270
|
'status.aiModel': 'Generated by {model}',
|
|
227
271
|
'status.aiFallback': 'Model output unusable; used a fallback message',
|
|
228
272
|
'toast.error': 'Failed',
|
|
@@ -242,6 +286,14 @@ window.__ModuleLoader__.load({
|
|
|
242
286
|
'status.aiModelEdit': 'Generated by {model}; edit as needed',
|
|
243
287
|
'status.ready': 'Ready',
|
|
244
288
|
'changes.stagedCount': '{n} staged',
|
|
289
|
+
'stash.title': 'Stash',
|
|
290
|
+
'stash.empty': 'No stash entries',
|
|
291
|
+
'stash.placeholder': 'Stash message (optional)',
|
|
292
|
+
'stash.save': 'Stash changes',
|
|
293
|
+
'stash.pop': 'Restore',
|
|
294
|
+
'stash.drop': 'Drop',
|
|
295
|
+
'stash.popConfirm': 'Restore stash@{n} and remove it from the list?',
|
|
296
|
+
'stash.dropConfirm': 'Drop stash@{n}? This cannot be undone.',
|
|
245
297
|
'changes.conflictBanner': '{n} conflicted file(s); resolve them before committing',
|
|
246
298
|
'branch.selectTitle': 'Switch branch',
|
|
247
299
|
'header.refreshing': 'Refreshing…',
|
|
@@ -482,6 +534,11 @@ window.__ModuleLoader__.load({
|
|
|
482
534
|
.gm-commit::before { content:''; position:absolute; top:0; left:0; right:0; height:3px;
|
|
483
535
|
background:linear-gradient(90deg, var(--gm-cyan), var(--gm-blue) 45%, transparent 90%); }
|
|
484
536
|
.gm-commit-head { display:flex; align-items:center; gap:6px; }
|
|
537
|
+
.gm-prompt { display:flex; flex-direction:column; gap:5px; padding:8px; border-radius:10px;
|
|
538
|
+
background:var(--gm-blue-06); border:1px solid var(--gm-blue-12); }
|
|
539
|
+
.gm-prompt .gm-section { padding:0; }
|
|
540
|
+
.gm-prompt-text { min-height:96px; max-height:180px; font-size:11px;
|
|
541
|
+
font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
485
542
|
.gm-commit-title { font-size:10.5px; font-weight:700; letter-spacing:.05em; text-transform:uppercase;
|
|
486
543
|
color:var(--gm-blue); }
|
|
487
544
|
.gm-textarea { width:100%; min-height:46px; max-height:140px; padding:7px 9px; border-radius:10px;
|
|
@@ -607,6 +664,9 @@ window.__ModuleLoader__.load({
|
|
|
607
664
|
commit: ['M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16z', 'M8.5 12l2.5 2.5L16 9.5'],
|
|
608
665
|
file: ['M6 3h8l4 4v14H6z', 'M14 3v5h5'],
|
|
609
666
|
branch: ['M6 4v9', 'M6 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0', 'M18 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0', 'M18 8v1a6 6 0 0 1-6 6H8'],
|
|
667
|
+
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 14h10l1-14', 'M10 11v6', 'M14 11v6'],
|
|
668
|
+
gear: ['M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', 'M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z'],
|
|
669
|
+
stash: ['M4 5h16v4H4z', 'M6 9v10h12V9', 'M10 13h4'],
|
|
610
670
|
}
|
|
611
671
|
|
|
612
672
|
function Icon(props) {
|
|
@@ -790,6 +850,11 @@ window.__ModuleLoader__.load({
|
|
|
790
850
|
newBranchOpen: false,
|
|
791
851
|
newBranchName: '',
|
|
792
852
|
newBranchFrom: '',
|
|
853
|
+
// 自定义 AI 提示词编辑态。promptDraft 是编辑中的草稿,
|
|
854
|
+
// 只有点保存才写回 git config,避免边打字边写盘。
|
|
855
|
+
promptOpen: false,
|
|
856
|
+
promptDraft: '',
|
|
857
|
+
stashMessage: '',
|
|
793
858
|
})
|
|
794
859
|
var toastTimer = null
|
|
795
860
|
var loadToken = 0
|
|
@@ -909,6 +974,10 @@ window.__ModuleLoader__.load({
|
|
|
909
974
|
}
|
|
910
975
|
|
|
911
976
|
function setNewBranchName(value) { set({ newBranchName: value }) }
|
|
977
|
+
function setStashMessage(value) {
|
|
978
|
+
set({ stashMessage: String(value || '') })
|
|
979
|
+
}
|
|
980
|
+
|
|
912
981
|
function setNewBranchFrom(value) { set({ newBranchFrom: value }) }
|
|
913
982
|
|
|
914
983
|
async function runBusy(label, operation) {
|
|
@@ -981,6 +1050,117 @@ window.__ModuleLoader__.load({
|
|
|
981
1050
|
}
|
|
982
1051
|
}
|
|
983
1052
|
|
|
1053
|
+
/**
|
|
1054
|
+
* 丢弃改动。分组的语义决定用哪个后端模式:
|
|
1055
|
+
* unstaged → worktree(只回退工作区)
|
|
1056
|
+
* untracked → untracked(删除文件)
|
|
1057
|
+
* staged → all(连暂存区一起回退)
|
|
1058
|
+
* 三种都不可恢复,所以一律先 window.confirm。
|
|
1059
|
+
*/
|
|
1060
|
+
async function discard(group, entry) {
|
|
1061
|
+
var cwd = store.get().cwd
|
|
1062
|
+
if (!cwd || !entry || !entry.path) return
|
|
1063
|
+
var mode = group === 'untracked' ? 'untracked' : group === 'staged' ? 'all' : 'worktree'
|
|
1064
|
+
var key = group === 'untracked'
|
|
1065
|
+
? 'changes.discardUntrackedConfirm'
|
|
1066
|
+
: group === 'staged' ? 'changes.discardStagedConfirm' : 'changes.discardConfirm'
|
|
1067
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
1068
|
+
if (!window.confirm(tr(key, { path: entry.path }))) return
|
|
1069
|
+
}
|
|
1070
|
+
var result = await runBusy('discard', function () {
|
|
1071
|
+
return rpc('discard', { cwd: cwd, paths: [entry.path], mode: mode })
|
|
1072
|
+
})
|
|
1073
|
+
if (result !== null) {
|
|
1074
|
+
toast('ok', tr('status.discardOk', { path: entry.path }), result.output)
|
|
1075
|
+
set({ selected: null, diff: null })
|
|
1076
|
+
await refresh()
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function stashSave() {
|
|
1081
|
+
var state = store.get()
|
|
1082
|
+
var cwd = state.cwd
|
|
1083
|
+
if (!cwd) return
|
|
1084
|
+
var params = { cwd: cwd }
|
|
1085
|
+
var message = String(state.stashMessage || '').trim()
|
|
1086
|
+
if (message !== '') params.message = message
|
|
1087
|
+
// 未跟踪文件默认不进 stash;有未跟踪改动时一并存起来,否则用户会以为「全存了」
|
|
1088
|
+
var status = state.snapshot && state.snapshot.status ? state.snapshot.status : null
|
|
1089
|
+
if (status && status.untracked && status.untracked.length > 0) params.includeUntracked = true
|
|
1090
|
+
var result = await runBusy('stash', function () { return rpc('stashPush', params) })
|
|
1091
|
+
if (result !== null) {
|
|
1092
|
+
set({ stashMessage: '' })
|
|
1093
|
+
toast('ok', tr('status.stashSaveOk'), result.output)
|
|
1094
|
+
await refresh()
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function stashPop(index) {
|
|
1099
|
+
var cwd = store.get().cwd
|
|
1100
|
+
if (!cwd) return
|
|
1101
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
1102
|
+
if (!window.confirm(tr('stash.popConfirm', { n: index }))) return
|
|
1103
|
+
}
|
|
1104
|
+
var result = await runBusy('stash', function () { return rpc('stashPop', { cwd: cwd, index: index }) })
|
|
1105
|
+
if (result !== null) {
|
|
1106
|
+
toast('ok', tr('status.stashPopOk', { n: index }), result.output)
|
|
1107
|
+
await refresh()
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
async function stashDrop(index) {
|
|
1112
|
+
var cwd = store.get().cwd
|
|
1113
|
+
if (!cwd) return
|
|
1114
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
1115
|
+
if (!window.confirm(tr('stash.dropConfirm', { n: index }))) return
|
|
1116
|
+
}
|
|
1117
|
+
var result = await runBusy('stash', function () { return rpc('stashDrop', { cwd: cwd, index: index }) })
|
|
1118
|
+
if (result !== null) {
|
|
1119
|
+
toast('ok', tr('status.stashDropOk', { n: index }), result.output)
|
|
1120
|
+
await refresh()
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function openPrompt() {
|
|
1125
|
+
var snapshot = store.get().snapshot
|
|
1126
|
+
set({ promptOpen: true, promptDraft: snapshot && snapshot.commitPrompt ? snapshot.commitPrompt : '' })
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function closePrompt() {
|
|
1130
|
+
set({ promptOpen: false, promptDraft: '' })
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function setPromptDraft(value) {
|
|
1134
|
+
set({ promptDraft: String(value || '') })
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function savePrompt() {
|
|
1138
|
+
var state = store.get()
|
|
1139
|
+
var cwd = state.cwd
|
|
1140
|
+
if (!cwd) return
|
|
1141
|
+
var result = await runBusy('prompt', function () {
|
|
1142
|
+
return rpc('setCommitPrompt', { cwd: cwd, prompt: String(state.promptDraft || '') })
|
|
1143
|
+
})
|
|
1144
|
+
if (result !== null) {
|
|
1145
|
+
set({ promptOpen: false, promptDraft: '' })
|
|
1146
|
+
toast('ok', tr('status.promptSaved'))
|
|
1147
|
+
await refresh()
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function resetPrompt() {
|
|
1152
|
+
var cwd = store.get().cwd
|
|
1153
|
+
if (!cwd) return
|
|
1154
|
+
var result = await runBusy('prompt', function () {
|
|
1155
|
+
return rpc('setCommitPrompt', { cwd: cwd, prompt: '' })
|
|
1156
|
+
})
|
|
1157
|
+
if (result !== null) {
|
|
1158
|
+
set({ promptOpen: false, promptDraft: '' })
|
|
1159
|
+
toast('ok', tr('status.promptReset'))
|
|
1160
|
+
await refresh()
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
984
1164
|
async function switchBranch(name) {
|
|
985
1165
|
var cwd = store.get().cwd
|
|
986
1166
|
if (!cwd || !name) return
|
|
@@ -1105,6 +1285,16 @@ window.__ModuleLoader__.load({
|
|
|
1105
1285
|
setNewBranch: setNewBranch,
|
|
1106
1286
|
setNewBranchName: setNewBranchName,
|
|
1107
1287
|
setNewBranchFrom: setNewBranchFrom,
|
|
1288
|
+
discard: discard,
|
|
1289
|
+
stashSave: stashSave,
|
|
1290
|
+
stashPop: stashPop,
|
|
1291
|
+
stashDrop: stashDrop,
|
|
1292
|
+
setStashMessage: setStashMessage,
|
|
1293
|
+
openPrompt: openPrompt,
|
|
1294
|
+
closePrompt: closePrompt,
|
|
1295
|
+
setPromptDraft: setPromptDraft,
|
|
1296
|
+
savePrompt: savePrompt,
|
|
1297
|
+
resetPrompt: resetPrompt,
|
|
1108
1298
|
clearToast: clearToast,
|
|
1109
1299
|
}
|
|
1110
1300
|
}
|
|
@@ -1162,16 +1352,29 @@ window.__ModuleLoader__.load({
|
|
|
1162
1352
|
h('span', { className: 'gm-fpath' },
|
|
1163
1353
|
parts.dir ? h('span', { className: 'gm-dir' }, parts.dir) : null,
|
|
1164
1354
|
parts.base),
|
|
1165
|
-
h('
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
event
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1355
|
+
h('span', { className: 'gm-row', style: { gap: '2px' } },
|
|
1356
|
+
h('button', {
|
|
1357
|
+
type: 'button',
|
|
1358
|
+
className: 'gm-btn icon sm ghost gm-act',
|
|
1359
|
+
title: staged ? tr('changes.unstage') : tr('changes.stage'),
|
|
1360
|
+
onClick: function (event) {
|
|
1361
|
+
event.stopPropagation()
|
|
1362
|
+
if (staged) props.onUnstage(entry)
|
|
1363
|
+
else props.onStage(entry)
|
|
1364
|
+
},
|
|
1365
|
+
}, h(Icon, { name: staged ? 'minus' : 'plus', size: 12 })),
|
|
1366
|
+
// 冲突文件不给「丢弃」:那等于放弃冲突解决状态,语义上容易误伤
|
|
1367
|
+
props.onDiscard && props.group !== 'conflicted'
|
|
1368
|
+
? h('button', {
|
|
1369
|
+
type: 'button',
|
|
1370
|
+
className: 'gm-btn icon sm ghost gm-act',
|
|
1371
|
+
title: props.group === 'untracked' ? tr('changes.discardUntracked') : tr('changes.discard'),
|
|
1372
|
+
onClick: function (event) {
|
|
1373
|
+
event.stopPropagation()
|
|
1374
|
+
props.onDiscard(entry)
|
|
1375
|
+
},
|
|
1376
|
+
}, h(Icon, { name: 'trash', size: 12 }))
|
|
1377
|
+
: null))
|
|
1175
1378
|
}))
|
|
1176
1379
|
}
|
|
1177
1380
|
|
|
@@ -1194,6 +1397,7 @@ window.__ModuleLoader__.load({
|
|
|
1194
1397
|
onSelect: function (entry) { props.actions.selectFile(group, entry) },
|
|
1195
1398
|
onStage: function (entry) { props.actions.stage(group, entry) },
|
|
1196
1399
|
onUnstage: function (entry) { props.actions.unstage(group, entry) },
|
|
1400
|
+
onDiscard: function (entry) { props.actions.discard(group, entry) },
|
|
1197
1401
|
})
|
|
1198
1402
|
}
|
|
1199
1403
|
var empty = !conflicted.length && !staged.length && !unstaged.length && !untracked.length
|
|
@@ -1226,6 +1430,29 @@ window.__ModuleLoader__.load({
|
|
|
1226
1430
|
}
|
|
1227
1431
|
}
|
|
1228
1432
|
return h('div', { className: 'gm-commit' },
|
|
1433
|
+
state.promptOpen
|
|
1434
|
+
? h('div', { className: 'gm-prompt' },
|
|
1435
|
+
h('div', { className: 'gm-section' },
|
|
1436
|
+
h('span', null, tr('commit.promptTitle')),
|
|
1437
|
+
h('span', { className: 'gm-spacer' }),
|
|
1438
|
+
h('button', {
|
|
1439
|
+
type: 'button',
|
|
1440
|
+
className: 'gm-btn icon sm ghost',
|
|
1441
|
+
title: tr('commit.promptCancel'),
|
|
1442
|
+
onClick: props.onClosePrompt,
|
|
1443
|
+
}, h(Icon, { name: 'close', size: 12 }))),
|
|
1444
|
+
h('textarea', {
|
|
1445
|
+
className: 'gm-textarea gm-prompt-text',
|
|
1446
|
+
value: state.promptDraft,
|
|
1447
|
+
placeholder: tr('commit.promptHint'),
|
|
1448
|
+
onChange: function (event) { props.onPromptDraft(event.target.value) },
|
|
1449
|
+
}),
|
|
1450
|
+
h('div', { className: 'gm-hint' }, tr('commit.promptHint')),
|
|
1451
|
+
h('div', { className: 'gm-actions' },
|
|
1452
|
+
h(Btn, { label: tr('commit.promptReset'), small: true, ghost: true, disabled: !!busy, onClick: props.onResetPrompt }),
|
|
1453
|
+
h('span', { className: 'gm-spacer' }),
|
|
1454
|
+
h(Btn, { label: tr('commit.promptSave'), small: true, variant: 'primary', disabled: !!busy, onClick: props.onSavePrompt })))
|
|
1455
|
+
: null,
|
|
1229
1456
|
h('div', { className: 'gm-commit-head' },
|
|
1230
1457
|
h(WhaleGlyph, { size: 22 }),
|
|
1231
1458
|
h('span', { className: 'gm-commit-title' }, tr('commit.title')),
|
|
@@ -1254,8 +1481,17 @@ window.__ModuleLoader__.load({
|
|
|
1254
1481
|
className: busy === 'ai' ? 'gm-ai-busy' : undefined,
|
|
1255
1482
|
disabled: !!busy || !state.cwd,
|
|
1256
1483
|
onClick: props.onGenerate,
|
|
1484
|
+
}),
|
|
1485
|
+
h(Btn, {
|
|
1486
|
+
icon: 'gear',
|
|
1487
|
+
title: tr('commit.prompt'),
|
|
1488
|
+
disabled: !!busy || !state.cwd,
|
|
1489
|
+
onClick: props.onOpenPrompt,
|
|
1257
1490
|
})),
|
|
1258
1491
|
h('div', { className: 'gm-actions' },
|
|
1492
|
+
state.snapshot && state.snapshot.commitPrompt
|
|
1493
|
+
? h('span', { className: 'gm-badge brand', title: state.snapshot.commitPrompt }, tr('commit.promptCustom'))
|
|
1494
|
+
: null,
|
|
1259
1495
|
state.aiModel
|
|
1260
1496
|
? h('span', { className: 'gm-ai-line gm-hint', title: tr('status.aiModel', { model: state.aiModel }) },
|
|
1261
1497
|
state.aiFallback ? tr('status.aiFallback') : tr('status.aiModelEdit', { model: state.aiModel }))
|
|
@@ -1539,6 +1775,7 @@ window.__ModuleLoader__.load({
|
|
|
1539
1775
|
var branches = state.snapshot && state.snapshot.branches ? state.snapshot.branches : []
|
|
1540
1776
|
var locals = branches.filter(function (branch) { return branch.kind === 'local' })
|
|
1541
1777
|
var remotes = branches.filter(function (branch) { return branch.kind === 'remote' })
|
|
1778
|
+
var stashes = state.snapshot && state.snapshot.stashes ? state.snapshot.stashes : []
|
|
1542
1779
|
function group(title, items, allowDelete) {
|
|
1543
1780
|
if (items.length === 0) return null
|
|
1544
1781
|
return h('div', null,
|
|
@@ -1564,13 +1801,42 @@ window.__ModuleLoader__.load({
|
|
|
1564
1801
|
: null))
|
|
1565
1802
|
}))
|
|
1566
1803
|
}
|
|
1804
|
+
// stash 放在分支页:它和分支一样是「当前工作放哪儿」的操作,
|
|
1805
|
+
// 而不是某次提交的内容。后端 snapshot 早就解析了 stashes,这里才真正用上。
|
|
1806
|
+
function stashSection() {
|
|
1807
|
+
return h('div', null,
|
|
1808
|
+
h('div', { className: 'gm-section' },
|
|
1809
|
+
h('span', null, tr('stash.title')),
|
|
1810
|
+
h('span', { className: 'gm-section-count' }, String(stashes.length))),
|
|
1811
|
+
h('div', { className: 'gm-row', style: { padding: '0 10px 6px' } },
|
|
1812
|
+
h('input', {
|
|
1813
|
+
className: 'gm-input',
|
|
1814
|
+
value: state.stashMessage,
|
|
1815
|
+
placeholder: tr('stash.placeholder'),
|
|
1816
|
+
onChange: function (event) { props.onStashMessage(event.target.value) },
|
|
1817
|
+
onKeyDown: function (event) { if (event.key === 'Enter') props.onStashSave() },
|
|
1818
|
+
}),
|
|
1819
|
+
h(Btn, { label: tr('stash.save'), small: true, disabled: !!state.busy, onClick: props.onStashSave })),
|
|
1820
|
+
stashes.length === 0
|
|
1821
|
+
? h('div', { className: 'gm-hint', style: { padding: '0 12px 8px' } }, tr('stash.empty'))
|
|
1822
|
+
: stashes.map(function (item) {
|
|
1823
|
+
return h('div', { key: 'stash:' + item.index, className: 'gm-branch' },
|
|
1824
|
+
h(Icon, { name: 'stash', size: 12 }),
|
|
1825
|
+
h('span', { className: 'gm-badge' }, 'stash@{' + item.index + '}'),
|
|
1826
|
+
h('span', { className: 'gm-bmeta' }, item.message || ''),
|
|
1827
|
+
h('span', { className: 'gm-row gm-act', style: { gap: '4px', flex: 'none' } },
|
|
1828
|
+
h(Btn, { label: tr('stash.pop'), small: true, disabled: !!state.busy, onClick: function () { props.onStashPop(item.index) } }),
|
|
1829
|
+
h(Btn, { label: tr('stash.drop'), small: true, ghost: true, disabled: !!state.busy, onClick: function () { props.onStashDrop(item.index) } })))
|
|
1830
|
+
}))
|
|
1831
|
+
}
|
|
1567
1832
|
return h('div', { className: 'gm-scroll' },
|
|
1568
1833
|
h('div', { className: 'gm-content-inner' },
|
|
1569
1834
|
group(tr('branch.local'), locals, true),
|
|
1570
1835
|
group(tr('branch.remote'), remotes, false),
|
|
1571
1836
|
locals.length === 0 && remotes.length === 0
|
|
1572
1837
|
? h(EmptyState, { title: tr('branch.empty') })
|
|
1573
|
-
: null
|
|
1838
|
+
: null,
|
|
1839
|
+
stashSection()))
|
|
1574
1840
|
}
|
|
1575
1841
|
|
|
1576
1842
|
function StatusBar(props) {
|
|
@@ -1629,7 +1895,15 @@ window.__ModuleLoader__.load({
|
|
|
1629
1895
|
var content
|
|
1630
1896
|
if (state.activeTab === 'changes') content = h(ChangesPane, { state: state, actions: props.actions })
|
|
1631
1897
|
else if (state.activeTab === 'graph') content = h(GraphView, { state: state, onSelectCommit: props.actions.selectCommit })
|
|
1632
|
-
else if (state.activeTab === 'branches') content = h(BranchesView, {
|
|
1898
|
+
else if (state.activeTab === 'branches') content = h(BranchesView, {
|
|
1899
|
+
state: state,
|
|
1900
|
+
onSwitch: props.actions.switchBranch,
|
|
1901
|
+
onDelete: props.actions.deleteBranch,
|
|
1902
|
+
onStashSave: props.actions.stashSave,
|
|
1903
|
+
onStashPop: props.actions.stashPop,
|
|
1904
|
+
onStashDrop: props.actions.stashDrop,
|
|
1905
|
+
onStashMessage: props.actions.setStashMessage,
|
|
1906
|
+
})
|
|
1633
1907
|
else content = h(DiffView, { state: state })
|
|
1634
1908
|
return h('div', { className: 'gm-root' },
|
|
1635
1909
|
panelStyle(),
|
|
@@ -1665,6 +1939,11 @@ window.__ModuleLoader__.load({
|
|
|
1665
1939
|
onHint: props.actions.setAiHint,
|
|
1666
1940
|
onGenerate: props.actions.generateMessage,
|
|
1667
1941
|
onCommit: function (andPush) { props.actions.commit(andPush) },
|
|
1942
|
+
onOpenPrompt: props.actions.openPrompt,
|
|
1943
|
+
onClosePrompt: props.actions.closePrompt,
|
|
1944
|
+
onPromptDraft: props.actions.setPromptDraft,
|
|
1945
|
+
onSavePrompt: props.actions.savePrompt,
|
|
1946
|
+
onResetPrompt: props.actions.resetPrompt,
|
|
1668
1947
|
})
|
|
1669
1948
|
: null)
|
|
1670
1949
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caozeal/dsh-next-git",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "DSH Git 管理插件:分支切换、变更暂存、提交/推送/拉取、AI 提交信息、提交图与 diff 查看",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"src",
|
|
17
17
|
"lib/client.js",
|
|
18
18
|
"cordis.patch.yml",
|
|
19
|
+
"screenshots.json",
|
|
20
|
+
"assets/screenshots",
|
|
19
21
|
"locale/*.json",
|
|
20
22
|
"icon.svg",
|
|
21
23
|
"icon.png"
|
package/screenshots.json
ADDED
package/src/ai-commit.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* git-manager — AI 提交信息生成。
|
|
3
3
|
*
|
|
4
4
|
* 流程:收集「已暂存 diff(为空则工作区 diff)→ 最近提交风格 → 调用默认模型的
|
|
5
|
-
* llm.stream」→
|
|
6
|
-
*
|
|
5
|
+
* llm.stream」→ 清理成「标题 + 可选正文」。任何一步不可用都退回确定性文案
|
|
6
|
+
* (fallback),让面板始终能拿到一条可编辑的建议。
|
|
7
|
+
*
|
|
8
|
+
* 系统提示词可由仓库自定义(git config dsh.commitPrompt),只替换「怎么写」的规则,
|
|
9
|
+
* 不改变「给模型看什么数据」——diff/文件/最近提交这条装配逻辑始终由本模块掌握,
|
|
10
|
+
* 避免用户把模板写坏后模型看不到 diff 而胡乱编造。
|
|
7
11
|
*/
|
|
8
12
|
import { GitError } from './git.js'
|
|
9
13
|
|
|
@@ -11,11 +15,13 @@ const MAX_DIFF_CHARS = 12000
|
|
|
11
15
|
/** 单次生成的 token 预算:reasoning 模型会先消耗思考 token,200 太小容易截断。 */
|
|
12
16
|
const MAX_OUTPUT_TOKENS = 600
|
|
13
17
|
|
|
14
|
-
const
|
|
18
|
+
export const DEFAULT_SYSTEM_PROMPT = [
|
|
15
19
|
'你是一个 Git 提交信息生成器。',
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
20
|
+
'第一行是标题:Conventional Commits 风格(feat:/fix:/refactor:/docs:/chore: 等),不超过 72 个字符,不以句号结尾。',
|
|
21
|
+
'如果这次改动需要说明动机、影响面或取舍,空一行后写正文,正文每行不超过 72 个字符,可以分段。',
|
|
22
|
+
'改动很小、一句话能说清时不要硬凑正文。',
|
|
23
|
+
'不要解释、不要代码块、不要引号。',
|
|
24
|
+
'跟随仓库最近提交的语言:最近提交多为中文就用中文,否则用英文。',
|
|
19
25
|
].join('')
|
|
20
26
|
|
|
21
27
|
/** 从 diff 与最近提交构造 user prompt。 */
|
|
@@ -48,6 +54,44 @@ export function sanitizeSubject(text) {
|
|
|
48
54
|
return subject
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/** 去掉整行的代码块围栏,保留内容。 */
|
|
58
|
+
function stripFences(text) {
|
|
59
|
+
return String(text ?? '').replace(/^[ \t]*```[a-zA-Z0-9_-]*[ \t]*$/gm, '')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 清理模型输出的正文部分:去围栏、去首尾空行、去「正文:」这类前缀、限长。
|
|
64
|
+
* 不做逐行 trim——正文里的缩进(列表、代码片段)要保留。
|
|
65
|
+
* @param {string} text
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function sanitizeBody(text) {
|
|
69
|
+
let value = stripFences(text).replace(/\r\n/g, '\n')
|
|
70
|
+
const lines = value.split('\n').map((line) => line.replace(/[ \t]+$/, ''))
|
|
71
|
+
while (lines.length > 0 && lines[0].trim() === '') lines.shift()
|
|
72
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
|
|
73
|
+
let body = lines.join('\n').trim()
|
|
74
|
+
body = body.replace(/^(commit body|提交正文|正文)\s*[::]\s*/i, '')
|
|
75
|
+
if (body.length > 2000) body = body.slice(0, 2000).trim()
|
|
76
|
+
return body
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把模型输出拆成「标题 + 正文」。
|
|
81
|
+
* 模型偶尔只给标题,也可能给「标题 / 空行 / 正文」,两种都要能吃下。
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {{subject: string, body: string}}
|
|
84
|
+
*/
|
|
85
|
+
export function parseCommitMessage(text) {
|
|
86
|
+
const cleaned = stripFences(text).replace(/\r\n/g, '\n')
|
|
87
|
+
const lines = cleaned.split('\n')
|
|
88
|
+
let index = 0
|
|
89
|
+
while (index < lines.length && lines[index].trim() === '') index += 1
|
|
90
|
+
const subjectLine = index < lines.length ? lines[index] : ''
|
|
91
|
+
const rest = lines.slice(index + 1).join('\n')
|
|
92
|
+
return { subject: sanitizeSubject(subjectLine), body: sanitizeBody(rest) }
|
|
93
|
+
}
|
|
94
|
+
|
|
51
95
|
/** 最近提交是否以中文为主。 */
|
|
52
96
|
function looksChinese(subjects) {
|
|
53
97
|
const joined = subjects.join('')
|
|
@@ -104,8 +148,9 @@ export function fallbackSubject({ files, stagedCount, Chinese }) {
|
|
|
104
148
|
* signal?: AbortSignal,
|
|
105
149
|
* llm?: unknown,
|
|
106
150
|
* selection?: {provider?: string, model?: string, reasoningEffort?: string},
|
|
151
|
+
* systemPrompt?: string,
|
|
107
152
|
* }} options
|
|
108
|
-
* @returns {Promise<{message: string, model: string|null, fallback: boolean}>}
|
|
153
|
+
* @returns {Promise<{message: string, subject: string, body: string, model: string|null, fallback: boolean}>}
|
|
109
154
|
*/
|
|
110
155
|
export async function generateCommitMessage(options) {
|
|
111
156
|
const {
|
|
@@ -117,6 +162,7 @@ export async function generateCommitMessage(options) {
|
|
|
117
162
|
selection,
|
|
118
163
|
files = [],
|
|
119
164
|
stagedCount = 0,
|
|
165
|
+
systemPrompt = '',
|
|
120
166
|
} = options
|
|
121
167
|
|
|
122
168
|
const staged = await runGit(['diff', '--cached', '--no-color'], { cwd, signal, allowFailure: true })
|
|
@@ -144,13 +190,12 @@ export async function generateCommitMessage(options) {
|
|
|
144
190
|
|
|
145
191
|
const canUseLlm = Boolean(llm && selection && selection.provider && selection.model && diff.trim())
|
|
146
192
|
if (!canUseLlm) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
model,
|
|
150
|
-
fallback: true,
|
|
151
|
-
}
|
|
193
|
+
const subject = fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese })
|
|
194
|
+
return { message: subject, subject, body: '', model, fallback: true }
|
|
152
195
|
}
|
|
153
196
|
|
|
197
|
+
// 用户自定义提示词:只覆盖「怎么写」的规则,装配数据仍由 buildPrompt 负责。
|
|
198
|
+
const customPrompt = typeof systemPrompt === 'string' ? systemPrompt.trim().slice(0, 4000) : ''
|
|
154
199
|
const prompt = buildPrompt({ diff, files: changedFiles, recent, hint })
|
|
155
200
|
let text = ''
|
|
156
201
|
try {
|
|
@@ -158,7 +203,7 @@ export async function generateCommitMessage(options) {
|
|
|
158
203
|
provider: selection.provider,
|
|
159
204
|
model: selection.model,
|
|
160
205
|
reasoningEffort: selection.reasoningEffort,
|
|
161
|
-
system:
|
|
206
|
+
system: customPrompt === '' ? DEFAULT_SYSTEM_PROMPT : customPrompt,
|
|
162
207
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
163
208
|
maxTokens: MAX_OUTPUT_TOKENS,
|
|
164
209
|
temperature: 0.2,
|
|
@@ -182,13 +227,11 @@ export async function generateCommitMessage(options) {
|
|
|
182
227
|
throw new GitError(`模型生成失败:${message}`, { code: 'ai-unavailable' })
|
|
183
228
|
}
|
|
184
229
|
|
|
185
|
-
const
|
|
186
|
-
if (!looksLikeSubject(subject)) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
model,
|
|
190
|
-
fallback: true,
|
|
191
|
-
}
|
|
230
|
+
const parsed = parseCommitMessage(text)
|
|
231
|
+
if (!looksLikeSubject(parsed.subject)) {
|
|
232
|
+
const subject = fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese })
|
|
233
|
+
return { message: subject, subject, body: '', model, fallback: true }
|
|
192
234
|
}
|
|
193
|
-
|
|
194
|
-
}
|
|
235
|
+
const message = parsed.body === '' ? parsed.subject : parsed.subject + '\n\n' + parsed.body
|
|
236
|
+
return { message, subject: parsed.subject, body: parsed.body, model, fallback: false }
|
|
237
|
+
}
|
package/src/operations.js
CHANGED
|
@@ -27,10 +27,17 @@ const DIFF_MAX_CHARS = 400000
|
|
|
27
27
|
|
|
28
28
|
/** 支持的操作名。 */
|
|
29
29
|
const OPS = [
|
|
30
|
-
'snapshot', 'graph', 'diff', 'stage', 'unstage', 'switchBranch', 'deleteBranch',
|
|
30
|
+
'snapshot', 'graph', 'diff', 'stage', 'unstage', 'discard', 'switchBranch', 'deleteBranch',
|
|
31
31
|
'commit', 'fetch', 'pull', 'push', 'aiCommit',
|
|
32
|
+
'stashPush', 'stashPop', 'stashDrop', 'setCommitPrompt',
|
|
32
33
|
]
|
|
33
34
|
|
|
35
|
+
/** 自定义提交提示词在 git config 里的键。用 git config 而不是新增工作区文件:
|
|
36
|
+
* 不污染 git status、不打扰 .gitignore,且天然区分本地/全局(写入用 --local,
|
|
37
|
+
* 读取走默认级联,所以用户可以先设一条全局默认再按仓库覆盖)。 */
|
|
38
|
+
const COMMIT_PROMPT_KEY = 'dsh.commitPrompt'
|
|
39
|
+
const COMMIT_PROMPT_MAX = 4000
|
|
40
|
+
|
|
34
41
|
/** 只接受安全的 ref/远程名,避免被当作命令行选项。 */
|
|
35
42
|
const SAFE_REF = /^[A-Za-z0-9._/-]+$/
|
|
36
43
|
|
|
@@ -160,10 +167,11 @@ export function createOperations(deps) {
|
|
|
160
167
|
remotes: [],
|
|
161
168
|
status: { clean: true, staged: [], unstaged: [], untracked: [], conflicted: [] },
|
|
162
169
|
stashes: [],
|
|
170
|
+
commitPrompt: '',
|
|
163
171
|
}
|
|
164
172
|
}
|
|
165
173
|
const root = resolved.root
|
|
166
|
-
const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes] = await Promise.all([
|
|
174
|
+
const [statusRes, branchesRes, remotesRes, stashRes, headRes, urlRes, promptRes] = await Promise.all([
|
|
167
175
|
runGit(['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'], {
|
|
168
176
|
cwd: root,
|
|
169
177
|
signal,
|
|
@@ -178,6 +186,7 @@ export function createOperations(deps) {
|
|
|
178
186
|
runGit(['stash', 'list', '--format=%gd%x00%s'], { cwd: root, signal, allowFailure: true }),
|
|
179
187
|
runGit(['rev-parse', '--short', 'HEAD'], { cwd: root, signal, allowFailure: true }),
|
|
180
188
|
runGit(['config', '--get', 'remote.origin.url'], { cwd: root, signal, allowFailure: true }),
|
|
189
|
+
runGit(['config', '--get', COMMIT_PROMPT_KEY], { cwd: root, signal, allowFailure: true }),
|
|
181
190
|
])
|
|
182
191
|
const parsed = parsePorcelain(statusRes.stdout)
|
|
183
192
|
const head = parseBranchHeader(parsed.branchLine)
|
|
@@ -209,6 +218,7 @@ export function createOperations(deps) {
|
|
|
209
218
|
remotes,
|
|
210
219
|
status: { clean, ...groups },
|
|
211
220
|
stashes: parseStashes(stashRes.stdout),
|
|
221
|
+
commitPrompt: promptRes.code === 0 ? promptRes.stdout.trim() : '',
|
|
212
222
|
}
|
|
213
223
|
}
|
|
214
224
|
|
|
@@ -311,6 +321,31 @@ export function createOperations(deps) {
|
|
|
311
321
|
return { output: joinOutput(result.stdout, result.stderr) }
|
|
312
322
|
}
|
|
313
323
|
|
|
324
|
+
/**
|
|
325
|
+
* 丢弃改动。三种模式对应变更列表里的三个分组:
|
|
326
|
+
* worktree —— 未暂存(git restore --worktree)
|
|
327
|
+
* untracked —— 未跟踪(git clean -f -d)
|
|
328
|
+
* all —— 已暂存,连暂存区一起回退(git restore --staged --worktree)
|
|
329
|
+
* 都不可恢复,前端必须二次确认后才调用。
|
|
330
|
+
*/
|
|
331
|
+
async function discard(params, signal) {
|
|
332
|
+
const cwd = assertCwd(params.cwd)
|
|
333
|
+
const resolved = await repoRoot(cwd, signal)
|
|
334
|
+
const root = resolved.root
|
|
335
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
336
|
+
const mode = params.mode
|
|
337
|
+
if (!['worktree', 'untracked', 'all'].includes(mode)) {
|
|
338
|
+
throw new GitError(`未知的丢弃模式:${String(mode)}`, { code: 'invalid-params' })
|
|
339
|
+
}
|
|
340
|
+
const paths = assertPaths(params.paths)
|
|
341
|
+
let args
|
|
342
|
+
if (mode === 'untracked') args = ['clean', '-f', '-d', '--', ...paths]
|
|
343
|
+
else if (mode === 'all') args = ['restore', '--staged', '--worktree', '--', ...paths]
|
|
344
|
+
else args = ['restore', '--worktree', '--', ...paths]
|
|
345
|
+
const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
|
|
346
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
347
|
+
}
|
|
348
|
+
|
|
314
349
|
async function switchBranch(params, signal) {
|
|
315
350
|
const cwd = assertCwd(params.cwd)
|
|
316
351
|
const resolved = await repoRoot(cwd, signal)
|
|
@@ -429,6 +464,80 @@ export function createOperations(deps) {
|
|
|
429
464
|
return { branch, output: joinOutput(result.stdout, result.stderr) }
|
|
430
465
|
}
|
|
431
466
|
|
|
467
|
+
/** stash 索引只接受非负整数,避免拼进 stash@{...} 时被注入别的 ref。 */
|
|
468
|
+
function assertStashIndex(value, fallback) {
|
|
469
|
+
const raw = value === undefined || value === null ? fallback : value
|
|
470
|
+
const index = Number(raw)
|
|
471
|
+
if (!Number.isInteger(index) || index < 0 || index > 999) {
|
|
472
|
+
throw new GitError(`stash 索引非法:${String(raw)}`, { code: 'invalid-params' })
|
|
473
|
+
}
|
|
474
|
+
return index
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function stashPush(params, signal) {
|
|
478
|
+
const cwd = assertCwd(params.cwd)
|
|
479
|
+
const resolved = await repoRoot(cwd, signal)
|
|
480
|
+
const root = resolved.root
|
|
481
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
482
|
+
const args = ['stash', 'push']
|
|
483
|
+
if (params.includeUntracked === true) args.push('-u')
|
|
484
|
+
const message = typeof params.message === 'string' ? params.message.trim().slice(0, 200) : ''
|
|
485
|
+
if (message !== '') args.push('-m', message)
|
|
486
|
+
const result = await runGit(args, { cwd: root, signal, timeoutMs: timeouts.write })
|
|
487
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function stashPop(params, signal) {
|
|
491
|
+
const cwd = assertCwd(params.cwd)
|
|
492
|
+
const resolved = await repoRoot(cwd, signal)
|
|
493
|
+
const root = resolved.root
|
|
494
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
495
|
+
const index = assertStashIndex(params.index, 0)
|
|
496
|
+
const result = await runGit(['stash', 'pop', `stash@{${index}}`], {
|
|
497
|
+
cwd: root, signal, timeoutMs: timeouts.write,
|
|
498
|
+
})
|
|
499
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function stashDrop(params, signal) {
|
|
503
|
+
const cwd = assertCwd(params.cwd)
|
|
504
|
+
const resolved = await repoRoot(cwd, signal)
|
|
505
|
+
const root = resolved.root
|
|
506
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
507
|
+
const index = assertStashIndex(params.index, 0)
|
|
508
|
+
const result = await runGit(['stash', 'drop', `stash@{${index}}`], {
|
|
509
|
+
cwd: root, signal, timeoutMs: timeouts.write,
|
|
510
|
+
})
|
|
511
|
+
return { output: joinOutput(result.stdout, result.stderr) }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** 读写自定义提交提示词;传空串表示恢复默认。 */
|
|
515
|
+
async function setCommitPrompt(params, signal) {
|
|
516
|
+
const cwd = assertCwd(params.cwd)
|
|
517
|
+
const resolved = await repoRoot(cwd, signal)
|
|
518
|
+
const root = resolved.root
|
|
519
|
+
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
520
|
+
const raw = typeof params.prompt === 'string' ? params.prompt : ''
|
|
521
|
+
if (raw.length > COMMIT_PROMPT_MAX) {
|
|
522
|
+
throw new GitError(`提示词过长(上限 ${COMMIT_PROMPT_MAX} 字)`, { code: 'invalid-params' })
|
|
523
|
+
}
|
|
524
|
+
const value = raw.trim()
|
|
525
|
+
if (value === '') {
|
|
526
|
+
// 没设过时 git config --unset 退出码非 0,不算失败。
|
|
527
|
+
await runGit(['config', '--local', '--unset', COMMIT_PROMPT_KEY], {
|
|
528
|
+
cwd: root, signal, timeoutMs: timeouts.write, allowFailure: true,
|
|
529
|
+
})
|
|
530
|
+
} else {
|
|
531
|
+
await runGit(['config', '--local', COMMIT_PROMPT_KEY, value], {
|
|
532
|
+
cwd: root, signal, timeoutMs: timeouts.write,
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
const read = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
|
|
536
|
+
cwd: root, signal, allowFailure: true,
|
|
537
|
+
})
|
|
538
|
+
return { prompt: read.code === 0 ? read.stdout.trim() : '' }
|
|
539
|
+
}
|
|
540
|
+
|
|
432
541
|
async function aiCommit(params, signal) {
|
|
433
542
|
const cwd = assertCwd(params.cwd)
|
|
434
543
|
const resolved = await repoRoot(cwd, signal)
|
|
@@ -436,6 +545,10 @@ export function createOperations(deps) {
|
|
|
436
545
|
if (!root) throw new GitError(`当前目录不是 Git 仓库:${cwd}${resolved.error ? `(${resolved.error})` : ''}`, { code: 'not-a-repo' })
|
|
437
546
|
const hint = typeof params.hint === 'string' ? params.hint.slice(0, 500) : ''
|
|
438
547
|
const context = ai() ?? {}
|
|
548
|
+
const promptRes = await runGit(['config', '--get', COMMIT_PROMPT_KEY], {
|
|
549
|
+
cwd: root, signal, allowFailure: true,
|
|
550
|
+
})
|
|
551
|
+
const systemPrompt = promptRes.code === 0 ? promptRes.stdout : ''
|
|
439
552
|
return generate({
|
|
440
553
|
runGit: (args, options = {}) => runGit(args, {
|
|
441
554
|
cwd: root,
|
|
@@ -448,6 +561,7 @@ export function createOperations(deps) {
|
|
|
448
561
|
signal,
|
|
449
562
|
llm: context.llm,
|
|
450
563
|
selection: context.selection,
|
|
564
|
+
systemPrompt,
|
|
451
565
|
})
|
|
452
566
|
}
|
|
453
567
|
|
|
@@ -457,6 +571,7 @@ export function createOperations(deps) {
|
|
|
457
571
|
diff,
|
|
458
572
|
stage,
|
|
459
573
|
unstage,
|
|
574
|
+
discard,
|
|
460
575
|
switchBranch,
|
|
461
576
|
deleteBranch,
|
|
462
577
|
commit,
|
|
@@ -464,6 +579,10 @@ export function createOperations(deps) {
|
|
|
464
579
|
pull,
|
|
465
580
|
push,
|
|
466
581
|
aiCommit,
|
|
582
|
+
stashPush,
|
|
583
|
+
stashPop,
|
|
584
|
+
stashDrop,
|
|
585
|
+
setCommitPrompt,
|
|
467
586
|
}
|
|
468
587
|
|
|
469
588
|
/**
|
|
@@ -507,4 +626,4 @@ export function createOperations(deps) {
|
|
|
507
626
|
}
|
|
508
627
|
|
|
509
628
|
return { handle, ops: [...OPS], runGit }
|
|
510
|
-
}
|
|
629
|
+
}
|