dsh-gitbar-lite 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.
@@ -0,0 +1,448 @@
1
+ /**
2
+ * The RPC surface: one exact Fetch route, one action per operation.
3
+ *
4
+ * Why one route and not one per action: `connection.fetch` matches on the exact
5
+ * pathname and method, and every client call already carries a `sessionId` plus
6
+ * whichever fields the action needs. A single `POST` with an `action` field
7
+ * keeps the wire contract in one place (mirrored verbatim in the browser half)
8
+ * instead of spreading seven path literals across two languages.
9
+ *
10
+ * Registration must go on `connection.fetch`, never on `webServer.register({
11
+ * kind: 'exact' })`: the web server resolves exact routes *before* its `/api`
12
+ * prefix route, and the fence, the browser-cookie check and the operator peer
13
+ * all hang off that prefix route. An exact route on the same path would shadow
14
+ * all three and silently expose an unauthenticated git surface.
15
+ *
16
+ * Failures are reported twice on purpose — a meaningful HTTP status *and* a
17
+ * `{ ok: false, error }` body — because the browser half reads the body and a
18
+ * non-2xx status must not be the only signal.
19
+ *
20
+ * @module dsh-gitbar-lite/host/routes
21
+ */
22
+
23
+ import { resolve } from 'node:path'
24
+
25
+ import { ACTIONS, ERROR_CODES, RPC_ROUTE } from '../shared.js'
26
+ import { classifyGitFailure, RepoError } from './repo.js'
27
+ import { trashId, workspaceKey } from './store.js'
28
+ import { requireWorkspace } from './workspace.js'
29
+
30
+ /** HTTP status per failure code. The body carries the code as well. */
31
+ const HTTP_STATUS = {
32
+ [ERROR_CODES.BAD_REQUEST]: 400,
33
+ [ERROR_CODES.UNKNOWN_ACTION]: 400,
34
+ [ERROR_CODES.EMPTY_SUMMARY]: 400,
35
+ [ERROR_CODES.TRASH_NOT_FOUND]: 404,
36
+ [ERROR_CODES.NO_WORKSPACE]: 409,
37
+ [ERROR_CODES.NO_REPO]: 409,
38
+ [ERROR_CODES.NOTHING_STAGED]: 409,
39
+ [ERROR_CODES.CONFLICT]: 409,
40
+ [ERROR_CODES.NO_REMOTE]: 409,
41
+ [ERROR_CODES.NO_UPSTREAM]: 409,
42
+ [ERROR_CODES.PUSH_REJECTED]: 409,
43
+ [ERROR_CODES.AUTH]: 401,
44
+ [ERROR_CODES.GIT_FAILED]: 500,
45
+ [ERROR_CODES.INTERNAL]: 500,
46
+ [ERROR_CODES.NO_GIT]: 503,
47
+ }
48
+
49
+ /** A JSON response that is never cached. */
50
+ export function json(payload, status = 200) {
51
+ return new Response(JSON.stringify(payload), {
52
+ status,
53
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
54
+ })
55
+ }
56
+
57
+ /** Read a required non-empty string field. */
58
+ function stringField(payload, name) {
59
+ const value = payload?.[name]
60
+ if (typeof value !== 'string' || value.trim() === '') {
61
+ throw new RepoError(ERROR_CODES.BAD_REQUEST, `缺少字段 ${name}。`)
62
+ }
63
+ return value
64
+ }
65
+
66
+ /** Read an optional array of non-empty strings. */
67
+ function stringArrayField(payload, name) {
68
+ const value = payload?.[name]
69
+ if (value === undefined) return []
70
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item === '')) {
71
+ throw new RepoError(ERROR_CODES.BAD_REQUEST, `字段 ${name} 必须是非空字符串数组。`)
72
+ }
73
+ return [...new Set(value)]
74
+ }
75
+
76
+ /** Normalize any thrown value into `{ code, message, status }`. */
77
+ export function describeError(error) {
78
+ if (error instanceof RepoError) {
79
+ return { code: error.code, message: error.message, status: HTTP_STATUS[error.code] ?? 400 }
80
+ }
81
+ if (typeof error?.code === 'string' && HTTP_STATUS[error.code] !== undefined) {
82
+ return { code: error.code, message: String(error.message ?? error), status: HTTP_STATUS[error.code] }
83
+ }
84
+ if (error?.name === 'GitFailure') {
85
+ const mapped = classifyGitFailure(error)
86
+ return { code: mapped.code, message: mapped.message, status: HTTP_STATUS[mapped.code] ?? 500 }
87
+ }
88
+ return {
89
+ code: ERROR_CODES.INTERNAL,
90
+ message: String(error?.message ?? error),
91
+ status: HTTP_STATUS[ERROR_CODES.INTERNAL],
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Build the action handlers.
97
+ *
98
+ * @param {object} deps
99
+ * @param {object} deps.ctx host context
100
+ * @param {object} deps.git runner from `createGit`
101
+ * @param {object} deps.repo facade from `createRepo`
102
+ * @param {object} deps.store from `createStore`
103
+ */
104
+ export function createHandlers({ ctx, git, repo, store }) {
105
+ /**
106
+ * Resolve the repository this session should be looking at, persisting a
107
+ * successful auto-detection so the binding survives a restart.
108
+ *
109
+ * A stale binding (the directory moved, or `.git` was removed) is dropped and
110
+ * re-detected rather than reported: a broken binding the user cannot clear
111
+ * from the UI is worse than re-deciding.
112
+ */
113
+ async function resolveBinding(workspacePath, signal) {
114
+ const bound = await store.readBinding(workspacePath)
115
+ if (bound !== undefined) {
116
+ const verified = await git.isRepoRoot(bound, signal)
117
+ if (verified !== undefined) return { repoRoot: resolve(verified), source: 'binding' }
118
+ await store.writeBinding(workspacePath, undefined)
119
+ }
120
+ const detected = await git.repoRootOf(workspacePath, signal)
121
+ if (detected === undefined) return { repoRoot: undefined, source: 'none' }
122
+ const persisted = await store.writeBinding(workspacePath, detected)
123
+ return { repoRoot: resolve(persisted ?? detected), source: 'detected' }
124
+ }
125
+
126
+ /** Resolve the bound repository or fail with the reason the UI should show. */
127
+ async function requireRepoRoot(payload, signal) {
128
+ const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
129
+ const binding = await resolveBinding(workspace.cwd, signal)
130
+ if (binding.repoRoot === undefined) {
131
+ throw new RepoError(ERROR_CODES.NO_REPO, '这个工作区还没有绑定 Git 仓库,先在上面的仓库选择里挑一个。')
132
+ }
133
+ return { workspace, repoRoot: binding.repoRoot, source: binding.source }
134
+ }
135
+
136
+ /** The whole panel state for one session. */
137
+ async function status(payload, request) {
138
+ const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
139
+ const prefs = await store.readPrefs()
140
+ const binding = await resolveBinding(workspace.cwd, request.signal)
141
+
142
+ if (binding.repoRoot === undefined) {
143
+ const scan = await repo.scanRepos(workspace.cwd, request.signal)
144
+ return {
145
+ workspacePath: workspace.cwd,
146
+ workspaceTitle: workspace.workspaceTitle,
147
+ repoRoot: null,
148
+ source: binding.source,
149
+ candidates: scan.repos,
150
+ scanTruncated: scan.truncated,
151
+ prefs,
152
+ }
153
+ }
154
+
155
+ const state = await repo.readStatus(binding.repoRoot, request.signal)
156
+ return {
157
+ workspacePath: workspace.cwd,
158
+ workspaceTitle: workspace.workspaceTitle,
159
+ source: binding.source,
160
+ prefs,
161
+ ...state,
162
+ }
163
+ }
164
+
165
+ /** Bind this workspace to a chosen repository, or clear the binding. */
166
+ async function bind(payload, request) {
167
+ const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
168
+ const requested = payload?.repoRoot
169
+
170
+ if (requested === null) {
171
+ await store.writeBinding(workspace.cwd, undefined)
172
+ return { cleared: true, workspacePath: workspace.cwd }
173
+ }
174
+ if (typeof requested !== 'string' || requested.trim() === '') {
175
+ throw new RepoError(ERROR_CODES.BAD_REQUEST, 'repoRoot 必须是非空路径字符串,或 null。')
176
+ }
177
+
178
+ const verified = await git.isRepoRoot(requested.trim(), request.signal)
179
+ if (verified === undefined) {
180
+ throw new RepoError(ERROR_CODES.NO_REPO, `这个目录不是 Git 仓库的根:${requested.trim()}`)
181
+ }
182
+ const repoRoot = await store.writeBinding(workspace.cwd, verified)
183
+ return { workspacePath: workspace.cwd, repoRoot: resolve(repoRoot ?? verified), bound: true }
184
+ }
185
+
186
+ /** Candidate repositories for the picker. */
187
+ async function repos(payload, request) {
188
+ const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
189
+ const binding = await resolveBinding(workspace.cwd, request.signal)
190
+ const scan = await repo.scanRepos(workspace.cwd, request.signal)
191
+ return {
192
+ workspacePath: workspace.cwd,
193
+ workspaceTitle: workspace.workspaceTitle,
194
+ repoRoot: binding.repoRoot ?? null,
195
+ source: binding.source,
196
+ candidates: scan.repos,
197
+ scanned: scan.scanned,
198
+ scanTruncated: scan.truncated,
199
+ }
200
+ }
201
+
202
+ /**
203
+ * The single context button, which is either a push or a fetch.
204
+ *
205
+ * GitHub Desktop's rule, and the client's too: a branch with local commits the
206
+ * upstream does not have offers Push; otherwise the useful thing to do is
207
+ * refresh the remote, so it offers Fetch. The host does not re-decide here —
208
+ * the client already knows which one it drew, and re-deriving it would let the
209
+ * button and the action disagree.
210
+ */
211
+ async function primaryAction(kind, payload, request) {
212
+ const { workspace, repoRoot } = await requireRepoRoot(payload, request.signal)
213
+ const outcome = kind === 'push' ? await repo.push(repoRoot, request.signal) : await repo.fetch(repoRoot, request.signal)
214
+ const state = await repo.readStatus(repoRoot, request.signal)
215
+ return { action: kind, outcome, repoRoot, workspacePath: workspace.cwd, ...state }
216
+ }
217
+
218
+ return {
219
+ [ACTIONS.STATUS]: status,
220
+
221
+ [ACTIONS.BIND]: bind,
222
+
223
+ [ACTIONS.REPOS]: repos,
224
+
225
+ async [ACTIONS.STAGE](payload, request) {
226
+ const { workspace, repoRoot } = await requireRepoRoot(payload, request.signal)
227
+ const paths = stringArrayField(payload, 'paths')
228
+ if (paths.length === 0) throw new RepoError(ERROR_CODES.BAD_REQUEST, 'paths 不能为空。')
229
+ await repo.setStaged(repoRoot, paths, payload?.staged === true, request.signal)
230
+ const state = await repo.readStatus(repoRoot, request.signal)
231
+ return { repoRoot, workspacePath: workspace.cwd, ...state }
232
+ },
233
+
234
+ async [ACTIONS.COMMIT](payload, request) {
235
+ const { workspace, repoRoot } = await requireRepoRoot(payload, request.signal)
236
+ const commit = await repo.commit(
237
+ repoRoot,
238
+ { summary: payload?.summary, description: payload?.description },
239
+ request.signal,
240
+ )
241
+ const state = await repo.readStatus(repoRoot, request.signal)
242
+ return { commit, repoRoot, workspacePath: workspace.cwd, ...state }
243
+ },
244
+
245
+ /**
246
+ * Commit, then push, reporting the two halves separately.
247
+ *
248
+ * If the push fails the commit has already happened, and saying "it failed"
249
+ * would be a lie the user acts on. So the commit result is returned as
250
+ * success and the push carries its own failure.
251
+ */
252
+ async [ACTIONS.COMMIT_PUSH](payload, request) {
253
+ const { workspace, repoRoot } = await requireRepoRoot(payload, request.signal)
254
+ const commit = await repo.commit(
255
+ repoRoot,
256
+ { summary: payload?.summary, description: payload?.description },
257
+ request.signal,
258
+ )
259
+
260
+ let push
261
+ try {
262
+ push = { ok: true, ...(await repo.push(repoRoot, request.signal)) }
263
+ } catch (error) {
264
+ push = { ok: false, error: describeError(error) }
265
+ }
266
+
267
+ const state = await repo.readStatus(repoRoot, request.signal)
268
+ return { commit, push, repoRoot, workspacePath: workspace.cwd, ...state }
269
+ },
270
+
271
+ [ACTIONS.PUSH]: (payload, request) => primaryAction('push', payload, request),
272
+
273
+ [ACTIONS.FETCH]: (payload, request) => primaryAction('fetch', payload, request),
274
+
275
+ async [ACTIONS.DISCARD](payload, request) {
276
+ const { workspace, repoRoot } = await requireRepoRoot(payload, request.signal)
277
+ const paths = payload?.paths === null || payload?.paths === undefined ? null : stringArrayField(payload, 'paths')
278
+ const hardDelete = payload?.hardDelete === true
279
+
280
+ const discard = await repo.discard({
281
+ repoRoot,
282
+ workspacePath: workspace.cwd,
283
+ sessionId: workspace.sessionId,
284
+ paths,
285
+ hardDelete,
286
+ signal: request.signal,
287
+ // The entry id is minted here rather than in `repo.discard` so the git
288
+ // layer stays free of any notion of where archives are stored.
289
+ archive: (entry, diffText) =>
290
+ store.appendTrash({ ...entry, id: trashId(), at: new Date().toISOString() }, diffText),
291
+ })
292
+
293
+ // Remember the choice: the confirmation box opens on whatever the user
294
+ // picked last time, and a first-time user gets the safe answer (unticked).
295
+ await store.writePrefs({ trashHardDelete: hardDelete })
296
+
297
+ const state = await repo.readStatus(repoRoot, request.signal)
298
+ return { discard, repoRoot, workspacePath: workspace.cwd, ...state }
299
+ },
300
+
301
+ async [ACTIONS.FILE_DIFF](payload, request) {
302
+ const { repoRoot } = await requireRepoRoot(payload, request.signal)
303
+ const path = stringField(payload, 'path')
304
+ const diff = await repo.fileDiff(repoRoot, path, request.signal)
305
+ return { repoRoot, diff }
306
+ },
307
+
308
+ async [ACTIONS.TRASH_LIST](payload, request) {
309
+ const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
310
+ const prefs = await store.readPrefs()
311
+ const binding = await resolveBinding(workspace.cwd, request.signal)
312
+ const showAll = payload?.all === true || (payload?.all === undefined && prefs.trashShowAllRepos)
313
+ const all = await store.readTrash()
314
+
315
+ const filtered =
316
+ showAll || binding.repoRoot === undefined
317
+ ? all
318
+ : all.filter((entry) => typeof entry.repoRoot === 'string' && workspaceKey(entry.repoRoot) === workspaceKey(binding.repoRoot))
319
+
320
+ return {
321
+ workspacePath: workspace.cwd,
322
+ repoRoot: binding.repoRoot ?? null,
323
+ showAll,
324
+ total: all.length,
325
+ entries: filtered.map((entry) => ({
326
+ id: entry.id,
327
+ at: entry.at,
328
+ repoRoot: entry.repoRoot,
329
+ repoName: entry.repoName,
330
+ workspacePath: entry.workspacePath ?? null,
331
+ scope: entry.scope,
332
+ branch: entry.branch ?? null,
333
+ base: entry.base ?? null,
334
+ files: Array.isArray(entry.files)
335
+ ? entry.files.map((file) => ({
336
+ path: file.path,
337
+ origPath: file.origPath ?? null,
338
+ kind: file.kind,
339
+ added: file.added ?? 0,
340
+ deleted: file.deleted ?? 0,
341
+ binary: file.binary === true,
342
+ oversized: file.oversized === true,
343
+ }))
344
+ : [],
345
+ bytes: entry.bytes ?? 0,
346
+ truncated: entry.truncated === true,
347
+ droppedLines: entry.droppedLines ?? 0,
348
+ })),
349
+ }
350
+ },
351
+
352
+ async [ACTIONS.TRASH_DIFF](payload) {
353
+ const entry = await store.readTrashEntry(stringField(payload, 'id'))
354
+ if (entry === undefined) throw new RepoError(ERROR_CODES.TRASH_NOT_FOUND, '这条回收站记录已经不存在了。')
355
+ const text = await store.readTrashDiff(entry)
356
+ if (text === undefined) {
357
+ throw new RepoError(ERROR_CODES.TRASH_NOT_FOUND, '这条记录的 diff 文件已经不在了(可能被手工删掉了)。')
358
+ }
359
+ return {
360
+ entry: { id: entry.id, at: entry.at, scope: entry.scope, base: entry.base ?? null, truncated: entry.truncated === true },
361
+ text,
362
+ }
363
+ },
364
+
365
+ async [ACTIONS.TRASH_DELETE](payload) {
366
+ const id = stringField(payload, 'id')
367
+ const removed = await store.removeTrash(id)
368
+ if (!removed) throw new RepoError(ERROR_CODES.TRASH_NOT_FOUND, '这条回收站记录已经不存在了。')
369
+ return { removed: id }
370
+ },
371
+
372
+ async [ACTIONS.TRASH_CLEAR](payload, request) {
373
+ if (payload?.all === true) return { removed: await store.clearTrash() }
374
+
375
+ const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
376
+ const binding = await resolveBinding(workspace.cwd, request.signal)
377
+ if (binding.repoRoot === undefined) {
378
+ throw new RepoError(ERROR_CODES.NO_REPO, '没有绑定仓库,无法确定要清空哪个仓库的回收站。')
379
+ }
380
+ const all = await store.readTrash()
381
+ const mine = all.filter(
382
+ (entry) => typeof entry.repoRoot === 'string' && workspaceKey(entry.repoRoot) === workspaceKey(binding.repoRoot),
383
+ )
384
+ for (const entry of mine) await store.removeTrash(entry.id)
385
+ return { removed: mine.length }
386
+ },
387
+
388
+ async [ACTIONS.PREFS](payload) {
389
+ const hasWrite = payload?.trashHardDelete !== undefined || payload?.trashShowAllRepos !== undefined
390
+ if (!hasWrite) return { prefs: await store.readPrefs() }
391
+ const prefs = await store.writePrefs({
392
+ trashHardDelete: payload?.trashHardDelete,
393
+ trashShowAllRepos: payload?.trashShowAllRepos,
394
+ })
395
+ return { prefs }
396
+ },
397
+ }
398
+ }
399
+
400
+ /**
401
+ * The exact Fetch route to register on `connection.fetch`.
402
+ *
403
+ * @param {object} options
404
+ * @param {Record<string, (payload: object, request: Request) => Promise<object>>} options.handlers
405
+ * @param {{ warn: (message: string) => void }} [options.logger]
406
+ */
407
+ export function createRoute({ handlers, logger }) {
408
+ return {
409
+ path: RPC_ROUTE,
410
+ methods: ['POST'],
411
+ requestBody: 'buffered',
412
+ /**
413
+ * @param {Request} request
414
+ * @returns {Promise<Response>}
415
+ */
416
+ async fetch(request) {
417
+ let payload
418
+ try {
419
+ payload = await request.json()
420
+ } catch {
421
+ return json({ ok: false, error: { code: ERROR_CODES.BAD_REQUEST, message: '请求体不是合法 JSON。' } }, 400)
422
+ }
423
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
424
+ return json({ ok: false, error: { code: ERROR_CODES.BAD_REQUEST, message: '请求体必须是一个 JSON 对象。' } }, 400)
425
+ }
426
+
427
+ const action = typeof payload.action === 'string' ? payload.action : ''
428
+ const handler = Object.prototype.hasOwnProperty.call(handlers, action) ? handlers[action] : undefined
429
+ if (typeof handler !== 'function') {
430
+ return json(
431
+ { ok: false, error: { code: ERROR_CODES.UNKNOWN_ACTION, message: `未知的 action:${JSON.stringify(action)}` } },
432
+ 400,
433
+ )
434
+ }
435
+
436
+ try {
437
+ const data = await handler(payload, request)
438
+ return json({ ok: true, ...data })
439
+ } catch (error) {
440
+ const described = describeError(error)
441
+ if (described.code === ERROR_CODES.INTERNAL) {
442
+ logger?.warn?.(`dsh-gitbar-lite: action ${action} failed: ${described.message}`)
443
+ }
444
+ return json({ ok: false, error: { code: described.code, message: described.message } }, described.status)
445
+ }
446
+ },
447
+ }
448
+ }