@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 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 共 104 个 key),tab 标题与头部按钮都是
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
- - AI 提交信息只生成一行 subject,不生成 body。
189
- - 网络操作超时 180s;无凭据的私有仓库会失败并把 git stderr 显示出来。
200
+ - 冲突只做展示(conflicted 分组 + 横幅提示),不提供 accept ours/theirs 之类的解决 UI。
201
+ - 网络操作超时 180s;无凭据的私有仓库会失败并把 git stderr 显示出来。
@@ -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('button', {
1166
- type: 'button',
1167
- className: 'gm-btn icon sm ghost gm-act',
1168
- title: staged ? tr('changes.unstage') : tr('changes.stage'),
1169
- onClick: function (event) {
1170
- event.stopPropagation()
1171
- if (staged) props.onUnstage(entry)
1172
- else props.onStage(entry)
1173
- },
1174
- }, h(Icon, { name: staged ? 'minus' : 'plus', size: 12 })))
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, { state: state, onSwitch: props.actions.switchBranch, onDelete: props.actions.deleteBranch })
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.5",
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"
@@ -0,0 +1,8 @@
1
+ [
2
+ "assets/screenshots/01-changes.png",
3
+ "assets/screenshots/02-commit-graph.png",
4
+ "assets/screenshots/03-word-level-diff.png",
5
+ "assets/screenshots/04-branches.png",
6
+ "assets/screenshots/05-graph-multibranch.png",
7
+ "assets/screenshots/06-fullscreen.png"
8
+ ]
package/src/ai-commit.js CHANGED
@@ -2,8 +2,12 @@
2
2
  * git-manager — AI 提交信息生成。
3
3
  *
4
4
  * 流程:收集「已暂存 diff(为空则工作区 diff)→ 最近提交风格 → 调用默认模型的
5
- * llm.stream」→ 清理成一行提交信息。任何一步不可用都退回确定性文案(fallback),
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 SYSTEM_PROMPT = [
18
+ export const DEFAULT_SYSTEM_PROMPT = [
15
19
  '你是一个 Git 提交信息生成器。',
16
- '只输出一行提交信息,不要解释、不要代码块、不要引号、不要句号结尾。',
17
- '使用 Conventional Commits 风格(如 feat:/fix:/refactor:/docs:/chore:),',
18
- '不超过 72 个字符。跟随仓库最近提交的语言:最近提交多为中文就用中文,否则用英文。',
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
- return {
148
- message: fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese }),
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: SYSTEM_PROMPT,
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 subject = sanitizeSubject(text)
186
- if (!looksLikeSubject(subject)) {
187
- return {
188
- message: fallbackSubject({ files: changedFiles, stagedCount, Chinese: chinese }),
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
- return { message: subject, model, fallback: false }
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
+ }