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.
- package/LICENSE +21 -0
- package/README.en.md +105 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/vendor/monaco/LICENSE +21 -0
- package/assets/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/assets/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +7 -0
- package/assets/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +7 -0
- package/assets/vendor/monaco/vs/base/worker/workerMain.js +26 -0
- package/assets/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/assets/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/assets/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/assets/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/assets/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/assets/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/assets/vendor/monaco/vs/editor/editor.main.css +6 -0
- package/assets/vendor/monaco/vs/editor/editor.main.js +744 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.js +28 -0
- package/assets/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +30 -0
- package/assets/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/assets/vendor/monaco/vs/language/css/cssWorker.js +81 -0
- package/assets/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/assets/vendor/monaco/vs/language/html/htmlWorker.js +453 -0
- package/assets/vendor/monaco/vs/language/json/jsonMode.js +15 -0
- package/assets/vendor/monaco/vs/language/json/jsonWorker.js +36 -0
- package/assets/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/assets/vendor/monaco/vs/language/typescript/tsWorker.js +37016 -0
- package/assets/vendor/monaco/vs/loader.js +10 -0
- package/cordis.patch.yml +3 -0
- package/docs/images/git-changes-diff.png +0 -0
- package/icon.svg +10 -0
- package/lib/client.js +9418 -0
- package/lib/host/assets.js +161 -0
- package/lib/host/commit-message.js +158 -0
- package/lib/host/files-index.js +206 -0
- package/lib/host/git-write.js +194 -0
- package/lib/host/git.js +1147 -0
- package/lib/host/lsp.js +842 -0
- package/lib/host/routes.js +976 -0
- package/lib/host/session.js +84 -0
- package/lib/host/util.js +107 -0
- package/lib/index.js +33 -0
- package/licenses/dsh-vscode-mode-LICENSE +21 -0
- package/licenses/vscode-theme-seti-LICENSE +21 -0
- package/licenses/vscode-theme-seti-ThirdPartyNotices.txt +32 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -3
- package/src/client/00-const.js +79 -0
- package/src/client/05-utils.js +277 -0
- package/src/client/10-i18n.js +548 -0
- package/src/client/12-vscode-icons.js +460 -0
- package/src/client/15-theme.js +509 -0
- package/src/client/20-monaco.js +1404 -0
- package/src/client/22-lsp.js +592 -0
- package/src/client/25-tree.js +563 -0
- package/src/client/28-changes.js +1163 -0
- package/src/client/30-page.js +2259 -0
- package/src/client/35-pill.js +185 -0
- package/src/client/40-commands.js +248 -0
- package/src/client/45-css.js +357 -0
- package/src/client/47-pickers.js +457 -0
- package/src/client/50-apply.js +207 -0
- 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
|
+
}
|