dsh-git-changes 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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +105 -0
  3. package/README.md +122 -2
  4. package/THIRD_PARTY_NOTICES.md +12 -0
  5. package/assets/vendor/monaco/LICENSE +21 -0
  6. package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
  7. package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  8. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
  9. package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
  10. package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
  11. package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  12. package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  13. package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  14. package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  15. package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  16. package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  17. package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  18. package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  19. package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  20. package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  21. package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  22. package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  23. package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  24. package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  25. package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  26. package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  27. package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  28. package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  29. package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  30. package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  31. package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  32. package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  33. package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  34. package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  35. package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  36. package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  37. package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  38. package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  39. package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  40. package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  41. package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  42. package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  43. package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  44. package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  45. package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  46. package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  47. package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  48. package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  49. package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  50. package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  51. package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  52. package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  53. package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  54. package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  55. package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  56. package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  57. package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  58. package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  59. package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  60. package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  61. package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  62. package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  63. package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  64. package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  65. package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  66. package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  67. package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  68. package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  69. package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  70. package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  71. package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  72. package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  73. package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  74. package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  75. package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  76. package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  77. package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  78. package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  79. package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  80. package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  81. package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  82. package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  83. package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  84. package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  85. package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  86. package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  87. package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  88. package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  89. package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  90. package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  91. package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
  92. package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
  93. package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
  94. package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
  95. package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
  96. package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
  97. package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  98. package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
  99. package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
  100. package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
  101. package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  102. package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
  103. package/assets/vendor/monaco/vs/loader.js +10 -0
  104. package/cordis.patch.yml +3 -0
  105. package/docs/images/git-changes-diff.png +0 -0
  106. package/icon.svg +10 -0
  107. package/lib/client.js +9418 -0
  108. package/lib/host/assets.js +161 -0
  109. package/lib/host/commit-message.js +158 -0
  110. package/lib/host/files-index.js +206 -0
  111. package/lib/host/git-write.js +194 -0
  112. package/lib/host/git.js +1147 -0
  113. package/lib/host/lsp.js +842 -0
  114. package/lib/host/routes.js +976 -0
  115. package/lib/host/session.js +84 -0
  116. package/lib/host/util.js +107 -0
  117. package/lib/index.js +33 -0
  118. package/licenses/dsh-vscode-mode-LICENSE +21 -0
  119. package/licenses/vscode-theme-seti-LICENSE +21 -0
  120. package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
  121. package/locale/en.json +6 -0
  122. package/locale/zh.json +6 -0
  123. package/package.json +63 -3
  124. package/src/client/00-const.js +79 -0
  125. package/src/client/05-utils.js +277 -0
  126. package/src/client/10-i18n.js +548 -0
  127. package/src/client/12-vscode-icons.js +460 -0
  128. package/src/client/15-theme.js +509 -0
  129. package/src/client/20-monaco.js +1404 -0
  130. package/src/client/22-lsp.js +592 -0
  131. package/src/client/25-tree.js +563 -0
  132. package/src/client/28-changes.js +1163 -0
  133. package/src/client/30-page.js +2259 -0
  134. package/src/client/35-pill.js +185 -0
  135. package/src/client/40-commands.js +248 -0
  136. package/src/client/45-css.js +357 -0
  137. package/src/client/47-pickers.js +457 -0
  138. package/src/client/50-apply.js +207 -0
  139. package/src/client/99-footer.js +62 -0
@@ -0,0 +1,1163 @@
1
+ /* dsh-git-changes client half — Git CHANGES panel (staged / unstaged / commit / push). */
2
+
3
+ /** Status codes rendered as a single uppercase letter in the row tail. */
4
+ const CHANGE_CODES = new Set(['M', 'A', 'D', 'R', 'C', 'U', '?', '!'])
5
+ /** Maximum number of file rows rendered per section (defensive cap). */
6
+ const CHANGE_ROW_LIMIT = 2000
7
+ /** Left inset shared by every row under a section header. */
8
+ const SECTION_ROW_INDENT = 8
9
+ /** Inset that lines a file icon up with the folder icon column. */
10
+ const FILE_ICON_OFFSET = 8
11
+ /** Extra inset for file rows so names line up with the folder icon column. */
12
+
13
+ /**
14
+ * Split a porcelain status payload into the two Cursor-style groups.
15
+ *
16
+ * A file can appear in both groups (for example `MM`), matching VS Code.
17
+ * @param {object | null} status `gitStatus` route payload
18
+ * @returns {{branch: object | null, staged: Array<object>, unstaged: Array<object>}}
19
+ */
20
+ function groupChanges(status) {
21
+ const files = status && Array.isArray(status.files) ? status.files : []
22
+ const staged = []
23
+ const unstaged = []
24
+ for (const file of files) {
25
+ if (!file || typeof file.path !== 'string') continue
26
+ const index = typeof file.indexStatus === 'string' ? file.indexStatus : ' '
27
+ const work = typeof file.workTreeStatus === 'string' ? file.workTreeStatus : ' '
28
+ if (index !== ' ' && index !== '?' && index !== '!') staged.push(file)
29
+ if (work !== ' ' || index === '?') unstaged.push(file)
30
+ }
31
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
32
+ staged.sort(byPath)
33
+ unstaged.sort(byPath)
34
+ return { branch: status && status.branch ? status.branch : null, staged, unstaged }
35
+ }
36
+
37
+ /**
38
+ * Human label for the branch bar: name, detached marker, ahead/behind counts.
39
+ * @param {object | null} branch status payload branch
40
+ * @returns {string}
41
+ */
42
+ function branchLabel(branch) {
43
+ if (!branch || typeof branch !== 'object') return ''
44
+ if (branch.detached === true) return 'HEAD (detached)'
45
+ const name = typeof branch.name === 'string' && branch.name.length > 0 ? branch.name : 'HEAD'
46
+ const counts = []
47
+ if (branch.ahead > 0) counts.push('↑' + branch.ahead)
48
+ if (branch.behind > 0) counts.push('↓' + branch.behind)
49
+ return counts.length > 0 ? name + ' ' + counts.join(' ') : name
50
+ }
51
+
52
+ /**
53
+ * Split a workspace path into its display name and its parent directory.
54
+ * @param {string} path workspace-relative path
55
+ * @returns {{name: string, dir: string}}
56
+ */
57
+ function changePathParts(path) {
58
+ const text = String(path || '')
59
+ const cut = text.lastIndexOf('/')
60
+ if (cut < 0) return { name: text, dir: '' }
61
+ return { name: text.slice(cut + 1), dir: text.slice(0, cut) }
62
+ }
63
+
64
+ /**
65
+ * Build a directory tree from a flat list of change rows.
66
+ *
67
+ * Directory nodes keep their expanded state in the caller's `Set`; only file
68
+ * rows are actionable (directories toggle). The tree is intentionally local to
69
+ * this module because `FileTree` is driven by lazy Host directory reads.
70
+ * @param {Array<object>} files change rows
71
+ * @returns {object} tree root node
72
+ */
73
+ function buildChangeTree(files) {
74
+ const root = { type: 'dir', name: '', path: '', children: [] }
75
+ const dirs = new Map([['', root]])
76
+ /** Find or create one directory node. */
77
+ const ensureDir = (dirPath) => {
78
+ if (dirs.has(dirPath)) return dirs.get(dirPath)
79
+ const cut = dirPath.lastIndexOf('/')
80
+ const parentPath = cut < 0 ? '' : dirPath.slice(0, cut)
81
+ const parent = ensureDir(parentPath)
82
+ const node = { type: 'dir', name: dirPath.slice(cut + 1), path: dirPath, children: [] }
83
+ parent.children.push(node)
84
+ dirs.set(dirPath, node)
85
+ return node
86
+ }
87
+ for (const file of files) {
88
+ const { dir } = changePathParts(file.path)
89
+ ensureDir(dir).children.push({ type: 'file', name: changePathParts(file.path).name, path: file.path, file })
90
+ }
91
+ /** Sort directories first, then by name. */
92
+ const sortNode = (node) => {
93
+ if (node.children.length > 1) {
94
+ node.children.sort((a, b) => {
95
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
96
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
97
+ })
98
+ }
99
+ for (const child of node.children) if (child.type === 'dir') sortNode(child)
100
+ }
101
+ sortNode(root)
102
+ // Collapse single-child directory chains (packages/ui/theme in one row) the
103
+ // way VS Code and Cursor render the SCM tree.
104
+ const collapseDirs = (node) => {
105
+ for (const child of node.children) {
106
+ if (child.type !== 'dir') continue
107
+ while (child.children.length === 1 && child.children[0].type === 'dir') {
108
+ const only = child.children[0]
109
+ child.name = child.name + '/' + only.name
110
+ child.path = only.path
111
+ child.children = only.children
112
+ }
113
+ collapseDirs(child)
114
+ }
115
+ }
116
+ collapseDirs(root)
117
+ // Aggregate status for folder rows: the dot colour mirrors the most
118
+ // important change below the folder.
119
+ const STATUS_PRIORITY = ['!', 'D', 'A', 'U', 'R', 'C', 'M', '?']
120
+ const worstStatus = (node) => {
121
+ let best = ''
122
+ let bestRank = STATUS_PRIORITY.length + 1
123
+ const walk = (entry) => {
124
+ for (const child of entry.children) {
125
+ if (child.type === 'dir') {
126
+ walk(child)
127
+ continue
128
+ }
129
+ const code = String((child.file && child.file.code) || '').trim()
130
+ if (!code) continue
131
+ const rank = STATUS_PRIORITY.indexOf(code)
132
+ if (rank === -1) continue
133
+ if (rank < bestRank) { best = code; bestRank = rank }
134
+ }
135
+ }
136
+ walk(node)
137
+ return best
138
+ }
139
+ const attachStatus = (node) => {
140
+ for (const child of node.children) {
141
+ if (child.type !== 'dir') continue
142
+ child.status = worstStatus(child)
143
+ attachStatus(child)
144
+ }
145
+ }
146
+ attachStatus(root)
147
+ return root
148
+ }
149
+
150
+ /**
151
+ * Map a failed write payload onto a translated, user-facing message.
152
+ * @param {object | null} body response payload
153
+ * @param {(key: string) => string} t locale translate
154
+ * @returns {string}
155
+ */
156
+ function describeGitError(body, t) {
157
+ const code = body && (body.code || (body.error && body.error.code))
158
+ const key = code ? 'changes.error.' + code : 'changes.error.generic'
159
+ const translated = typeof t === 'function' ? t(key) : key
160
+ if (translated === key) return typeof t === 'function' ? t('changes.error.generic') : 'Operation failed.'
161
+ return translated
162
+ }
163
+
164
+ /**
165
+ * CSS-safe modifier for one porcelain status code ('?'/! would break selectors).
166
+ * @param {string} code status letter
167
+ * @returns {string} class suffix
168
+ */
169
+ function changeCodeClass(code) {
170
+ const value = String(code || ' ').trim()
171
+ if (value === '?') return 'u'
172
+ if (value === '!') return 'conflict'
173
+ return value === '' ? 'none' : value.toLowerCase()
174
+ }
175
+
176
+ /**
177
+ * Small document glyph used by the "open file" row action.
178
+ * @returns {any} React element
179
+ */
180
+ function FileGlyph() {
181
+ return h(
182
+ 'svg',
183
+ {
184
+ width: 12,
185
+ height: 12,
186
+ viewBox: '0 0 16 16',
187
+ 'aria-hidden': 'true',
188
+ focusable: 'false',
189
+ fill: 'none',
190
+ stroke: 'currentColor',
191
+ strokeWidth: 1.3,
192
+ strokeLinejoin: 'round',
193
+ strokeLinecap: 'round',
194
+ },
195
+ h('path', { d: 'M4 1.5h5.2l2.8 2.8V14.5H4z' }),
196
+ h('path', { d: 'M9.2 1.5v2.8H12' }),
197
+ h('path', { d: 'M6 8h4' }),
198
+ h('path', { d: 'M6 11h4' }),
199
+ )
200
+ }
201
+
202
+ /**
203
+ * One clickable change row with hover stage/unstage action and a status letter.
204
+ * @param {object} props row props
205
+ * @returns {any} React element
206
+ */
207
+ function ChangeRow(props) {
208
+ const { file, scope, depth, activePath, onOpen, onStage, onUnstage, onContextMenu, onOpenFile, onRequestDiscard, t, scheme } = props
209
+ const parts = changePathParts(file.path)
210
+ const isActive = activePath === file.path
211
+ const action = scope === 'staged'
212
+ ? { label: '\u2212', title: t('changes.unstage'), onClick: () => onUnstage([file.path]) }
213
+ : { label: '+', title: t('changes.stage'), onClick: () => onStage([file.path]) }
214
+ const untracked = file.code === 'U' || file.indexStatus === '?'
215
+ const openFileButton = typeof onOpenFile === 'function'
216
+ ? h(
217
+ 'button',
218
+ {
219
+ type: 'button',
220
+ className: 'drp-icon-btn drp-change-action drp-file-action',
221
+ title: t('changes.openFile'),
222
+ 'aria-label': t('changes.openFile'),
223
+ onClick: (event) => {
224
+ event.stopPropagation()
225
+ onOpenFile(file.path)
226
+ },
227
+ },
228
+ h(FileGlyph),
229
+ )
230
+ : null
231
+ // 暂存区里的改动已经有「取消暂存」(−);撤销/放弃只对工作区未暂存的改动开放。
232
+ const discardButton = scope !== 'staged' && typeof onRequestDiscard === 'function'
233
+ ? h(
234
+ 'button',
235
+ {
236
+ type: 'button',
237
+ className: 'drp-icon-btn drp-change-action drp-discard-action',
238
+ title: scope === 'staged' ? t('changes.discardStaged') : t('changes.discard'),
239
+ 'aria-label': scope === 'staged' ? t('changes.discardStaged') : t('changes.discard'),
240
+ onClick: (event) => {
241
+ event.stopPropagation()
242
+ onRequestDiscard([file.path], scope, untracked ? [file.path] : [])
243
+ },
244
+ },
245
+ '\u21ba',
246
+ )
247
+ : null
248
+ return h(
249
+ 'div',
250
+ {
251
+ className: cx('drp-tree-row', 'drp-change-row', isActive && 'drp-tree-active'),
252
+ style: { paddingLeft: (SECTION_ROW_INDENT + FILE_ICON_OFFSET + treeIndentOf((depth || 0) + 1)) + 'px' },
253
+ role: 'treeitem',
254
+ tabIndex: 0,
255
+ title: file.path + (file.code ? '\nGit: ' + file.code : ''),
256
+ onClick: () => onOpen(file.path, scope),
257
+ onContextMenu: (event) => {
258
+ if (typeof onContextMenu === 'function') onContextMenu(event, file.path, scope)
259
+ },
260
+ onKeyDown: (event) => {
261
+ if (event.key === 'Enter') onOpen(file.path, scope)
262
+ },
263
+ },
264
+ h('span', { className: 'drp-change-file-icon' }, h(FileIcon, { path: file.path, scheme })),
265
+ h('span', { className: 'drp-change-name' }, parts.name),
266
+ parts.dir
267
+ ? h('span', { className: 'drp-change-dir' }, parts.dir)
268
+ : null,
269
+ openFileButton,
270
+ discardButton,
271
+ h(
272
+ 'button',
273
+ {
274
+ type: 'button',
275
+ className: 'drp-icon-btn drp-change-action',
276
+ title: action.title,
277
+ 'aria-label': action.title,
278
+ onClick: (event) => {
279
+ event.stopPropagation()
280
+ action.onClick()
281
+ },
282
+ },
283
+ action.label,
284
+ ),
285
+ h('span', { className: cx('drp-change-code', 'drp-code-' + changeCodeClass(file.code)) }, file.code || ''),
286
+ )
287
+ }
288
+
289
+ /**
290
+ * Flatten one change group into a sorted list (the "list" view).
291
+ * @param {Array<object>} files status records
292
+ * @returns {Array<object>} sorted records
293
+ */
294
+ function buildChangeList(files) {
295
+ return (Array.isArray(files) ? files : [])
296
+ .filter((file) => file && typeof file.path === 'string')
297
+ .slice()
298
+ .sort((left, right) => left.path.localeCompare(right.path))
299
+ }
300
+
301
+ /**
302
+ * Collect the file paths below one tree node so folder rows can stage or
303
+ * unstage everything inside them with the existing write API.
304
+ * @param {object} node tree node
305
+ * @returns {Array<string>} descendant file paths
306
+ */
307
+ function collectDirFiles(node) {
308
+ const paths = []
309
+ const walk = (entry) => {
310
+ if (!entry || !Array.isArray(entry.children)) return
311
+ for (const child of entry.children) {
312
+ if (child.type === 'dir') walk(child)
313
+ else if (child.file && typeof child.file.path === 'string') paths.push(child.file.path)
314
+ }
315
+ }
316
+ walk(node)
317
+ return paths
318
+ }
319
+
320
+ /**
321
+ * Render one tree level of change rows.
322
+ * @param {object} props level props
323
+ * @returns {Array<any>} rows
324
+ */
325
+ function ChangeTreeLevel(props) {
326
+ const { node, depth, collapsed, onToggle, rowProps } = props
327
+ const { t } = rowProps
328
+ const rows = []
329
+ let files = 0
330
+ for (const child of node.children) {
331
+ if (files >= CHANGE_ROW_LIMIT) break
332
+ if (child.type === 'dir') {
333
+ const isOpen = !collapsed.has(child.path)
334
+ const dirPaths = collectDirFiles(child)
335
+ const dirAction = rowProps.scope === 'staged'
336
+ ? { label: '\u2212', title: t('changes.unstageFolder'), click: () => rowProps.onUnstage(dirPaths) }
337
+ : { label: '+', title: t('changes.stageFolder'), click: () => rowProps.onStage(dirPaths) }
338
+ rows.push(h(
339
+ 'div',
340
+ {
341
+ key: 'dir:' + child.path,
342
+ className: 'drp-tree-row drp-tree-dir',
343
+ style: { paddingLeft: (SECTION_ROW_INDENT + treeIndentOf(depth)) + 'px' },
344
+ role: 'treeitem',
345
+ onClick: () => onToggle(child.path),
346
+ },
347
+ h('span', { className: 'drp-tree-caret' }, isOpen ? '▾' : '▸'),
348
+ h('span', { className: 'drp-folder-icon' }, h(FolderGlyph)),
349
+ h('span', { className: 'drp-change-dirname' }, child.name),
350
+ child.status
351
+ ? h('span', { className: cx('drp-dir-dot', 'drp-code-' + changeCodeClass(child.status)) })
352
+ : null,
353
+ dirPaths.length > 0
354
+ ? h(
355
+ 'button',
356
+ {
357
+ type: 'button',
358
+ className: 'drp-icon-btn drp-change-action drp-dir-action',
359
+ title: dirAction.title,
360
+ 'aria-label': dirAction.title,
361
+ onClick: (event) => {
362
+ event.stopPropagation()
363
+ dirAction.click()
364
+ },
365
+ },
366
+ dirAction.label,
367
+ )
368
+ : null,
369
+ ))
370
+ if (isOpen) {
371
+ rows.push(...ChangeTreeLevel({ ...props, node: child, depth: depth + 1 }))
372
+ }
373
+ continue
374
+ }
375
+ files += 1
376
+ rows.push(h(ChangeRow, { key: 'file:' + child.path, file: child.file, depth, ...rowProps }))
377
+ }
378
+ return rows
379
+ }
380
+
381
+ /**
382
+ * Modal confirmation shown before a discard (destructive, not undoable).
383
+ * @param {object} props component props
384
+ * @returns {any} React element
385
+ */
386
+ function DiscardConfirm(props) {
387
+ const { t, count, onCancel, onConfirm } = props
388
+ return h(
389
+ 'div',
390
+ { className: 'drp-confirm-overlay', role: 'presentation' },
391
+ h(
392
+ 'div',
393
+ { className: 'drp-confirm', role: 'dialog', 'aria-modal': 'true' },
394
+ h('div', { className: 'drp-confirm-title' }, t('changes.discardConfirmTitle')),
395
+ h('div', { className: 'drp-confirm-body' }, t('changes.discardConfirmBody', { count: String(count) })),
396
+ h(
397
+ 'div',
398
+ { className: 'drp-confirm-actions' },
399
+ h('button', { type: 'button', className: 'drp-btn', onClick: onCancel }, t('changes.discardCancel')),
400
+ h('button', { type: 'button', className: 'drp-btn drp-btn-danger', onClick: onConfirm }, t('changes.discardConfirmOk')),
401
+ ),
402
+ ),
403
+ )
404
+ }
405
+
406
+ /**
407
+ * One collapsible section (staged or unstaged) with a header action.
408
+ * @param {object} props section props
409
+ * @returns {any} React element
410
+ */
411
+ function ChangeSection(props) {
412
+ const { scope, files, collapsedDirs, collapsed, view, onToggleCollapsed, onToggleDir, onStage, onUnstage, onOpen, onContextMenu, activePath, t, onOpenFile, onRequestDiscard, scheme } = props
413
+ const count = files.length
414
+ const allAction = scope === 'staged'
415
+ ? { title: t('changes.unstageAll'), label: '−', onClick: () => onUnstage(null) }
416
+ : { title: t('changes.stageAll'), label: '+', onClick: () => onStage(null) }
417
+ const title = scope === 'staged' ? t('changes.staged') : t('changes.unstaged')
418
+ const tree = buildChangeTree(files)
419
+ return h(
420
+ 'div',
421
+ { className: 'drp-changes-section' },
422
+ h(
423
+ 'div',
424
+ {
425
+ className: 'drp-changes-section-head',
426
+ role: 'button',
427
+ tabIndex: 0,
428
+ onClick: () => onToggleCollapsed(scope),
429
+ onKeyDown: (event) => { if (event.key === 'Enter') onToggleCollapsed(scope) },
430
+ },
431
+ h('span', { className: 'drp-tree-caret' }, collapsed ? '▸' : '▾'),
432
+ h('span', { className: 'drp-changes-section-title' }, title),
433
+ h('span', { className: 'drp-changes-count' }, String(count)),
434
+ scope === 'unstaged' && count > 0 && typeof onRequestDiscard === 'function'
435
+ ? h(
436
+ 'button',
437
+ {
438
+ type: 'button',
439
+ className: 'drp-icon-btn drp-changes-bulk',
440
+ title: t('changes.discardAll'),
441
+ 'aria-label': t('changes.discardAll'),
442
+ onClick: (event) => {
443
+ event.stopPropagation()
444
+ const paths = files.map((file) => file.path)
445
+ const untracked = files.filter((file) => file.code === 'U' || file.indexStatus === '?').map((file) => file.path)
446
+ onRequestDiscard(paths, 'unstaged', untracked)
447
+ },
448
+ },
449
+ '\u21ba',
450
+ )
451
+ : null,
452
+ h(
453
+ 'button',
454
+ {
455
+ type: 'button',
456
+ className: 'drp-icon-btn drp-changes-bulk',
457
+ title: allAction.title,
458
+ 'aria-label': allAction.title,
459
+ disabled: count === 0,
460
+ onClick: (event) => {
461
+ event.stopPropagation()
462
+ allAction.onClick()
463
+ },
464
+ },
465
+ allAction.label,
466
+ ),
467
+ ),
468
+ collapsed
469
+ ? null
470
+ : h(
471
+ 'div',
472
+ { className: 'drp-changes-tree', role: 'tree' },
473
+ count === 0
474
+ ? h('div', { className: 'drp-changes-empty' }, t('changes.sectionEmpty'))
475
+ : view === 'list'
476
+ ? buildChangeList(files).slice(0, CHANGE_ROW_LIMIT).map((file) => h(ChangeRow, {
477
+ key: 'file:' + file.path,
478
+ file,
479
+ scope,
480
+ depth: 0,
481
+ activePath,
482
+ onOpen,
483
+ onStage,
484
+ onUnstage,
485
+ onContextMenu,
486
+ onOpenFile,
487
+ onRequestDiscard,
488
+ scheme,
489
+ t,
490
+ }))
491
+ : ChangeTreeLevel({
492
+ node: tree,
493
+ depth: 0,
494
+ collapsed: collapsedDirs,
495
+ onToggle: onToggleDir,
496
+ rowProps: { scope, activePath, onOpen, onStage, onUnstage, onContextMenu, onOpenFile, onRequestDiscard, scheme, t },
497
+ }),
498
+ ),
499
+ )
500
+ }
501
+
502
+ /**
503
+ * Branch name, ahead/behind counters, refresh action.
504
+ * @param {object} props bar props
505
+ * @returns {any} React element
506
+ */
507
+ function BranchBar(props) {
508
+ const { branch, busy, view, onRefresh, onToggleView, t } = props
509
+ const label = branchLabel(branch)
510
+ const nextView = view === 'list' ? 'tree' : 'list'
511
+ return h(
512
+ 'div',
513
+ { className: 'drp-changes-branch' },
514
+ h('span', { className: 'drp-branch-icon', 'aria-hidden': 'true' }, '⎇'),
515
+ h('span', { className: 'drp-branch-name', title: label || t('changes.branchUnknown') }, label || t('changes.branchUnknown')),
516
+ h(
517
+ 'button',
518
+ {
519
+ type: 'button',
520
+ className: 'drp-icon-btn drp-branch-view',
521
+ title: t('changes.view.' + nextView),
522
+ 'aria-label': t('changes.view.' + nextView),
523
+ 'aria-pressed': view === 'list' ? 'true' : 'false',
524
+ onClick: () => {
525
+ if (typeof onToggleView === 'function') onToggleView()
526
+ },
527
+ },
528
+ view === 'list' ? '▤' : '☰',
529
+ ),
530
+ h(
531
+ 'button',
532
+ {
533
+ type: 'button',
534
+ className: 'drp-icon-btn drp-branch-refresh',
535
+ title: t('changes.refresh'),
536
+ 'aria-label': t('changes.refresh'),
537
+ disabled: busy === true,
538
+ onClick: onRefresh,
539
+ },
540
+ busy === true ? '…' : '⟳',
541
+ ),
542
+ )
543
+ }
544
+
545
+ /**
546
+ * Lay out commit history on a compact lane graph.
547
+ * Walks newest-first and keeps one lane head per pending commit, so merges and
548
+ * branches stay on their own column without a full git-graph library.
549
+ * @param {Array<object>} commits commit records from the log route
550
+ * @returns {Array<object>} per-row layout records
551
+ */
552
+ function buildGraphRows(commits) {
553
+ const lanes = []
554
+ const rows = []
555
+ for (const commit of Array.isArray(commits) ? commits : []) {
556
+ if (!commit || typeof commit.sha !== 'string') continue
557
+ let lane = lanes.indexOf(commit.sha)
558
+ if (lane < 0) {
559
+ lane = lanes.length
560
+ lanes.push(commit.sha)
561
+ }
562
+ const before = lanes.slice()
563
+ const parents = Array.isArray(commit.parents) ? commit.parents : []
564
+ const first = parents[0]
565
+ if (first) lanes[lane] = first
566
+ else lanes.splice(lane, 1)
567
+ for (const parent of parents.slice(1)) {
568
+ if (!lanes.includes(parent)) lanes.push(parent)
569
+ }
570
+ rows.push({
571
+ sha: commit.sha,
572
+ short: commit.short || String(commit.sha).slice(0, 7),
573
+ subject: commit.subject || '',
574
+ author: commit.author || '',
575
+ date: commit.date || '',
576
+ refs: Array.isArray(commit.refs) ? commit.refs : [],
577
+ lane,
578
+ before,
579
+ after: lanes.slice(),
580
+ })
581
+ }
582
+ return rows
583
+ }
584
+
585
+ /**
586
+ * One lane-graph row: connector lines, the commit dot, subject and metadata.
587
+ * @param {object} props row props
588
+ * @returns {any} React element
589
+ */
590
+ function ChangeGraphRow(props) {
591
+ const { row, onOpen, t } = props
592
+ const height = 26
593
+ const column = (index) => index * 14 + 7
594
+ const width = Math.max(14, Math.max(row.before.length, row.after.length) * 14)
595
+ const lines = []
596
+ const count = Math.max(row.before.length, row.after.length)
597
+ for (let index = 0; index < count; index += 1) {
598
+ lines.push(h('line', {
599
+ key: 'lane' + index,
600
+ x1: column(index),
601
+ y1: 0,
602
+ x2: column(index),
603
+ y2: height,
604
+ className: 'drp-graph-lane',
605
+ }))
606
+ }
607
+ lines.push(h('circle', { key: 'dot', cx: column(row.lane), cy: height / 2, r: 3.2, className: 'drp-graph-dot' }))
608
+ const meta = [row.refs[0], row.author, String(row.date || '').slice(0, 10)].filter(Boolean).join(' · ')
609
+ return h(
610
+ 'button',
611
+ {
612
+ type: 'button',
613
+ className: 'drp-graph-row',
614
+ title: row.sha,
615
+ onClick: () => {
616
+ if (typeof onOpen === 'function') onOpen(row)
617
+ },
618
+ },
619
+ h('svg', { className: 'drp-graph-svg', width, height, 'aria-hidden': 'true' }, lines),
620
+ h(
621
+ 'span',
622
+ { className: 'drp-graph-main' },
623
+ h('span', { className: 'drp-graph-subject' }, row.subject || row.short),
624
+ meta ? h('span', { className: 'drp-graph-meta' }, meta) : null,
625
+ ),
626
+ )
627
+ }
628
+
629
+ /**
630
+ * Commit message input; Enter submits, Shift+Enter submits and pushes.
631
+ * @param {object} props box props
632
+ * @returns {any} React element
633
+ */
634
+ function CommitBox(props) {
635
+ const { value, branch, busy, generating, onChange, onSubmit, onGenerate, t } = props
636
+ const placeholder = t('changes.messagePlaceholder', { branch: branchLabel(branch) || 'HEAD' })
637
+ return h(
638
+ 'div',
639
+ { className: 'drp-commit-field' },
640
+ h('textarea', {
641
+ className: 'drp-commit-box',
642
+ rows: 2,
643
+ value: value,
644
+ placeholder,
645
+ spellCheck: false,
646
+ 'aria-label': t('changes.message'),
647
+ disabled: busy === true,
648
+ onChange: (event) => onChange(event.target.value),
649
+ onKeyDown: (event) => {
650
+ if (event.key !== 'Enter') return
651
+ if (!(event.metaKey || event.ctrlKey)) return
652
+ event.preventDefault()
653
+ onSubmit(event.shiftKey === true)
654
+ },
655
+ }),
656
+ h(
657
+ 'button',
658
+ {
659
+ type: 'button',
660
+ className: 'drp-commit-ai',
661
+ title: t('changes.aiGenerate'),
662
+ 'aria-label': t('changes.aiGenerate'),
663
+ disabled: busy === true || generating === true,
664
+ onClick: () => {
665
+ if (typeof onGenerate === 'function') onGenerate()
666
+ },
667
+ },
668
+ generating === true ? '…' : '✨',
669
+ ),
670
+ )
671
+ }
672
+
673
+ /**
674
+ * Primary commit button with a dropdown for "commit and push".
675
+ * @param {object} props button props
676
+ * @returns {any} React element
677
+ */
678
+ function CommitButton(props) {
679
+ const { busy, disabled, onCommit, onCommitPush, t } = props
680
+ const [menuOpen, setMenuOpen] = React.useState(false)
681
+ const menuRef = React.useRef(null)
682
+ React.useEffect(() => {
683
+ if (!menuOpen) return undefined
684
+ const close = (event) => {
685
+ if (menuRef.current && !menuRef.current.contains(event.target)) setMenuOpen(false)
686
+ }
687
+ document.addEventListener('mousedown', close)
688
+ return () => document.removeEventListener('mousedown', close)
689
+ }, [menuOpen])
690
+ return h(
691
+ 'div',
692
+ { className: 'drp-commit-row' },
693
+ h(
694
+ 'button',
695
+ {
696
+ type: 'button',
697
+ className: 'drp-btn drp-btn-primary drp-commit-main',
698
+ disabled: disabled === true || busy === true,
699
+ onClick: () => onCommit(false),
700
+ },
701
+ busy === true ? t('changes.committing') : '✓ ' + t('changes.commit'),
702
+ ),
703
+ h(
704
+ 'div',
705
+ { className: 'drp-commit-split', ref: menuRef },
706
+ h(
707
+ 'button',
708
+ {
709
+ type: 'button',
710
+ className: 'drp-btn drp-commit-caret',
711
+ 'aria-label': t('changes.moreCommitActions'),
712
+ title: t('changes.moreCommitActions'),
713
+ disabled: disabled === true || busy === true,
714
+ onClick: () => setMenuOpen((open) => !open),
715
+ },
716
+ '▾',
717
+ ),
718
+ menuOpen
719
+ ? h(
720
+ 'div',
721
+ { className: 'drp-commit-menu', role: 'menu' },
722
+ h('button', {
723
+ type: 'button',
724
+ className: 'drp-menu-item',
725
+ role: 'menuitem',
726
+ onClick: () => { setMenuOpen(false); onCommit(false) },
727
+ }, t('changes.commit')),
728
+ h('button', {
729
+ type: 'button',
730
+ className: 'drp-menu-item',
731
+ role: 'menuitem',
732
+ onClick: () => { setMenuOpen(false); onCommit(true) },
733
+ }, t('changes.commitPush')),
734
+ )
735
+ : null,
736
+ ),
737
+ )
738
+ }
739
+
740
+ /**
741
+ * Transient toast for operation results.
742
+ * @param {object} props toast props
743
+ * @returns {any} React element or null
744
+ */
745
+ function Toast(props) {
746
+ const { toast } = props
747
+ if (!toast || typeof toast.text !== 'string' || toast.text.length === 0) return null
748
+ return h('div', { className: cx('drp-toast', toast.kind === 'error' && 'drp-toast-error') }, toast.text)
749
+ }
750
+
751
+ /**
752
+ * Context menu for one change row (open / stage / unstage / copy path).
753
+ * @param {object} props menu props
754
+ * @returns {any} React element or null
755
+ */
756
+ function ChangeContextMenu(props) {
757
+ const { menu, onClose, onOpen, onStage, onUnstage, onRequestDiscard, onNotify, t } = props
758
+ React.useEffect(() => {
759
+ if (!menu) return undefined
760
+ const close = () => onClose()
761
+ document.addEventListener('mousedown', close)
762
+ document.addEventListener('keydown', close)
763
+ return () => {
764
+ document.removeEventListener('mousedown', close)
765
+ document.removeEventListener('keydown', close)
766
+ }
767
+ }, [menu, onClose])
768
+ if (!menu) return null
769
+ const item = (label, run) => h('button', {
770
+ type: 'button',
771
+ className: 'drp-menu-item',
772
+ role: 'menuitem',
773
+ onClick: (event) => { event.stopPropagation(); run() },
774
+ }, label)
775
+ return h(
776
+ 'div',
777
+ {
778
+ className: 'drp-ctx-menu',
779
+ role: 'menu',
780
+ style: { left: menu.x + 'px', top: menu.y + 'px' },
781
+ onMouseDown: (event) => event.stopPropagation(),
782
+ },
783
+ item(t('changes.openDiff'), () => { onClose(); onOpen(menu.path, menu.scope) }),
784
+ menu.scope === 'staged'
785
+ ? item(t('changes.unstage'), () => { onClose(); onUnstage([menu.path]) })
786
+ : item(t('changes.stage'), () => { onClose(); onStage([menu.path]) }),
787
+ menu.scope === 'staged'
788
+ ? null
789
+ : item(t('changes.discard'), () => { onClose(); onRequestDiscard([menu.path], menu.scope, []) }),
790
+ item(t('changes.copyPath'), () => {
791
+ onClose()
792
+ try {
793
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
794
+ void navigator.clipboard.writeText(menu.path)
795
+ if (typeof onNotify === 'function') onNotify(t('changes.copied'), 'info')
796
+ return
797
+ }
798
+ } catch (error) { /* clipboard unavailable */ }
799
+ if (typeof onNotify === 'function') onNotify(t('changes.copyFailed'), 'error')
800
+ }),
801
+ )
802
+ }
803
+
804
+ /**
805
+ * Git CHANGES panel: branch bar, commit box, commit button, staged/unstaged trees.
806
+ *
807
+ * Status comes from `gitStatus`; writes go through the `stage`/`unstage`/`commit`
808
+ * POST routes, and a successful write's fresh `status` replaces local state
809
+ * (optimistic refresh) before the 30s fallback poll runs again.
810
+ * @param {object} props panel props
811
+ * @returns {any} React element
812
+ */
813
+ function ChangesPanel(props) {
814
+ const { sessionId, activePath, onOpenChangeDiff, onOpenCommit, onOpenFile, onWorkspaceChanged, onNotify, t, initialStatus, scheme } = props
815
+ const [status, setStatus] = React.useState(initialStatus || null)
816
+ const [error, setError] = React.useState(null)
817
+ const [loading, setLoading] = React.useState(!initialStatus)
818
+ const [busy, setBusy] = React.useState(false)
819
+ const [message, setMessage] = React.useState('')
820
+ const [collapsed, setCollapsed] = React.useState({ staged: false, unstaged: false })
821
+ // Directories are expanded by default; only paths the user collapsed are kept.
822
+ const [collapsedDirs, setCollapsedDirs] = React.useState(() => new Set())
823
+ const [toast, setToast] = React.useState(null)
824
+ const [generating, setGenerating] = React.useState(false)
825
+ /** Changes list layout: tree (folders) or flat list. */
826
+ const [view, setView] = React.useState(() => {
827
+ try {
828
+ return localStorage.getItem('dsh-git-changes.changeView') === 'list' ? 'list' : 'tree'
829
+ } catch (error) {
830
+ return 'tree'
831
+ }
832
+ })
833
+ const [graph, setGraph] = React.useState(null)
834
+ const [graphOpen, setGraphOpen] = React.useState(false)
835
+ const [graphLoading, setGraphLoading] = React.useState(false)
836
+ const [menu, setMenu] = React.useState(null)
837
+ const [pendingDiscard, setPendingDiscard] = React.useState(null)
838
+ const toastTimer = React.useRef(null)
839
+ const aliveRef = React.useRef(true)
840
+
841
+ React.useEffect(() => {
842
+ aliveRef.current = true
843
+ return () => {
844
+ aliveRef.current = false
845
+ if (toastTimer.current) clearTimeout(toastTimer.current)
846
+ }
847
+ }, [])
848
+
849
+ /** Show a transient toast. */
850
+ const notify = React.useCallback((text, kind) => {
851
+ if (!aliveRef.current) return
852
+ setToast({ text, kind: kind || 'info' })
853
+ if (toastTimer.current) clearTimeout(toastTimer.current)
854
+ toastTimer.current = setTimeout(() => {
855
+ if (aliveRef.current) setToast(null)
856
+ }, 3200)
857
+ }, [])
858
+
859
+ /** Load the latest status payload. */
860
+ const refresh = React.useCallback(async (options) => {
861
+ if (!sessionId) return
862
+ if (!(options && options.quiet === true)) setLoading(true)
863
+ try {
864
+ const value = await apiGet('gitStatus', { sessionId })
865
+ if (!aliveRef.current) return
866
+ setStatus(value || { available: false, reason: 'git-failed' })
867
+ setError(null)
868
+ } catch (failure) {
869
+ if (!aliveRef.current) return
870
+ setError(failure && failure.message ? failure.message : String(failure))
871
+ } finally {
872
+ if (aliveRef.current) setLoading(false)
873
+ }
874
+ }, [sessionId])
875
+
876
+ React.useEffect(() => {
877
+ if (initialStatus) return
878
+ void refresh()
879
+ }, [refresh, initialStatus])
880
+
881
+ // 30s fallback poll; skip while a write is in flight or the document is hidden.
882
+ React.useEffect(() => {
883
+ const timer = setInterval(() => {
884
+ if (busy || (typeof document !== 'undefined' && document.hidden)) return
885
+ void refresh({ quiet: true })
886
+ }, 30000)
887
+ return () => clearInterval(timer)
888
+ }, [busy, refresh])
889
+
890
+ /** Run one write request and fold its fresh status back into local state. */
891
+ const runWrite = React.useCallback(async (name, body, successText) => {
892
+ if (!sessionId || busy) return
893
+ setBusy(true)
894
+ try {
895
+ const value = await apiPost(name, { sessionId, ...(body || {}) })
896
+ if (!aliveRef.current) return
897
+ if (value && value.ok === true) {
898
+ if (value.status) setStatus(value.status)
899
+ else void refresh({ quiet: true })
900
+ if (successText) notify(successText, 'info')
901
+ return value
902
+ }
903
+ if (value && value.status) setStatus(value.status)
904
+ const text = describeGitError(value, t)
905
+ setError(text)
906
+ notify(text, 'error')
907
+ return null
908
+ } catch (failure) {
909
+ if (!aliveRef.current) return null
910
+ const text = failure && failure.message ? failure.message : String(failure)
911
+ setError(text)
912
+ notify(text, 'error')
913
+ return null
914
+ } finally {
915
+ if (aliveRef.current) setBusy(false)
916
+ }
917
+ }, [busy, notify, refresh, sessionId, t])
918
+
919
+ const groups = groupChanges(status)
920
+ const canCommit = groups.staged.length > 0 && message.trim().length > 0 && !busy
921
+
922
+ /** Stage the given paths, or everything when `paths` is null. */
923
+ const stage = (paths) => runWrite('stage', paths === null ? { all: true } : { paths }, t('changes.stagedToast'))
924
+ /** Unstage the given paths, or everything when `paths` is null. */
925
+ const unstage = (paths) => runWrite('unstage', paths === null ? { all: true } : { paths }, t('changes.unstagedToast'))
926
+
927
+ /** Commit the index, optionally pushing afterwards. */
928
+ const commit = async (push) => {
929
+ if (!canCommit) return
930
+ const value = await runWrite('commit', { message, push: push === true }, null)
931
+ if (!value) return
932
+ setMessage('')
933
+ if (value.pushed === true) notify(t('changes.committedPushed'), 'info')
934
+ else if (push === true) notify(describeGitError({ code: value.pushCode || 'push-failed' }, t), 'error')
935
+ else notify(t('changes.committed'), 'info')
936
+ }
937
+
938
+ const toggleCollapsed = (scope) => setCollapsed((current) => ({ ...current, [scope]: !current[scope] }))
939
+ const toggleDir = (path) => setCollapsedDirs((current) => {
940
+ const next = new Set(current)
941
+ if (next.has(path)) next.delete(path)
942
+ else next.add(path)
943
+ return next
944
+ })
945
+
946
+ const openChangeDiff = (path, scope) => {
947
+ if (typeof onOpenChangeDiff === 'function') onOpenChangeDiff(path, scope)
948
+ }
949
+
950
+ /** Ask for confirmation before discarding; the caller never discards inline. */
951
+ const requestDiscard = (paths, scope, untrackedPaths) => {
952
+ if (!Array.isArray(paths) || paths.length === 0) return
953
+ setPendingDiscard({
954
+ paths,
955
+ scope: scope === 'staged' ? 'staged' : 'unstaged',
956
+ untrackedPaths: Array.isArray(untrackedPaths) ? untrackedPaths : [],
957
+ })
958
+ }
959
+
960
+ /** Run the destructive discard after the user confirmed it. */
961
+ const confirmDiscard = () => {
962
+ const pending = pendingDiscard
963
+ if (!pending) return
964
+ setPendingDiscard(null)
965
+ void (async () => {
966
+ const value = await runWrite('discard', {
967
+ paths: pending.paths,
968
+ untrackedPaths: pending.untrackedPaths,
969
+ scope: pending.scope,
970
+ }, t('changes.discarded'))
971
+ if (value && typeof onWorkspaceChanged === 'function') onWorkspaceChanged()
972
+ })()
973
+ }
974
+
975
+ /** Ask the host model for a commit message from the current diff. */
976
+ const generate = async () => {
977
+ if (!sessionId || busy || generating) return
978
+ setGenerating(true)
979
+ try {
980
+ const value = await apiPost('generateMessage', { sessionId })
981
+ if (!aliveRef.current) return
982
+ if (value && value.ok === true && typeof value.message === 'string') {
983
+ setMessage(value.message)
984
+ notify(t('changes.aiGenerated'), 'info')
985
+ } else {
986
+ notify(describeGitError({ code: value && value.code ? value.code : 'generate-failed' }, t), 'error')
987
+ }
988
+ } catch (failure) {
989
+ if (!aliveRef.current) return
990
+ notify(failure && failure.message ? failure.message : t('changes.aiFailed'), 'error')
991
+ } finally {
992
+ if (aliveRef.current) setGenerating(false)
993
+ }
994
+ }
995
+
996
+ /** Load the commit history shown by the graph panel. */
997
+ const loadGraph = React.useCallback(async () => {
998
+ if (!sessionId) return
999
+ setGraphLoading(true)
1000
+ try {
1001
+ const value = await apiGet('log', { sessionId, limit: 80 })
1002
+ if (!aliveRef.current) return
1003
+ setGraph(value && value.available === true && Array.isArray(value.commits) ? value.commits : [])
1004
+ } catch (failure) {
1005
+ if (aliveRef.current) setGraph([])
1006
+ } finally {
1007
+ if (aliveRef.current) setGraphLoading(false)
1008
+ }
1009
+ }, [sessionId])
1010
+
1011
+ React.useEffect(() => {
1012
+ if (graphOpen && graph === null && !graphLoading) void loadGraph()
1013
+ }, [graphOpen, graph, graphLoading, loadGraph])
1014
+
1015
+ React.useEffect(() => {
1016
+ try {
1017
+ localStorage.setItem('dsh-git-changes.changeView', view)
1018
+ } catch (error) { /* storage unavailable */ }
1019
+ }, [view])
1020
+
1021
+ if (!sessionId) return null
1022
+ if (status && status.available === false) {
1023
+ const reason = status.reason === 'no-git'
1024
+ ? t('changes.noGit')
1025
+ : status.reason === 'not-git'
1026
+ ? t('changes.notGit')
1027
+ : status.reason === 'no-changes'
1028
+ ? t('changes.noChanges')
1029
+ : t('changes.unavailable')
1030
+ return h('div', { className: 'drp-changes-panel' }, h('div', { className: 'drp-changes-empty-state' }, reason))
1031
+ }
1032
+
1033
+ return h(
1034
+ 'div',
1035
+ { className: 'drp-changes-panel' },
1036
+ h(BranchBar, { branch: groups.branch, busy, view, onRefresh: () => void refresh(), onToggleView: () => setView((current) => (current === 'list' ? 'tree' : 'list')), t }),
1037
+ h(CommitBox, {
1038
+ value: message,
1039
+ branch: groups.branch,
1040
+ busy,
1041
+ generating,
1042
+ onChange: setMessage,
1043
+ onSubmit: (push) => void commit(push),
1044
+ onGenerate: () => void generate(),
1045
+ t,
1046
+ }),
1047
+ h(CommitButton, {
1048
+ busy,
1049
+ disabled: !canCommit,
1050
+ onCommit: (push) => void commit(push),
1051
+ onCommitPush: () => void commit(true),
1052
+ t,
1053
+ }),
1054
+ error ? h('div', { className: 'drp-changes-error' }, error) : null,
1055
+ loading && !status
1056
+ ? h('div', { className: 'drp-changes-loading' }, t('changes.loading'))
1057
+ : h(
1058
+ 'div',
1059
+ { className: 'drp-changes-groups' },
1060
+ h(ChangeSection, {
1061
+ scope: 'staged',
1062
+ files: groups.staged,
1063
+ collapsedDirs,
1064
+ view,
1065
+ collapsed: collapsed.staged,
1066
+ onToggleCollapsed: toggleCollapsed,
1067
+ onToggleDir: toggleDir,
1068
+ onStage: stage,
1069
+ onUnstage: unstage,
1070
+ onOpen: openChangeDiff,
1071
+ onOpenFile: typeof onOpenFile === 'function' ? onOpenFile : null,
1072
+ onRequestDiscard: requestDiscard,
1073
+ onContextMenu: (event, path, scope) => {
1074
+ event.preventDefault()
1075
+ setMenu({ x: event.clientX, y: event.clientY, path, scope })
1076
+ },
1077
+ activePath,
1078
+ scheme,
1079
+ t,
1080
+ }),
1081
+ h(ChangeSection, {
1082
+ scope: 'unstaged',
1083
+ files: groups.unstaged,
1084
+ collapsedDirs,
1085
+ view,
1086
+ collapsed: collapsed.unstaged,
1087
+ onToggleCollapsed: toggleCollapsed,
1088
+ onToggleDir: toggleDir,
1089
+ onStage: stage,
1090
+ onUnstage: unstage,
1091
+ onOpen: openChangeDiff,
1092
+ onOpenFile: typeof onOpenFile === 'function' ? onOpenFile : null,
1093
+ onRequestDiscard: requestDiscard,
1094
+ onContextMenu: (event, path, scope) => {
1095
+ event.preventDefault()
1096
+ setMenu({ x: event.clientX, y: event.clientY, path, scope })
1097
+ },
1098
+ activePath,
1099
+ scheme,
1100
+ t,
1101
+ }),
1102
+ ),
1103
+ SHOW_COMMIT_GRAPH && h(
1104
+ 'div',
1105
+ { className: 'drp-graph' },
1106
+ h(
1107
+ 'button',
1108
+ {
1109
+ type: 'button',
1110
+ className: 'drp-changes-section-head drp-graph-head',
1111
+ onClick: () => setGraphOpen((current) => !current),
1112
+ },
1113
+ h('span', { className: 'drp-graph-caret' }, graphOpen ? '⌄' : '›'),
1114
+ h('span', { className: 'drp-changes-section-title' }, t('changes.graph')),
1115
+ h('span', { className: 'drp-changes-count' }, graph === null ? '…' : String(graph.length)),
1116
+ ),
1117
+ graphOpen === true
1118
+ ? (graphLoading && graph === null
1119
+ ? h('div', { className: 'drp-changes-empty' }, t('changes.loading'))
1120
+ : (graph && graph.length > 0)
1121
+ ? h('div', { className: 'drp-graph-list' }, buildGraphRows(graph).map((row) => h(ChangeGraphRow, {
1122
+ key: row.sha,
1123
+ row,
1124
+ t,
1125
+ onOpen: (commit) => {
1126
+ if (typeof onOpenCommit === 'function') onOpenCommit(commit)
1127
+ },
1128
+ })))
1129
+ : h('div', { className: 'drp-changes-empty' }, t('changes.graphEmpty')))
1130
+ : null,
1131
+ ),
1132
+ pendingDiscard
1133
+ ? h(DiscardConfirm, {
1134
+ t,
1135
+ count: pendingDiscard.paths.length + pendingDiscard.untrackedPaths.length,
1136
+ onCancel: () => setPendingDiscard(null),
1137
+ onConfirm: confirmDiscard,
1138
+ })
1139
+ : null,
1140
+ h(Toast, { toast }),
1141
+ h(ChangeContextMenu, {
1142
+ menu,
1143
+ onClose: () => setMenu(null),
1144
+ onOpen: openChangeDiff,
1145
+ onStage: stage,
1146
+ onUnstage: unstage,
1147
+ onRequestDiscard: requestDiscard,
1148
+ onNotify: notify,
1149
+ t,
1150
+ }),
1151
+ )
1152
+ }
1153
+
1154
+ /**
1155
+ * Fire one Git write route and return its JSON payload.
1156
+ * @param {'stage'|'unstage'|'commit'|'push'} name route name
1157
+ * @param {object} body request body
1158
+ * @param {AbortSignal} [signal] cancellation
1159
+ * @returns {Promise<object | null>} payload
1160
+ */
1161
+ async function postGitReview(name, body, signal) {
1162
+ return apiPost(name, body || {}, signal)
1163
+ }