dsh-gufa-code 0.0.0-stage → 1.0.1

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 (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-ide.jpg +0 -0
  9. package/host/assets.js +31 -0
  10. package/host/chat.js +523 -0
  11. package/host/context.js +438 -0
  12. package/host/dev.js +35 -0
  13. package/host/fs.js +306 -0
  14. package/host/git.js +532 -0
  15. package/host/project.js +305 -0
  16. package/host/ssh.js +54 -0
  17. package/host/terminal-pty.js +237 -0
  18. package/host/terminal.js +947 -0
  19. package/icon.svg +18 -0
  20. package/index.js +63 -0
  21. package/locale/en.json +121 -0
  22. package/locale/zh.json +121 -0
  23. package/package.json +67 -4
  24. package/screenshots.json +3 -0
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. package/vendor/xterm/xterm.js +2 -0
package/host/git.js ADDED
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Git 能力:状态、暂存/取消暂存、提交、差异、日志。
3
+ * 用系统 git 命令(node:child_process),不需要 node 依赖。
4
+ */
5
+ import { execFile } from 'node:child_process'
6
+ import { promisify } from 'node:util'
7
+ import { ApiError, badRequest } from './context.js'
8
+
9
+ const run = promisify(execFile)
10
+ const MAX_BUFFER = 32 * 1024 * 1024
11
+
12
+ async function git(root, args, timeout = 30_000) {
13
+ try {
14
+ const { stdout } = await run('git', ['-C', root, ...args], {
15
+ maxBuffer: MAX_BUFFER,
16
+ timeout,
17
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '1', LC_ALL: 'C' },
18
+ })
19
+ return stdout
20
+ } catch (error) {
21
+ const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''
22
+ const stdout = typeof error?.stdout === 'string' ? error.stdout.trim() : ''
23
+ const raw = stderr !== '' ? stderr : stdout !== '' ? stdout : error instanceof Error ? error.message : String(error)
24
+ // Node 的 "Command failed: git -C ..." 前缀没有信息量,去掉后只留 git 自己的输出
25
+ const message = raw.replace(/^Command failed:[^\n]*\n?/u, '').trim() || raw
26
+ throw new ApiError('git-failed', message.slice(0, 4000), 400)
27
+ }
28
+ }
29
+
30
+ async function projectOf(env, body) {
31
+ return env.projects.ensure(body.root)
32
+ }
33
+
34
+ /** 打开项目后才 git init 的目录:动态探测并回填 isGit。 */
35
+ async function ensureRepo(env, body) {
36
+ const project = await projectOf(env, body)
37
+ if (project.isGit !== true) {
38
+ const inside = await git(project.root, ['rev-parse', '--is-inside-work-tree']).catch(() => '')
39
+ if (inside.trim() === 'true') project.isGit = true
40
+ }
41
+ return project
42
+ }
43
+
44
+ /** 解析 porcelain=v1 -z 输出(NUL 分隔、路径不转义,正确处理空格/中文/重命名)。 */
45
+ function parseStatusZ(text) {
46
+ const parts = text.split('\u0000')
47
+ let branch = ''
48
+ let upstream = ''
49
+ let ahead = 0
50
+ let behind = 0
51
+ const changes = []
52
+ for (let index = 0; index < parts.length; index += 1) {
53
+ const line = parts[index]
54
+ if (line === '') continue
55
+ if (line.startsWith('## ')) {
56
+ let head = line.slice(3)
57
+ if (head.startsWith('No commits yet on ')) head = head.slice('No commits yet on '.length)
58
+ const bracketIndex = head.indexOf(' [')
59
+ const core = bracketIndex === -1 ? head : head.slice(0, bracketIndex)
60
+ const dots = core.indexOf('...')
61
+ if (dots === -1) {
62
+ branch = core.trim()
63
+ upstream = ''
64
+ } else {
65
+ branch = core.slice(0, dots).trim()
66
+ upstream = core.slice(dots + 3).trim()
67
+ }
68
+ const aheadMatch = /ahead (\d+)/.exec(head)
69
+ const behindMatch = /behind (\d+)/.exec(head)
70
+ if (aheadMatch !== null) ahead = Number(aheadMatch[1])
71
+ if (behindMatch !== null) behind = Number(behindMatch[1])
72
+ continue
73
+ }
74
+ if (line.length < 4) continue
75
+ const x = line[0]
76
+ const y = line[1]
77
+ let path = line.slice(3)
78
+ if (x === 'R' || x === 'C') {
79
+ index += 1
80
+ const next = parts[index]
81
+ if (typeof next === 'string' && next !== '') path = next
82
+ }
83
+ const staged = x !== ' ' && x !== '?'
84
+ const untracked = x === '?' && y === '?'
85
+ let status = 'M'
86
+ if (untracked) status = '??'
87
+ else if (x === 'A') status = 'A'
88
+ else if (x === 'D' || y === 'D') status = 'D'
89
+ else if (x === 'R') status = 'R'
90
+ else if (x === 'U' || y === 'U') status = 'U'
91
+ changes.push({ path, status, staged: staged && !untracked, untracked })
92
+ }
93
+ return { branch, upstream, hasUpstream: upstream !== '', ahead, behind, changes }
94
+ }
95
+
96
+ /** 解析 porcelain=v1 输出(旧接口,保留兜底)。 */
97
+ function parseStatus(text) {
98
+ const lines = text.split('\n')
99
+ let branch = ''
100
+ let upstream = ''
101
+ let ahead = 0
102
+ let behind = 0
103
+ const changes = []
104
+ for (const line of lines) {
105
+ if (line.startsWith('## ')) {
106
+ let head = line.slice(3)
107
+ if (head.startsWith('No commits yet on ')) head = head.slice('No commits yet on '.length)
108
+ const bracketIndex = head.indexOf(' [')
109
+ const core = bracketIndex === -1 ? head : head.slice(0, bracketIndex)
110
+ const dots = core.indexOf('...')
111
+ if (dots === -1) {
112
+ branch = core.trim()
113
+ upstream = ''
114
+ } else {
115
+ branch = core.slice(0, dots).trim()
116
+ upstream = core.slice(dots + 3).trim()
117
+ }
118
+ const aheadMatch = /ahead (\d+)/.exec(head)
119
+ const behindMatch = /behind (\d+)/.exec(head)
120
+ if (aheadMatch !== null) ahead = Number(aheadMatch[1])
121
+ if (behindMatch !== null) behind = Number(behindMatch[1])
122
+ continue
123
+ }
124
+ if (line.length < 4) continue
125
+ const x = line[0]
126
+ const y = line[1]
127
+ const path = line.slice(3).trim()
128
+ const staged = x !== ' ' && x !== '?'
129
+ const untracked = x === '?' && y === '?'
130
+ let status = 'M'
131
+ if (untracked) status = '??'
132
+ else if (x === 'A') status = 'A'
133
+ else if (x === 'D' || y === 'D') status = 'D'
134
+ else if (x === 'R') status = 'R'
135
+ else if (x === 'U' || y === 'U') status = 'U'
136
+ changes.push({ path, status, staged: staged && !untracked, untracked })
137
+ }
138
+ return { branch, upstream, hasUpstream: upstream !== '', ahead, behind, changes }
139
+ }
140
+
141
+ export function register(router, env) {
142
+ router.method('git.status', async (body) => {
143
+ const project = await ensureRepo(env, body)
144
+ if (!project.isGit) return { isGit: false, branch: '', changes: [], ahead: 0, behind: 0 }
145
+ const raw = await git(project.root, ['status', '--porcelain=v1', '-z', '--branch', '--untracked-files=all'])
146
+ const parsed = parseStatusZ(raw)
147
+ if (parsed.changes.length > 0 || parsed.branch !== '') return { isGit: true, ...parsed }
148
+ return { isGit: true, ...parseStatus(await git(project.root, ['status', '--porcelain=v1', '--branch', '--untracked-files=all'])) }
149
+ })
150
+
151
+ router.method('git.stage', async (body) => {
152
+ const project = await projectOf(env, body)
153
+ const paths = Array.isArray(body.paths) ? body.paths.filter((item) => typeof item === 'string' && item !== '') : []
154
+ if (paths.length === 0) throw badRequest('paths is required')
155
+ await git(project.root, ['add', '--', ...paths])
156
+ return { ok: true }
157
+ })
158
+
159
+ router.method('git.unstage', async (body) => {
160
+ const project = await projectOf(env, body)
161
+ const paths = Array.isArray(body.paths) ? body.paths.filter((item) => typeof item === 'string' && item !== '') : []
162
+ if (paths.length === 0) throw badRequest('paths is required')
163
+ try {
164
+ await git(project.root, ['restore', '--staged', '--', ...paths])
165
+ } catch {
166
+ await git(project.root, ['reset', '-q', 'HEAD', '--', ...paths])
167
+ }
168
+ return { ok: true }
169
+ })
170
+
171
+ router.method('git.commit', async (body) => {
172
+ const project = await projectOf(env, body)
173
+ if (typeof body.message !== 'string' || body.message.trim() === '') throw badRequest('提交信息不能为空')
174
+ // 先检查暂存区:git commit 在没有暂存内容时只会把提示写到 stdout,直接透传会变成无意义的 "Command failed"
175
+ const staged = await git(project.root, ['diff', '--cached', '--name-only'])
176
+ if (staged.trim() === '') {
177
+ throw new ApiError('nothing-staged', '没有已暂存的改动:请先在「未暂存」里点 + 暂存,或点击「全部暂存」后再提交', 400)
178
+ }
179
+ const stdout = await git(project.root, ['commit', '-m', body.message])
180
+ return { ok: true, output: stdout.trim().slice(0, 4000) }
181
+ })
182
+
183
+ /** 当前分支名。 */
184
+ async function currentBranch(root) {
185
+ const name = (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
186
+ if (name === '' || name === 'HEAD') throw new ApiError('detached-head', '当前处于 detached HEAD 状态,无法推送/拉取', 400)
187
+ return name
188
+ }
189
+
190
+ /** 检查远端与上游配置;返回 { upstream, remote }。 */
191
+ async function upstreamOf(root) {
192
+ const remotes = (await git(root, ['remote']).catch(() => '')).trim()
193
+ if (remotes === '') throw new ApiError('no-remote', '仓库没有配置远端(git remote),无法推送/拉取', 400)
194
+ const remote = remotes.split('\n').map((line) => line.trim()).filter(Boolean)[0]
195
+ const upstream = (await git(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']).catch(() => '')).trim()
196
+ return { upstream, remote }
197
+ }
198
+
199
+ router.method('git.push', async (body) => {
200
+ const project = await projectOf(env, body)
201
+ const { upstream, remote } = await upstreamOf(project.root)
202
+ const args = upstream !== '' ? ['push'] : ['push', '-u', remote, await currentBranch(project.root)]
203
+ const stdout = await git(project.root, args, 120_000)
204
+ return { ok: true, output: stdout.trim().slice(0, 4000) }
205
+ })
206
+
207
+ router.method('git.pull', async (body) => {
208
+ const project = await projectOf(env, body)
209
+ const { upstream } = await upstreamOf(project.root)
210
+ if (upstream === '') throw new ApiError('no-upstream', '当前分支没有上游分支,请先推送一次建立上游', 400)
211
+ const stdout = await git(project.root, ['pull', '--ff-only'], 120_000)
212
+ return { ok: true, output: stdout.trim().slice(0, 4000) }
213
+ })
214
+
215
+ /** 读取某文件在指定版本下的文本内容(供编辑器 diff 使用)。 */
216
+ router.method('git.show', async (body, context = {}) => {
217
+ const project = await projectOf(env, body)
218
+ const path = typeof body.path === 'string' ? body.path : ''
219
+ if (path === '' || path.startsWith('/') || path.includes('..')) throw badRequest('path is invalid')
220
+ const rev = body.rev === 'head' ? 'HEAD' : ':'
221
+ let text = ''
222
+ let missing = false
223
+ try {
224
+ text = await git(project.root, ['show', `${rev}${path}`], 30_000)
225
+ } catch {
226
+ missing = true
227
+ text = ''
228
+ }
229
+ if (Buffer.byteLength(text, 'utf8') > 2 * 1024 * 1024) {
230
+ return { text: '', missing: false, binary: true, tooLarge: true }
231
+ }
232
+ const binary = text.includes('\u0000')
233
+ return { text: binary ? '' : text, missing, binary, tooLarge: false }
234
+ })
235
+
236
+ /** 撤销指定文件的未暂存改动(git restore --worktree,等价 VS Code 行内还原)。 */
237
+ /** 撤销指定文件的未暂存改动(git restore --worktree,等价 VS Code 行内还原)。 */
238
+ /** 解析 unified diff 文本为:文件头 + 逐 hunk 列表。 */
239
+ function parseUnifiedDiff(text) {
240
+ const lines = String(text).split('\n')
241
+ const headerLines = []
242
+ const hunks = []
243
+ let current = null
244
+ for (const line of lines) {
245
+ if (line.startsWith('@@')) {
246
+ current = { header: line, lines: [line] }
247
+ hunks.push(current)
248
+ continue
249
+ }
250
+ if (current === null) {
251
+ if (line !== '' || headerLines.length > 0) headerLines.push(line)
252
+ continue
253
+ }
254
+ current.lines.push(line)
255
+ }
256
+ /* 去掉末尾空行,避免构造 patch 时出问题 */
257
+ while (hunks.length > 0) {
258
+ const last = hunks[hunks.length - 1]
259
+ while (last.lines.length > 1 && last.lines[last.lines.length - 1] === '') last.lines.pop()
260
+ break
261
+ }
262
+ return { headerLines, hunks }
263
+ }
264
+
265
+ /** 用 stdin 把 patch 交给 git apply(支持 -R)。 */
266
+ function gitApplyWithStdin(root, args, patchText) {
267
+ return new Promise((resolve, reject) => {
268
+ const child = spawn('git', ['-C', root, 'apply', ...args], { stdio: ['pipe', 'pipe', 'pipe'] })
269
+ let stderr = ''
270
+ let stdout = ''
271
+ child.stdout.on('data', (chunk) => { stdout += String(chunk) })
272
+ child.stderr.on('data', (chunk) => { stderr += String(chunk) })
273
+ child.on('error', reject)
274
+ child.on('close', (code) => {
275
+ if (code === 0) resolve(stdout)
276
+ else reject(new Error(stderr.trim() || `git apply 失败(exit ${code})`))
277
+ })
278
+ try {
279
+ child.stdin.end(patchText)
280
+ } catch (error) {
281
+ reject(error)
282
+ }
283
+ })
284
+ }
285
+
286
+ /** 读取文件改动的逐块信息(供「逐块接受/拒绝」)。 */
287
+ router.method('git.hunks', async (body) => {
288
+ const project = await projectOf(env, body)
289
+ const path = typeof body.path === 'string' ? body.path : ''
290
+ if (path === '' || path.startsWith('/') || path.includes('..')) throw badRequest('path is invalid')
291
+ /* 未跟踪的新文件:整文件当作一个新增块 */
292
+ const status = await git(project.root, ['status', '--porcelain=v1', '-z', '--', path]).catch(() => '')
293
+ const classified = parseStatusZ(status)
294
+ const untracked = classified.changes.some((change) => change.untracked === true)
295
+ if (untracked) {
296
+ let content = ''
297
+ try {
298
+ content = await git(project.root, ['show', `:${path}`]).catch(() => '')
299
+ } catch {
300
+ content = ''
301
+ }
302
+ try {
303
+ const { readFile } = await import('node:fs/promises')
304
+ const { join } = await import('node:path')
305
+ content = await readFile(join(project.root, path), 'utf8')
306
+ } catch {
307
+ /* ignore */
308
+ }
309
+ const lines = content.split('\n')
310
+ return {
311
+ untracked: true,
312
+ hunks: [{ id: 'h0', header: '@@ 新文件 @@', lines: lines.map((line) => `+${line}`) }],
313
+ count: 1,
314
+ }
315
+ }
316
+ const patch = await git(project.root, ['diff', '-U3', '--no-color', '--', path], 60_000)
317
+ const parsed = parseUnifiedDiff(patch)
318
+ const hunks = parsed.hunks.map((hunk, index) => ({ id: `h${index}`, header: hunk.header, lines: hunk.lines }))
319
+ return { untracked: false, hunks, count: hunks.length }
320
+ })
321
+
322
+ /** 拒绝指定块(git apply -R 只回滚选中的 hunk)。 */
323
+ router.method('git.rejectHunks', async (body) => {
324
+ const project = await projectOf(env, body)
325
+ const path = typeof body.path === 'string' ? body.path : ''
326
+ if (path === '' || path.startsWith('/') || path.includes('..')) throw badRequest('path is invalid')
327
+ const ids = Array.isArray(body.hunkIds) ? body.hunkIds.filter((item) => typeof item === 'string') : []
328
+ if (ids.length === 0) throw badRequest('hunkIds is required')
329
+ const status = await git(project.root, ['status', '--porcelain=v1', '-z', '--', path]).catch(() => '')
330
+ const classified = parseStatusZ(status)
331
+ const untracked = classified.changes.some((change) => change.untracked === true)
332
+ if (untracked) {
333
+ if (ids.includes('h0')) {
334
+ const { rm } = await import('node:fs/promises')
335
+ const { join } = await import('node:path')
336
+ await rm(join(project.root, path), { force: true })
337
+ }
338
+ return { ok: true }
339
+ }
340
+ const patch = await git(project.root, ['diff', '-U3', '--no-color', '--', path], 60_000)
341
+ const parsed = parseUnifiedDiff(patch)
342
+ const selected = parsed.hunks.filter((hunk, index) => ids.includes(`h${index}`))
343
+ if (selected.length === 0) throw new ApiError('no-hunks', '没有匹配的改动块', 400)
344
+ const subPatch = `${parsed.headerLines.join('\n')}\n${selected.map((hunk) => hunk.lines.join('\n')).join('\n')}\n`
345
+ await gitApplyWithStdin(project.root, ['-R', '--whitespace=nowarn'], subPatch)
346
+ return { ok: true }
347
+ })
348
+
349
+ router.method('git.discard', async (body) => {
350
+ const project = await projectOf(env, body)
351
+ const paths = Array.isArray(body.paths) ? body.paths.filter((item) => typeof item === 'string' && item !== '' && !item.includes('..') && !item.startsWith('/')) : []
352
+ if (paths.length === 0) throw badRequest('paths is required')
353
+ try {
354
+ await git(project.root, ['restore', '--worktree', '--', ...paths], 60_000)
355
+ } catch {
356
+ await git(project.root, ['checkout', '--', ...paths], 60_000)
357
+ }
358
+ return { ok: true }
359
+ })
360
+
361
+ /** 撤回上一次提交,改动保留在暂存区(等价 git reset --soft HEAD~1;首个提交用 update-ref)。 */
362
+ router.method('git.undoLastCommit', async (body) => {
363
+ const project = await projectOf(env, body)
364
+ try {
365
+ await git(project.root, ['reset', '--soft', 'HEAD~1'], 60_000)
366
+ } catch (error) {
367
+ try {
368
+ await git(project.root, ['update-ref', '-d', 'HEAD'], 60_000)
369
+ } catch {
370
+ throw error
371
+ }
372
+ }
373
+ return { ok: true }
374
+ })
375
+
376
+ /** 撤销所有(未暂存 + 已暂存)的已跟踪文件改动;不删除未跟踪文件。 */
377
+ router.method('git.discardAll', async (body) => {
378
+ const project = await projectOf(env, body)
379
+ const staged = await git(project.root, ['diff', '--cached', '--name-only']).catch(() => '')
380
+ if (staged.trim() !== '') await git(project.root, ['restore', '--staged', '--', '.'], 60_000)
381
+ await git(project.root, ['restore', '--worktree', '--', '.'], 60_000).catch(async () => {
382
+ await git(project.root, ['checkout', '--', '.'], 60_000)
383
+ })
384
+ return { ok: true }
385
+ })
386
+
387
+ router.method('git.diff', async (body) => {
388
+ const project = await projectOf(env, body)
389
+ const args = ['diff', '--no-color', '--unified=3']
390
+ if (body.staged === true) args.push('--cached')
391
+ if (typeof body.path === 'string' && body.path !== '') args.push('--', body.path)
392
+ const patch = await git(project.root, args)
393
+ return { patch }
394
+ })
395
+
396
+ /** 某次提交的详情:提交信息 + 改动文件列表(name-status)。 */
397
+ router.method('git.commitFiles', async (body) => {
398
+ const project = await projectOf(env, body)
399
+ const hash = typeof body.hash === 'string' ? body.hash.trim() : ''
400
+ if (!/^[0-9a-f]{4,64}$/i.test(hash)) throw badRequest('hash is invalid')
401
+ const metaText = await git(project.root, ['show', '-s', '--format=%H%x1f%h%x1f%an%x1f%ar%x1f%P%x1f%s', hash], 60_000)
402
+ const meta = metaText.trim().split('\u001f')
403
+ const filesText = await git(project.root, ['show', '--name-status', '--format=', '--no-renames', hash], 60_000)
404
+ const files = filesText
405
+ .split('\n')
406
+ .filter((line) => line.trim() !== '')
407
+ .map((line) => {
408
+ const [status = 'M', ...rest] = line.split('\t')
409
+ return { status: status[0] ?? 'M', path: rest.join('\t') }
410
+ })
411
+ .filter((row) => row.path !== '')
412
+ return {
413
+ files,
414
+ commit: {
415
+ hash: meta[0] ?? hash,
416
+ short: meta[1] ?? hash.slice(0, 7),
417
+ author: meta[2] ?? '',
418
+ when: meta[3] ?? '',
419
+ parents: meta[4] !== undefined && meta[4] !== '' ? meta[4].split(' ').filter(Boolean) : [],
420
+ subject: meta[5] ?? '',
421
+ },
422
+ }
423
+ })
424
+
425
+ /** 读取某文件在指定提交(或其父提交)中的文本内容,供提交 diff 使用。 */
426
+ router.method('git.fileVersion', async (body) => {
427
+ const project = await projectOf(env, body)
428
+ const path = typeof body.path === 'string' ? body.path : ''
429
+ const hash = typeof body.hash === 'string' ? body.hash.trim() : ''
430
+ if (path === '' || path.startsWith('/') || path.includes('..')) throw badRequest('path is invalid')
431
+ if (!/^[0-9a-f]{4,64}$/i.test(hash)) throw badRequest('hash is invalid')
432
+ const ref = `${hash}${body.parent === true ? '^' : ''}:${path}`
433
+ let text = ''
434
+ let missing = false
435
+ try {
436
+ text = await git(project.root, ['show', ref], 60_000)
437
+ } catch {
438
+ missing = true
439
+ text = ''
440
+ }
441
+ if (Buffer.byteLength(text, 'utf8') > 2 * 1024 * 1024) {
442
+ return { text: '', missing: false, binary: true, tooLarge: true }
443
+ }
444
+ const binary = text.includes('\u0000')
445
+ return { text: binary ? '' : text, missing, binary, tooLarge: false }
446
+ })
447
+
448
+ /** 提交图数据:hash / 父提交 / 作者 / 相对时间 / 引用装饰 / 标题。 */
449
+ router.method('git.graph', async (body) => {
450
+ const project = await projectOf(env, body)
451
+ const limit = Number.isInteger(body.limit) && body.limit > 0 ? Math.min(body.limit, 300) : 120
452
+ const args = ['log', `-n${limit}`, '--date=relative', '--format=%H%x1f%h%x1f%P%x1f%an%x1f%ar%x1f%D%x1f%s', '--decorate=short']
453
+ if (body.all !== false) args.push('--all')
454
+ let text = ''
455
+ try {
456
+ text = await git(project.root, args, 60_000)
457
+ } catch (error) {
458
+ return { commits: [], branch: '', error: error instanceof Error ? error.message : String(error) }
459
+ }
460
+ const commits = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
461
+ const [hash = '', short = '', parentsRaw = '', author = '', when = '', refsRaw = '', ...rest] = line.split('\u001f')
462
+ return {
463
+ hash,
464
+ short,
465
+ parents: parentsRaw.trim() === '' ? [] : parentsRaw.trim().split(' ').filter(Boolean),
466
+ author,
467
+ when,
468
+ refs: refsRaw.trim() === '' ? [] : refsRaw.split(',').map((item) => item.trim()).filter(Boolean),
469
+ subject: rest.join('\u001f'),
470
+ }
471
+ })
472
+ const branch = (await git(project.root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim()
473
+ return { commits, branch, error: '' }
474
+ })
475
+
476
+ router.method('git.log', async (body) => {
477
+ const project = await projectOf(env, body)
478
+ const limit = Number.isInteger(body.limit) && body.limit > 0 ? Math.min(body.limit, 100) : 30
479
+ const format = '%H%x1f%h%x1f%an%x1f%ar%x1f%s'
480
+ const stdout = await git(project.root, ['log', `-n${limit}`, `--format=${format}`])
481
+ const commits = stdout.split('\n').filter(Boolean).map((line) => {
482
+ const [hash, short, author, when, ...rest] = line.split('\x1f')
483
+ return { hash, short, author, when, subject: rest.join('\x1f') }
484
+ })
485
+ return { commits }
486
+ })
487
+
488
+ router.method('git.branches', async (body) => {
489
+ const project = await projectOf(env, body)
490
+ // 注意:%x1f 是 git log 的转义,ref-filter(branch/for-each-ref 的 --format)不支持,
491
+ // 因此这里用 for-each-ref 拿名字 + rev-parse 判断当前分支,不要拼接格式转义。
492
+ const stdout = await git(project.root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
493
+ const current = (await git(project.root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => '')).trim()
494
+ const branches = stdout
495
+ .split('\n')
496
+ .map((line) => line.trim())
497
+ .filter((name) => name !== '')
498
+ .map((name) => ({ name, current: name === current }))
499
+ return { branches }
500
+ })
501
+
502
+ /** 新建并切换到新分支。 */
503
+ router.method('git.createBranch', async (body) => {
504
+ const project = await projectOf(env, body)
505
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
506
+ if (name === '') throw badRequest('分支名不能为空')
507
+ await git(project.root, ['check-ref-format', '--branch', name]).catch(() => {
508
+ throw new ApiError('branch-invalid', `分支名不合法:${name}`, 400)
509
+ })
510
+ const exists = (await git(project.root, ['branch', '--list', '--format=%(refname:short)', name]).catch(() => '')).trim()
511
+ if (exists !== '') throw new ApiError('branch-exists', `分支已存在:${name}`, 409)
512
+ try {
513
+ await git(project.root, ['switch', '-c', name], 60_000)
514
+ } catch {
515
+ await git(project.root, ['checkout', '-b', name], 60_000)
516
+ }
517
+ return { ok: true, branch: name }
518
+ })
519
+
520
+ router.method('git.checkout', async (body) => {
521
+ const project = await projectOf(env, body)
522
+ if (typeof body.branch !== 'string' || body.branch === '') throw badRequest('branch is required')
523
+ await git(project.root, ['checkout', body.branch])
524
+ return { ok: true }
525
+ })
526
+
527
+ router.method('git.isRepo', async (body) => {
528
+ const project = env.projects.ensure(body.root)
529
+ const text = await git(project.root, ['rev-parse', '--is-inside-work-tree']).catch(() => '')
530
+ return { isGit: text.trim() === 'true' }
531
+ })
532
+ }