dsh-git-changes 0.0.0-stage → 0.1.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 (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +119 -0
  3. package/README.md +137 -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/docs/regression-checklist.md +47 -0
  107. package/icon.svg +10 -0
  108. package/lib/client.js +9418 -0
  109. package/lib/host/assets.js +161 -0
  110. package/lib/host/commit-message.js +158 -0
  111. package/lib/host/files-index.js +206 -0
  112. package/lib/host/git-write.js +194 -0
  113. package/lib/host/git.js +1147 -0
  114. package/lib/host/lsp.js +842 -0
  115. package/lib/host/routes.js +976 -0
  116. package/lib/host/session.js +84 -0
  117. package/lib/host/util.js +107 -0
  118. package/lib/index.js +33 -0
  119. package/licenses/dsh-vscode-mode-LICENSE +21 -0
  120. package/licenses/vscode-theme-seti-LICENSE +21 -0
  121. package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
  122. package/locale/en.json +6 -0
  123. package/locale/zh.json +6 -0
  124. package/package.json +65 -4
  125. package/src/client/00-const.js +79 -0
  126. package/src/client/05-utils.js +277 -0
  127. package/src/client/10-i18n.js +548 -0
  128. package/src/client/12-vscode-icons.js +460 -0
  129. package/src/client/15-theme.js +509 -0
  130. package/src/client/20-monaco.js +1404 -0
  131. package/src/client/22-lsp.js +592 -0
  132. package/src/client/25-tree.js +563 -0
  133. package/src/client/28-changes.js +1163 -0
  134. package/src/client/30-page.js +2259 -0
  135. package/src/client/35-pill.js +185 -0
  136. package/src/client/40-commands.js +248 -0
  137. package/src/client/45-css.js +357 -0
  138. package/src/client/47-pickers.js +457 -0
  139. package/src/client/50-apply.js +207 -0
  140. package/src/client/99-footer.js +62 -0
@@ -0,0 +1,976 @@
1
+ /**
2
+ * Authenticated JSON routes for the inline diff review tab. Every GET route is
3
+ * read-only; the four git write routes (stage / unstage / commit / push) mutate
4
+ * only the repository index and commits.
5
+ *
6
+ * All routes live under Connection's `/api/` Fetch channel
7
+ * (`ctx.connection.fetch.register`), so they inherit the browser
8
+ * authentication fence: untrusted Host/Origin → 403, unauthenticated → 401,
9
+ * non-owner peer → 403. The browser half calls them with a document-relative
10
+ * URL (leading slash stripped) so desktop `dsh-app:` documents resolve them.
11
+ */
12
+ import { coordinate, failure, identifier, jsonResponse, publicSummary, safeRelativePath, safeRevision } from './util.js'
13
+ import { createChangesTracker, cwdOf, lastChangesEvent, liveSession } from './session.js'
14
+ import { createGitProvider, listGitRevisions, readGitCompare, readGitContentAt, readGitDiff, readGitFile, readGitFileAgainst, readGitStatus, repositoryRoot, readGitBlame, readGitHistory, readCommitInfo } from './git.js'
15
+ import { createWriteGit, stagePaths, unstagePaths, commitChanges, pushCurrentBranch, discardPaths } from './git-write.js'
16
+ import { generateCommitMessage } from './commit-message.js'
17
+ import { WorkspaceIndex, searchWorkspace } from './files-index.js'
18
+ import { LspBridge } from './lsp.js'
19
+ import { posix, resolve, win32 } from 'node:path'
20
+ import { realpath } from 'node:fs/promises'
21
+
22
+ /** Exact Fetch paths; `/api/` prefix is mandatory for the connection fence. */
23
+ export const ROUTES = {
24
+ latest: '/api/git-changes.latest',
25
+ summary: '/api/git-changes.summary',
26
+ diff: '/api/git-changes.diff',
27
+ file: '/api/git-changes.file',
28
+ tree: '/api/git-changes.tree',
29
+ config: '/api/git-changes.config',
30
+ gitStatus: '/api/git-changes.git-status',
31
+ gitFile: '/api/git-changes.git-file',
32
+ gitRevisions: '/api/git-changes.git-revisions',
33
+ gitCompare: '/api/git-changes.git-compare',
34
+ files: '/api/git-changes.files',
35
+ search: '/api/git-changes.search',
36
+ lsp: '/api/git-changes.lsp',
37
+ gitDiff: '/api/git-changes.git-diff',
38
+ stage: '/api/git-changes.stage',
39
+ unstage: '/api/git-changes.unstage',
40
+ commit: '/api/git-changes.commit',
41
+ push: '/api/git-changes.push',
42
+ discard: '/api/git-changes.discard',
43
+ blame: '/api/git-changes.blame',
44
+ log: '/api/git-changes.log',
45
+ generateMessage: '/api/git-changes.generate-message',
46
+ commitInfo: '/api/git-changes.commit-info',
47
+ }
48
+
49
+ /** LSP actions the browser may invoke (read-only language features). */
50
+ const LSP_ACTIONS = new Set(['status', 'definition', 'references', 'hover', 'symbols', 'diagnostics'])
51
+ /** Longest document text accepted for LSP sync. */
52
+ const LSP_MAX_TEXT_BYTES = 2 * 1024 * 1024
53
+
54
+ /** Text files above this size are reported as oversized instead of being read. */
55
+ const MAX_FILE_BYTES = 2 * 1024 * 1024
56
+
57
+ /**
58
+ * Join a workspace-relative posix path with the native separator.
59
+ * @param {string} rel posix relative path
60
+ * @returns {string} native relative path
61
+ */
62
+ function nativePath(rel) {
63
+ return process.platform === 'win32' ? rel.split('/').join('\\') : rel
64
+ }
65
+
66
+ /**
67
+ * Whether the absolute path stays inside the workspace root.
68
+ * @param {string} root absolute workspace root
69
+ * @param {string} target absolute candidate
70
+ * @returns {boolean}
71
+ */
72
+ function withinRoot(root, target) {
73
+ if (typeof root !== 'string' || typeof target !== 'string') return false
74
+ const windows = /^[a-z]:[\\/]/i.test(root) || root.startsWith('\\\\') || root.startsWith('//')
75
+ const paths = windows ? win32 : posix
76
+ if (!paths.isAbsolute(root) || !paths.isAbsolute(target)) return false
77
+ const rel = paths.relative(root, target)
78
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + paths.sep) && !paths.isAbsolute(rel))
79
+ }
80
+
81
+ /**
82
+ * Whether a byte prefix looks binary (NUL byte or many undecodable control bytes).
83
+ * @param {Uint8Array | undefined} data bytes
84
+ * @returns {boolean}
85
+ */
86
+ function looksBinary(data) {
87
+ if (!(data instanceof Uint8Array)) return false
88
+ const limit = Math.min(data.byteLength, 8192)
89
+ for (let index = 0; index < limit; index += 1) {
90
+ if (data[index] === 0) return true
91
+ }
92
+ return false
93
+ }
94
+
95
+ /**
96
+ * Count lines of a decoded text payload.
97
+ * @param {string} text decoded text
98
+ * @returns {number}
99
+ */
100
+ function countLines(text) {
101
+ if (text.length === 0) return 0
102
+ let lines = 1
103
+ for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) lines += 1
104
+ return text.endsWith('\n') ? lines - 1 : lines
105
+ }
106
+
107
+ /**
108
+ * Latest turn announcement for a session, with its summary when the Host still
109
+ * serves it (it does not survive a Host restart).
110
+ * @param {object} ctx host context
111
+ * @param {WeakMap<object, any>} tracker live announcement tracker
112
+ * @param {string} sessionId session identity
113
+ * @returns {Promise<Response>}
114
+ */
115
+ async function handleLatest(ctx, tracker, sessionId) {
116
+ const session = liveSession(ctx, sessionId)
117
+ if (!session) return jsonResponse({ available: false, reason: 'session-not-live' })
118
+ const cwd = cwdOf(session)
119
+ if (!cwd) return jsonResponse({ available: false, reason: 'no-cwd' })
120
+ const event = tracker.get(session) ?? lastChangesEvent(session)
121
+ if (!event) return jsonResponse({ available: false, reason: 'no-changes', cwd })
122
+ const seq = Number(event.seq)
123
+ const turn = Number(event.data.turn)
124
+ let summary
125
+ try {
126
+ summary = ctx.workspaceChanges.summary(sessionId, seq)
127
+ } catch {
128
+ summary = undefined
129
+ }
130
+ if (!summary) return jsonResponse({ available: false, reason: 'summary-unavailable', seq, turn, cwd })
131
+ return jsonResponse({ available: true, seq, turn, cwd, summary: publicSummary(summary) })
132
+ }
133
+
134
+ /**
135
+ * Serve one announced turn summary.
136
+ * @param {object} ctx host context
137
+ * @param {Request} request Fetch request
138
+ * @returns {Promise<Response>}
139
+ */
140
+ async function handleSummary(ctx, request) {
141
+ const query = new URL(request.url).searchParams
142
+ const sessionId = identifier(query.get('sessionId'))
143
+ const seq = coordinate(query.get('seq'))
144
+ if (!sessionId || seq === undefined) return failure('invalid-coordinates', 'Invalid change summary coordinates.', 400)
145
+ if (!liveSession(ctx, sessionId)) return failure('session-not-live', 'Change summary unavailable.', 404)
146
+ let summary
147
+ try {
148
+ summary = ctx.workspaceChanges.summary(sessionId, seq)
149
+ } catch {
150
+ summary = undefined
151
+ }
152
+ if (!summary) return failure('summary-unavailable', 'Change summary unavailable.', 404)
153
+ return jsonResponse({ seq, ...publicSummary(summary) })
154
+ }
155
+
156
+ /**
157
+ * Serve one listed file's turn-start/turn-end comparison.
158
+ * @param {object} ctx host context
159
+ * @param {Request} request Fetch request
160
+ * @returns {Promise<Response>}
161
+ */
162
+ async function handleDiff(ctx, request) {
163
+ const query = new URL(request.url).searchParams
164
+ const sessionId = identifier(query.get('sessionId'))
165
+ const seq = coordinate(query.get('seq'))
166
+ const index = coordinate(query.get('index'))
167
+ if (!sessionId || seq === undefined || index === undefined) {
168
+ return failure('invalid-coordinates', 'Invalid changed file coordinates.', 400)
169
+ }
170
+ if (!liveSession(ctx, sessionId)) return failure('session-not-live', 'Change comparison unavailable.', 404)
171
+ try {
172
+ const diff = await ctx.workspaceChanges.diff(sessionId, seq, index, request.signal)
173
+ if (!diff) return failure('diff-unavailable', 'Change comparison unavailable.', 404)
174
+ return jsonResponse(diff)
175
+ } catch (error) {
176
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
177
+ return failure('diff-failed', 'Change comparison unavailable.', 404)
178
+ }
179
+ }
180
+
181
+ /** Map file-service errors without treating permission or I/O failures as deletion. */
182
+ function fileReadFailure(error, signal, operation) {
183
+ if (signal?.aborted) return failure('aborted', 'Request aborted.', 499)
184
+ const codes = new Set([error?.code, error?.cause?.code])
185
+ if (['ENOENT', 'ENOTDIR', 'FS_NOT_FOUND', 'not-found'].some((code) => codes.has(code))) {
186
+ return failure('not-found', 'File unavailable.', 404)
187
+ }
188
+ if (['EACCES', 'EPERM', 'FS_PERMISSION_DENIED', 'permission-denied'].some((code) => codes.has(code))) {
189
+ return failure('permission-denied', 'File access denied.', 403)
190
+ }
191
+ return failure(operation + '-failed', 'File content unavailable.', 502)
192
+ }
193
+
194
+ /**
195
+ * Serve one complete current file (or one file as committed at a revision).
196
+ * @param {object} ctx host context
197
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
198
+ * @param {Request} request Fetch request
199
+ * @returns {Promise<Response>}
200
+ */
201
+ async function handleFile(ctx, gitProvider, request) {
202
+ const query = new URL(request.url).searchParams
203
+ const sessionId = identifier(query.get('sessionId'))
204
+ const rel = query.get('path') ?? ''
205
+ if (!sessionId) return failure('invalid-coordinates', 'Missing session id.', 400)
206
+ if (!safeRelativePath(rel)) return failure('invalid-path', 'Invalid workspace path.', 400)
207
+ const session = liveSession(ctx, sessionId)
208
+ if (!session) return failure('session-not-live', 'Session is not live on this Host.', 404)
209
+ const cwd = cwdOf(session)
210
+ if (!cwd) return failure('no-cwd', 'Session has no working directory.', 404)
211
+ const rawRevision = query.get('rev')
212
+ if (rawRevision !== null) {
213
+ const rev = safeRevision(rawRevision)
214
+ if (!rev) return failure('invalid-revision', 'Invalid git revision.', 400)
215
+ try {
216
+ const content = await readGitContentAt(ctx, gitProvider, { cwd, path: rel, rev, signal: request.signal })
217
+ if (content?.available === false) {
218
+ if (content.reason === 'unknown-revision') return failure('invalid-revision', 'Unknown git revision.', 400)
219
+ if (content.reason === 'file-unavailable') return failure('not-found', 'File unavailable.', 404)
220
+ return jsonResponse(content)
221
+ }
222
+ if (content.kind === 'text') {
223
+ return jsonResponse({
224
+ kind: 'text',
225
+ path: rel,
226
+ display: rel,
227
+ text: content.text,
228
+ lines: content.lines,
229
+ bytes: content.bytes,
230
+ version: content.version,
231
+ absolutePath: content.absolutePath,
232
+ })
233
+ }
234
+ return jsonResponse({ kind: content.kind, path: rel, display: rel, bytes: content.bytes, absolutePath: content.absolutePath })
235
+ } catch (error) {
236
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
237
+ return failure('read-failed', 'File content unavailable.', 502)
238
+ }
239
+ }
240
+ const scope = { sessionId, workspaceRoot: cwd }
241
+ const target = nativePath(rel)
242
+ let stat
243
+ try {
244
+ stat = await ctx.workspaceFiles.stat(scope, target, request.signal)
245
+ } catch (error) {
246
+ return fileReadFailure(error, request.signal, 'stat')
247
+ }
248
+ if (!stat || typeof stat.absolutePath !== 'string') return failure('not-found', 'File unavailable.', 404)
249
+ if (!withinRoot(cwd, stat.absolutePath)) {
250
+ return failure('outside-workspace', 'File is outside the session workspace.', 403)
251
+ }
252
+ if (typeof stat.bytes === 'number' && stat.bytes > MAX_FILE_BYTES) {
253
+ return jsonResponse({ kind: 'oversized', path: rel, display: rel, bytes: stat.bytes, absolutePath: stat.absolutePath })
254
+ }
255
+ let read
256
+ try {
257
+ read = await ctx.workspaceFiles.readBytes(scope, target, {}, request.signal)
258
+ } catch (error) {
259
+ return fileReadFailure(error, request.signal, 'read')
260
+ }
261
+ if (!read || !(read.data instanceof Uint8Array)) return failure('read-failed', 'File content unavailable.', 502)
262
+ if (looksBinary(read.data)) {
263
+ return jsonResponse({ kind: 'binary', path: rel, display: rel, bytes: read.data.byteLength, absolutePath: stat.absolutePath })
264
+ }
265
+ const text = new TextDecoder('utf-8', { fatal: false }).decode(read.data)
266
+ return jsonResponse({
267
+ kind: 'text',
268
+ path: rel,
269
+ display: rel,
270
+ text,
271
+ lines: countLines(text),
272
+ bytes: read.data.byteLength,
273
+ version: read.version ?? stat.version,
274
+ absolutePath: stat.absolutePath,
275
+ })
276
+ }
277
+
278
+ /**
279
+ * Serve one directory's direct children for the lazy workspace tree.
280
+ * @param {object} ctx host context
281
+ * @param {Request} request Fetch request
282
+ * @returns {Promise<Response>}
283
+ */
284
+ async function handleTree(ctx, request) {
285
+ const query = new URL(request.url).searchParams
286
+ const sessionId = identifier(query.get('sessionId'))
287
+ const rel = query.get('path') ?? ''
288
+ if (!sessionId) return failure('invalid-coordinates', 'Missing session id.', 400)
289
+ if (rel !== '' && !safeRelativePath(rel)) return failure('invalid-path', 'Invalid workspace path.', 400)
290
+ const session = liveSession(ctx, sessionId)
291
+ if (!session) return failure('session-not-live', 'Session is not live on this Host.', 404)
292
+ const cwd = cwdOf(session)
293
+ if (!cwd) return failure('no-cwd', 'Session has no working directory.', 404)
294
+ const scope = { sessionId, workspaceRoot: cwd }
295
+ const target = rel === '' ? cwd : nativePath(rel)
296
+ let listing
297
+ try {
298
+ listing = await ctx.workspaceFiles.list(scope, target, request.signal)
299
+ } catch (error) {
300
+ return failure('list-failed', 'Directory unavailable.', 404)
301
+ }
302
+ if (!listing || !Array.isArray(listing.entries)) return failure('not-found', 'Directory unavailable.', 404)
303
+ return jsonResponse({
304
+ path: rel,
305
+ truncated: listing.truncated === true,
306
+ entries: listing.entries.map((entry) => ({
307
+ name: String(entry.name),
308
+ type: entry.type === 'directory' ? 'directory' : entry.type === 'file' ? 'file' : 'other',
309
+ ...(typeof entry.size === 'number' ? { size: entry.size } : {}),
310
+ })),
311
+ })
312
+ }
313
+
314
+ /**
315
+ * Resolve the live session and its working directory from a request query.
316
+ * @param {object} ctx host context
317
+ * @param {Request} request fetch request
318
+ * @returns {{sessionId:string, cwd:string} | Response}
319
+ */
320
+ function sessionCoordinates(ctx, request) {
321
+ const sessionId = identifier(new URL(request.url).searchParams.get('sessionId'))
322
+ if (!sessionId) return failure('invalid-coordinates', 'Missing session id.', 400)
323
+ const session = liveSession(ctx, sessionId)
324
+ if (!session) return failure('session-not-live', 'Session is not live on this Host.', 404)
325
+ const cwd = cwdOf(session)
326
+ if (!cwd) return failure('no-cwd', 'Session has no working directory.', 404)
327
+ return { sessionId, cwd }
328
+ }
329
+
330
+ /**
331
+ * Parse a JSON request body, mapping malformed payloads onto a 400 response.
332
+ * @param {Request} request fetch request
333
+ * @returns {Promise<{ok:true, body:object} | {ok:false, response:Response}>}
334
+ */
335
+ async function readJsonBody(request) {
336
+ let body
337
+ try {
338
+ body = await request.json()
339
+ } catch {
340
+ return { ok: false, response: failure('invalid-body', 'Invalid JSON body.', 400) }
341
+ }
342
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
343
+ return { ok: false, response: failure('invalid-body', 'Invalid JSON body.', 400) }
344
+ }
345
+ return { ok: true, body }
346
+ }
347
+
348
+ /**
349
+ * Resolve session cwd and repository root for a write request.
350
+ * @param {object} ctx host context
351
+ * @param {Request} request fetch request
352
+ * @param {object} body parsed request body
353
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
354
+ * @returns {Promise<{sessionId:string, cwd:string, root:string, git:object} | Response>}
355
+ */
356
+ async function writeCoordinates(ctx, request, body, gitProvider) {
357
+ const sessionId = identifier(body && body.sessionId) ?? identifier(new URL(request.url).searchParams.get('sessionId'))
358
+ if (!sessionId) return failure('invalid-coordinates', 'Missing session id.', 400)
359
+ const session = liveSession(ctx, sessionId)
360
+ if (!session) return failure('session-not-live', 'Session is not live on this Host.', 404)
361
+ const cwd = cwdOf(session)
362
+ if (!cwd) return failure('no-cwd', 'Session has no working directory.', 404)
363
+ const git = await gitProvider()
364
+ if (!git) return jsonResponse({ available: false, reason: 'no-git' })
365
+ let root
366
+ try {
367
+ root = await repositoryRoot(git, cwd, request.signal)
368
+ } catch {
369
+ root = null
370
+ }
371
+ if (!root) return jsonResponse({ available: false, reason: 'not-git' })
372
+ return { sessionId, cwd, root, git }
373
+ }
374
+
375
+ /**
376
+ * Validate the client-provided path list against the session cwd and repo root.
377
+ * @param {unknown} value raw `paths` payload
378
+ * @param {string} cwd session working directory
379
+ * @param {string} root repository root
380
+ * @returns {Promise<string[] | null>} cwd-relative paths, or null when any entry is unsafe
381
+ */
382
+ async function safeWritePaths(value, cwd, root) {
383
+ if (!Array.isArray(value) || value.length === 0) return null
384
+ if (value.length > 500) return null
385
+ let canonicalCwd
386
+ let canonicalRoot
387
+ try {
388
+ ;[canonicalCwd, canonicalRoot] = await Promise.all([realpath(cwd), realpath(root)])
389
+ } catch {
390
+ return null
391
+ }
392
+ const result = []
393
+ for (const raw of value) {
394
+ const relative = typeof raw === 'string' ? raw : ''
395
+ if (!safeRelativePath(relative)) return null
396
+ if (!withinRoot(canonicalRoot, resolve(canonicalCwd, relative))) return null
397
+ result.push(relative)
398
+ }
399
+ return result
400
+ }
401
+
402
+ /**
403
+ * Read the latest status payload after a successful write.
404
+ * @param {object} ctx host context
405
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
406
+ * @param {string} cwd session working directory
407
+ * @param {AbortSignal} signal request signal
408
+ * @returns {Promise<object>}
409
+ */
410
+ async function freshStatus(ctx, gitProvider, cwd, signal) {
411
+ try {
412
+ return await readGitStatus(ctx, gitProvider, cwd, signal)
413
+ } catch (error) {
414
+ return { available: false, reason: 'status-failed', detail: String(error?.message || error).slice(0, 400) }
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Stage or unstage every change inside the session directory.
420
+ * @param {object} ctx host context
421
+ * @param {Request} request fetch request
422
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
423
+ * @param {'stage'|'unstage'} kind operation
424
+ * @returns {Promise<Response>}
425
+ */
426
+ async function handleGitReviewWrite(ctx, request, gitProvider, kind) {
427
+ const parsed = await readJsonBody(request)
428
+ if (parsed.ok === false) return parsed.response
429
+ const body = parsed.body
430
+ const coordinates = await writeCoordinates(ctx, request, body, gitProvider)
431
+ if (coordinates instanceof Response) return coordinates
432
+ const all = body.all === true
433
+ let paths
434
+ if (all) {
435
+ paths = null
436
+ } else {
437
+ paths = await safeWritePaths(body.paths, coordinates.cwd, coordinates.root)
438
+ if (paths === null) {
439
+ if (Array.isArray(body.paths) && body.paths.length === 0) return failure('empty-selection', 'No files were selected.', 400)
440
+ return failure('invalid-path', 'Invalid workspace path.', 400)
441
+ }
442
+ }
443
+ const git = await createWriteGit(ctx, coordinates.git.executable)()
444
+ let result
445
+ try {
446
+ const input = { cwd: coordinates.cwd, paths, all, signal: request.signal }
447
+ result = kind === 'stage' ? await stagePaths(git, input) : await unstagePaths(git, input)
448
+ } catch (error) {
449
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
450
+ return failure('write-failed', String(error?.message || error).slice(0, 400), 500)
451
+ }
452
+ if (result.ok !== true) return jsonResponse({ ok: false, code: result.code, detail: result.detail }, 409)
453
+ return jsonResponse({ ok: true, status: await freshStatus(ctx, gitProvider, coordinates.cwd, request.signal) })
454
+ }
455
+
456
+ /**
457
+ * Discard the working-tree or staged changes for the selected paths.
458
+ * Untracked paths are deleted; every other path is restored from the index
459
+ * (unstaged scope) or from HEAD (staged scope). Destructive: the client asks
460
+ * for confirmation before calling this route.
461
+ * @param {object} ctx host context
462
+ * @param {Request} request fetch request
463
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
464
+ * @returns {Promise<Response>}
465
+ */
466
+ async function handleGitReviewDiscard(ctx, request, gitProvider) {
467
+ const parsed = await readJsonBody(request)
468
+ if (parsed.ok === false) return parsed.response
469
+ const body = parsed.body
470
+ const coordinates = await writeCoordinates(ctx, request, body, gitProvider)
471
+ if (coordinates instanceof Response) return coordinates
472
+ const scope = body.scope === 'staged' ? 'staged' : 'unstaged'
473
+ const paths = await safeWritePaths(body.paths, coordinates.cwd, coordinates.root)
474
+ if (paths === null) {
475
+ if (Array.isArray(body.paths) && body.paths.length === 0) return failure('empty-selection', 'No files were selected.', 400)
476
+ return failure('invalid-path', 'Invalid workspace path.', 400)
477
+ }
478
+ let untrackedPaths = []
479
+ if (Array.isArray(body.untrackedPaths) && body.untrackedPaths.length > 0) {
480
+ const untracked = await safeWritePaths(body.untrackedPaths, coordinates.cwd, coordinates.root)
481
+ if (untracked === null) return failure('invalid-path', 'Invalid workspace path.', 400)
482
+ untrackedPaths = untracked
483
+ }
484
+ const git = await createWriteGit(ctx, coordinates.git.executable)()
485
+ let result
486
+ try {
487
+ result = await discardPaths(git, {
488
+ cwd: coordinates.cwd,
489
+ paths,
490
+ untrackedPaths,
491
+ scope,
492
+ signal: request.signal,
493
+ })
494
+ } catch (error) {
495
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
496
+ return failure('write-failed', String(error?.message || error).slice(0, 400), 500)
497
+ }
498
+ if (result.ok !== true) {
499
+ if (result.code === 'empty-selection') return failure('empty-selection', 'No files were selected.', 400)
500
+ return jsonResponse({ ok: false, code: result.code, detail: result.detail }, 409)
501
+ }
502
+ return jsonResponse({ ok: true, status: await freshStatus(ctx, gitProvider, coordinates.cwd, request.signal) })
503
+ }
504
+
505
+ /**
506
+ * Commit the current index, optionally pushing the current branch afterwards.
507
+ * @param {object} ctx host context
508
+ * @param {Request} request fetch request
509
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
510
+ * @returns {Promise<Response>}
511
+ */
512
+ async function handleGitReviewCommit(ctx, request, gitProvider) {
513
+ const parsed = await readJsonBody(request)
514
+ if (parsed.ok === false) return parsed.response
515
+ const body = parsed.body
516
+ const coordinates = await writeCoordinates(ctx, request, body, gitProvider)
517
+ if (coordinates instanceof Response) return coordinates
518
+ const message = typeof body.message === 'string' ? body.message : ''
519
+ const trimmed = message.trim()
520
+ if (trimmed.length === 0 || trimmed.length > 5000 || trimmed.startsWith('-')) {
521
+ return failure('invalid-message', 'Commit message must be 1-5000 characters and must not start with "-".', 400)
522
+ }
523
+ const git = await createWriteGit(ctx, coordinates.git.executable)()
524
+ let result
525
+ try {
526
+ result = await commitChanges(git, { cwd: coordinates.cwd, message: trimmed, signal: request.signal })
527
+ } catch (error) {
528
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
529
+ return failure('write-failed', String(error?.message || error).slice(0, 400), 500)
530
+ }
531
+ if (result.ok !== true) return jsonResponse({ ok: false, code: result.code, detail: result.detail }, 409)
532
+ let pushed = false
533
+ let pushCode
534
+ if (body.push === true) {
535
+ try {
536
+ const pushedResult = await pushCurrentBranch(git, { cwd: coordinates.cwd, signal: request.signal })
537
+ pushed = pushedResult.ok === true
538
+ if (!pushed) pushCode = pushedResult.code
539
+ } catch (error) {
540
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
541
+ pushCode = 'push-failed'
542
+ }
543
+ }
544
+ return jsonResponse({
545
+ ok: true,
546
+ pushed,
547
+ pushCode,
548
+ status: await freshStatus(ctx, gitProvider, coordinates.cwd, request.signal),
549
+ })
550
+ }
551
+
552
+ /**
553
+ * Push the current branch (never forces).
554
+ * @param {object} ctx host context
555
+ * @param {Request} request fetch request
556
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
557
+ * @returns {Promise<Response>}
558
+ */
559
+ async function handleGitReviewPush(ctx, request, gitProvider) {
560
+ const parsed = await readJsonBody(request)
561
+ if (parsed.ok === false) return parsed.response
562
+ const coordinates = await writeCoordinates(ctx, request, parsed.body, gitProvider)
563
+ if (coordinates instanceof Response) return coordinates
564
+ const git = await createWriteGit(ctx, coordinates.git.executable)()
565
+ let result
566
+ try {
567
+ result = await pushCurrentBranch(git, { cwd: coordinates.cwd, signal: request.signal })
568
+ } catch (error) {
569
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
570
+ return failure('write-failed', String(error?.message || error).slice(0, 400), 500)
571
+ }
572
+ if (result.ok !== true) return jsonResponse({ ok: false, code: result.code, detail: result.detail }, 409)
573
+ return jsonResponse({ ok: true, status: await freshStatus(ctx, gitProvider, coordinates.cwd, request.signal) })
574
+ }
575
+
576
+ /**
577
+ * Read one file's diff against the requested baseline (staged or unstaged).
578
+ * @param {object} ctx host context
579
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
580
+ * @param {Request} request fetch request
581
+ * @returns {Promise<Response>}
582
+ */
583
+ async function handleGitDiff(ctx, gitProvider, request) {
584
+ const coordinates = sessionCoordinates(ctx, request)
585
+ if (coordinates instanceof Response) return coordinates
586
+ const url = new URL(request.url)
587
+ const path = url.searchParams.get('path') ?? ''
588
+ if (!safeRelativePath(path)) return failure('invalid-path', 'Invalid workspace path.', 400)
589
+ const scope = url.searchParams.get('scope') === 'staged' ? 'staged' : 'unstaged'
590
+ try {
591
+ const comparison = await readGitDiff(ctx, gitProvider, {
592
+ sessionId: coordinates.sessionId,
593
+ cwd: coordinates.cwd,
594
+ path,
595
+ scope,
596
+ signal: request.signal,
597
+ })
598
+ return jsonResponse(comparison)
599
+ } catch (error) {
600
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
601
+ return jsonResponse({ available: false, reason: 'diff-failed', detail: String(error?.message || error).slice(0, 400) })
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Per-line blame metadata for one file (read-only git).
607
+ * @param {object} ctx host context
608
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
609
+ * @param {Request} request fetch request
610
+ * @returns {Promise<Response>}
611
+ */
612
+ async function handleGitBlame(ctx, gitProvider, request) {
613
+ const coordinates = sessionCoordinates(ctx, request)
614
+ if (coordinates instanceof Response) return coordinates
615
+ const path = new URL(request.url).searchParams.get('path') ?? ''
616
+ if (!safeRelativePath(path)) return failure('invalid-path', 'Invalid workspace path.', 400)
617
+ try {
618
+ const blame = await readGitBlame(ctx, gitProvider, { cwd: coordinates.cwd, path, signal: request.signal })
619
+ return jsonResponse(blame)
620
+ } catch (error) {
621
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
622
+ return jsonResponse({ available: false, reason: 'blame-failed', detail: String(error?.message || error).slice(0, 400) })
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Serve one commit's metadata and message (read-only).
628
+ */
629
+ async function handleGitCommitInfo(ctx, request, gitProvider) {
630
+ const coordinates = sessionCoordinates(ctx, request)
631
+ if (coordinates instanceof Response) return coordinates
632
+ const revision = new URL(request.url).searchParams.get('revision') ?? ''
633
+ try {
634
+ const info = await readCommitInfo(ctx, gitProvider, { cwd: coordinates.cwd, revision, signal: request.signal })
635
+ return jsonResponse(info)
636
+ } catch (error) {
637
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
638
+ return jsonResponse({ available: false, reason: 'commit-failed', detail: String(error?.message || error).slice(0, 400) })
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Commit history for the graph panel (all refs, parents included).
644
+ * @param {object} ctx host context
645
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
646
+ * @param {Request} request fetch request
647
+ * @returns {Promise<Response>}
648
+ */
649
+ async function handleGitHistory(ctx, gitProvider, request) {
650
+ const coordinates = sessionCoordinates(ctx, request)
651
+ if (coordinates instanceof Response) return coordinates
652
+ const limit = Number.parseInt(new URL(request.url).searchParams.get('limit') ?? '', 10)
653
+ try {
654
+ const history = await readGitHistory(ctx, gitProvider, { cwd: coordinates.cwd, limit, signal: request.signal })
655
+ return jsonResponse(history)
656
+ } catch (error) {
657
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
658
+ return jsonResponse({ available: false, reason: 'log-failed', detail: String(error?.message || error).slice(0, 400) })
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Generate a commit message from the staged (or unstaged) changes.
664
+ * @param {object} ctx host context
665
+ * @param {Request} request fetch request
666
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
667
+ * @returns {Promise<Response>}
668
+ */
669
+ async function handleGenerateMessage(ctx, request, gitProvider) {
670
+ const parsed = await readJsonBody(request)
671
+ if (parsed.ok === false) return parsed.response
672
+ const coordinates = await writeCoordinates(ctx, request, parsed.body, gitProvider)
673
+ if (coordinates instanceof Response) return coordinates
674
+ const git = await createWriteGit(ctx, coordinates.git.executable)()
675
+ try {
676
+ const result = await generateCommitMessage(ctx, { git, cwd: coordinates.cwd, signal: request.signal })
677
+ return jsonResponse(result, result.ok === true ? 200 : 409)
678
+ } catch (error) {
679
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
680
+ return jsonResponse({ ok: false, code: 'generate-failed', detail: String(error?.message || error).slice(0, 400) }, 500)
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Working-tree status of the session repository (read-only git).
686
+ * @param {object} ctx host context
687
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
688
+ * @param {Request} request fetch request
689
+ * @returns {Promise<Response>}
690
+ */
691
+ async function handleGitStatus(ctx, gitProvider, request) {
692
+ const coordinates = sessionCoordinates(ctx, request)
693
+ if (coordinates instanceof Response) return coordinates
694
+ try {
695
+ const status = await readGitStatus(ctx, gitProvider, coordinates.cwd, request.signal)
696
+ return jsonResponse(status)
697
+ } catch (error) {
698
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
699
+ return jsonResponse({ available: false, reason: 'git-failed', detail: String(error?.message || error).slice(0, 400) })
700
+ }
701
+ }
702
+
703
+ /**
704
+ * One file's HEAD↔worktree comparison, or its comparison against a revision.
705
+ * @param {object} ctx host context
706
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
707
+ * @param {Request} request fetch request
708
+ * @returns {Promise<Response>}
709
+ */
710
+ async function handleGitFile(ctx, gitProvider, request) {
711
+ const coordinates = sessionCoordinates(ctx, request)
712
+ if (coordinates instanceof Response) return coordinates
713
+ const query = new URL(request.url).searchParams
714
+ const path = query.get('path') ?? ''
715
+ if (!safeRelativePath(path)) return failure('invalid-path', 'Invalid workspace path.', 400)
716
+ const rawRevision = query.get('rev')
717
+ if (rawRevision !== null) {
718
+ const rev = safeRevision(rawRevision)
719
+ if (!rev) return failure('invalid-revision', 'Invalid git revision.', 400)
720
+ const rawScope = query.get('scope')
721
+ const scope = rawScope === null ? 'commit' : rawScope
722
+ if (scope !== 'commit' && scope !== 'since') return failure('invalid-scope', 'Invalid diff scope.', 400)
723
+ try {
724
+ const comparison = await readGitFileAgainst(ctx, gitProvider, {
725
+ sessionId: coordinates.sessionId,
726
+ cwd: coordinates.cwd,
727
+ path,
728
+ rev,
729
+ scope,
730
+ signal: request.signal,
731
+ })
732
+ return jsonResponse(comparison)
733
+ } catch (error) {
734
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
735
+ return jsonResponse({ available: false, reason: 'git-failed', detail: String(error?.message || error).slice(0, 400) })
736
+ }
737
+ }
738
+ try {
739
+ const comparison = await readGitFile(ctx, gitProvider, {
740
+ sessionId: coordinates.sessionId,
741
+ cwd: coordinates.cwd,
742
+ path,
743
+ signal: request.signal,
744
+ })
745
+ return jsonResponse(comparison)
746
+ } catch (error) {
747
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
748
+ return jsonResponse({ available: false, reason: 'git-failed', detail: String(error?.message || error).slice(0, 400) })
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Revision picker data: HEAD metadata plus recent commits (read-only git).
754
+ * @param {object} ctx host context
755
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
756
+ * @param {Request} request fetch request
757
+ * @returns {Promise<Response>}
758
+ */
759
+ async function handleGitRevisions(ctx, gitProvider, request) {
760
+ const coordinates = sessionCoordinates(ctx, request)
761
+ if (coordinates instanceof Response) return coordinates
762
+ const query = new URL(request.url).searchParams
763
+ const rawQuery = query.get('q')
764
+ try {
765
+ const git = await gitProvider()
766
+ if (!git) return jsonResponse({ available: false, reason: 'no-git' })
767
+ const payload = await listGitRevisions(git, coordinates.cwd, {
768
+ limit: coordinate(query.get('limit')),
769
+ query: rawQuery === null ? undefined : rawQuery,
770
+ signal: request.signal,
771
+ })
772
+ return jsonResponse(payload)
773
+ } catch (error) {
774
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
775
+ return jsonResponse({ available: false, reason: 'git-failed', detail: String(error?.message || error).slice(0, 400) })
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Diff a revision against its first parent or against the working tree.
781
+ * @param {object} ctx host context
782
+ * @param {() => Promise<object | null>} gitProvider lazy git runner provider
783
+ * @param {Request} request fetch request
784
+ * @returns {Promise<Response>}
785
+ */
786
+ async function handleGitCompare(ctx, gitProvider, request) {
787
+ const coordinates = sessionCoordinates(ctx, request)
788
+ if (coordinates instanceof Response) return coordinates
789
+ const query = new URL(request.url).searchParams
790
+ const rev = safeRevision(query.get('rev') ?? '')
791
+ if (!rev) return failure('invalid-revision', 'Invalid git revision.', 400)
792
+ const rawScope = query.get('scope')
793
+ const scope = rawScope === null ? 'commit' : rawScope
794
+ if (scope !== 'commit' && scope !== 'since') return failure('invalid-scope', 'Invalid diff scope.', 400)
795
+ try {
796
+ const comparison = await readGitCompare(ctx, gitProvider, {
797
+ cwd: coordinates.cwd,
798
+ rev,
799
+ scope,
800
+ signal: request.signal,
801
+ })
802
+ return jsonResponse(comparison)
803
+ } catch (error) {
804
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
805
+ return jsonResponse({ available: false, reason: 'git-failed', detail: String(error?.message || error).slice(0, 400) })
806
+ }
807
+ }
808
+
809
+ /**
810
+ * The workspace file index for Quick Open.
811
+ * @param {object} ctx host context
812
+ * @param {WorkspaceIndex} index cached workspace index
813
+ * @param {Request} request fetch request
814
+ * @returns {Promise<Response>}
815
+ */
816
+ async function handleFiles(ctx, index, request) {
817
+ const coordinates = sessionCoordinates(ctx, request)
818
+ if (coordinates instanceof Response) return coordinates
819
+ const force = new URL(request.url).searchParams.get('refresh') === '1'
820
+ try {
821
+ const walked = await index.get({ sessionId: coordinates.sessionId, workspaceRoot: coordinates.cwd }, request.signal, force)
822
+ return jsonResponse(walked)
823
+ } catch (error) {
824
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
825
+ return failure('index-failed', 'Workspace index unavailable.', 502)
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Bounded case-insensitive text search across the workspace.
831
+ * @param {object} ctx host context
832
+ * @param {WorkspaceIndex} index cached workspace index
833
+ * @param {Request} request fetch request
834
+ * @returns {Promise<Response>}
835
+ */
836
+ async function handleSearch(ctx, index, request) {
837
+ const coordinates = sessionCoordinates(ctx, request)
838
+ if (coordinates instanceof Response) return coordinates
839
+ const query = new URL(request.url).searchParams
840
+ const result = await searchWorkspace(ctx, index, { sessionId: coordinates.sessionId, workspaceRoot: coordinates.cwd }, {
841
+ query: query.get('q') ?? '',
842
+ limit: coordinate(query.get('limit')) ?? undefined,
843
+ signal: request.signal,
844
+ })
845
+ return jsonResponse(result)
846
+ }
847
+
848
+ /**
849
+ * Language-server operations for the active file (read-only LSP features).
850
+ * @param {object} ctx host context
851
+ * @param {LspBridge} bridge language-server bridge
852
+ * @param {Request} request fetch request (JSON body)
853
+ * @returns {Promise<Response>}
854
+ */
855
+ async function handleLsp(ctx, bridge, request) {
856
+ let body
857
+ try {
858
+ body = await request.json()
859
+ } catch {
860
+ return failure('invalid-body', 'Invalid JSON body.', 400)
861
+ }
862
+ const sessionId = identifier(body && body.sessionId) ?? identifier(new URL(request.url).searchParams.get('sessionId'))
863
+ if (!sessionId) return failure('invalid-coordinates', 'Missing session id.', 400)
864
+ const session = liveSession(ctx, sessionId)
865
+ if (!session) return failure('session-not-live', 'Session is not live on this Host.', 404)
866
+ const cwd = cwdOf(session)
867
+ if (!cwd) return failure('no-cwd', 'Session has no working directory.', 404)
868
+ const coordinates = { sessionId, cwd }
869
+ const action = String((body && body.action) || '')
870
+ if (!LSP_ACTIONS.has(action)) return failure('invalid-action', 'Unsupported LSP action.', 400)
871
+ const languageId = String((body && body.languageId) || 'plaintext').slice(0, 64)
872
+ if (action === 'status') {
873
+ try {
874
+ return jsonResponse(await bridge.status(languageId, body.refresh === true))
875
+ } catch (error) {
876
+ return jsonResponse({ available: false, reason: 'probe-failed', message: String(error && error.message ? error.message : error).slice(0, 400) })
877
+ }
878
+ }
879
+ const path = body && body.path !== undefined ? String(body.path) : ''
880
+ if (!path || !safeRelativePath(path)) return failure('invalid-path', 'Invalid workspace path.', 400)
881
+ const text = typeof body.text === 'string' ? body.text : undefined
882
+ if (text !== undefined && Buffer.byteLength(text, 'utf8') > LSP_MAX_TEXT_BYTES) {
883
+ return jsonResponse({ available: false, reason: 'oversized' })
884
+ }
885
+ try {
886
+ const requested = {
887
+ sessionId: coordinates.sessionId,
888
+ cwd: coordinates.cwd,
889
+ languageId,
890
+ action,
891
+ path,
892
+ text,
893
+ line: coordinate(body.line),
894
+ column: coordinate(body.column),
895
+ }
896
+ const result = await bridge.operate(requested)
897
+ return jsonResponse(result)
898
+ } catch (error) {
899
+ if (request.signal?.aborted) return failure('aborted', 'Request aborted.', 499)
900
+ return jsonResponse({ available: false, reason: 'lsp-failed', message: String(error && error.message ? error.message : error).slice(0, 400) })
901
+ }
902
+ }
903
+
904
+ /**
905
+ * Advertise the plugin phase and feature flags so the browser hides
906
+ * unavailable affordances instead of failing at call time.
907
+ * @returns {Response}
908
+ */
909
+ function handleConfig() {
910
+ return jsonResponse({
911
+ phase: 3,
912
+ features: { turn: true, git: true, gitRevisions: true, files: true, search: true, lsp: true, wordLevel: false },
913
+ limits: { maxFileBytes: MAX_FILE_BYTES },
914
+ })
915
+ }
916
+
917
+ /**
918
+ * Register every review route inside the authenticated Fetch channel.
919
+ * @param {object} ctx host plugin context
920
+ */
921
+ export function registerApiRoutes(ctx) {
922
+ const tracker = createChangesTracker(ctx)
923
+ const gitProvider = createGitProvider(ctx)
924
+ const index = new WorkspaceIndex(ctx)
925
+ const lsp = new LspBridge(ctx, ctx.logger)
926
+ ctx.effect(() => {
927
+ const reaper = setInterval(() => {
928
+ try { lsp.reap() } catch { /* reaping is best-effort */ }
929
+ }, 60000)
930
+ try { reaper.unref?.() } catch { /* optional */ }
931
+ return () => {
932
+ clearInterval(reaper)
933
+ lsp.dispose()
934
+ }
935
+ }, 'dsh-git-changes: lsp lifecycle')
936
+ const routes = [
937
+ [ROUTES.latest, ['GET'], (request) => handleLatest(ctx, tracker, identifier(new URL(request.url).searchParams.get('sessionId')) ?? '')],
938
+ [ROUTES.summary, ['GET'], (request) => handleSummary(ctx, request)],
939
+ [ROUTES.diff, ['GET'], (request) => handleDiff(ctx, request)],
940
+ [ROUTES.file, ['GET'], (request) => handleFile(ctx, gitProvider, request)],
941
+ [ROUTES.tree, ['GET'], (request) => handleTree(ctx, request)],
942
+ [ROUTES.config, ['GET'], () => handleConfig()],
943
+ [ROUTES.gitStatus, ['GET'], (request) => handleGitStatus(ctx, gitProvider, request)],
944
+ [ROUTES.gitFile, ['GET'], (request) => handleGitFile(ctx, gitProvider, request)],
945
+ [ROUTES.gitRevisions, ['GET'], (request) => handleGitRevisions(ctx, gitProvider, request)],
946
+ [ROUTES.gitCompare, ['GET'], (request) => handleGitCompare(ctx, gitProvider, request)],
947
+ [ROUTES.files, ['GET'], (request) => handleFiles(ctx, index, request)],
948
+ [ROUTES.search, ['GET'], (request) => handleSearch(ctx, index, request)],
949
+ [ROUTES.lsp, ['POST'], (request) => handleLsp(ctx, lsp, request)],
950
+ [ROUTES.gitDiff, ['GET'], (request) => handleGitDiff(ctx, gitProvider, request)],
951
+ [ROUTES.stage, ['POST'], (request) => handleGitReviewWrite(ctx, request, gitProvider, 'stage')],
952
+ [ROUTES.unstage, ['POST'], (request) => handleGitReviewWrite(ctx, request, gitProvider, 'unstage')],
953
+ [ROUTES.commit, ['POST'], (request) => handleGitReviewCommit(ctx, request, gitProvider)],
954
+ [ROUTES.push, ['POST'], (request) => handleGitReviewPush(ctx, request, gitProvider)],
955
+ [ROUTES.discard, ['POST'], (request) => handleGitReviewDiscard(ctx, request, gitProvider)],
956
+ [ROUTES.blame, ['GET'], (request) => handleGitBlame(ctx, gitProvider, request)],
957
+ [ROUTES.log, ['GET'], (request) => handleGitHistory(ctx, gitProvider, request)],
958
+ [ROUTES.commitInfo, ['GET'], (request) => handleGitCommitInfo(ctx, request, gitProvider)],
959
+ [ROUTES.generateMessage, ['POST'], (request) => handleGenerateMessage(ctx, request, gitProvider)],
960
+ ]
961
+ for (const [path, methods, fetch] of routes) {
962
+ try {
963
+ const dispose = ctx.connection.fetch.register({ path, methods, requestBody: 'buffered', fetch })
964
+ ctx.effect(() => () => {
965
+ try {
966
+ const result = dispose()
967
+ if (result && typeof result.then === 'function') result.catch(() => {})
968
+ } catch {
969
+ /* connection teardown may already own the route */
970
+ }
971
+ }, `dsh-git-changes: ${path}`)
972
+ } catch (error) {
973
+ ctx.logger?.warn?.(`dsh-git-changes: route ${path} unavailable (${String(error)})`)
974
+ }
975
+ }
976
+ }